logo

鱼肚的博客

Don't Repeat Yourself

如何从JS的source map中还原源代码

在现代的前端开发过程中,source map是非常常见的,无论是babel转码、还是webpack打包,或者typescript编译,都会生成一份.map文件,就是我们说的source map文件。

首先,在Chrome等浏览器的devtools中可以看到源代码,说明根据.map肯定是能还原源代码的。不过浏览器只提供了单个文件的方式,对于想在工程级别还原整个代码的需求,还是不太方便。

下面我们通过分析一些现有的工具,一步步解析这个过程。

寻找合适的研究对象

前一段时间处理兼容性问题的时候,遇到过一个es代码检查工具es-check。它的能力还不错,就是报错的时候比较难懂。后来发现有人基于它做了一个改进,es-check-format,支持定位到具体的问题代码。这个里面就涉及到了对 source-map 的解析,我拿它作为一个分析对象。

另外通过搜索,找到了一个可以从source map中还原代码的工具:shuji,这个工具大体上已经能满足需求了。

下面就通过分析这两个对象,研究source map的还原过程。

分析对象

分析es-check-format

es-check-format是在原来的es-check上添加的source map解析,理论上来说比较容易定位到具体的改动。所以先从它开始分析。

es-check-format的commit数不多,很容易可以定位到关键代码来自于Commit: https://github.com/okbeng03/es-check-format/commit/fdf0230f21a22ecaa10ce6db1e78a42cfcb48c11

其中的关键代码如下:

1const sourceMap = require('source-map')
2
3async function getSource (root, errors) {
4  for (let err of errors) {
5    try {
6      // 如果有sourcemap文件,通过sourcemap定位到源文件
7      const source = fs.readFileSync(path.resolve(root, err.file + '.map')).toString()
8      const consumer = await new sourceMap.SourceMapConsumer(source)
9      const sm = consumer.originalPositionFor({
10        line: err.line,
11        column: err.column
12      })
13      const sources = consumer.sources
14      const smIndex = sources.indexOf(sm.source)
15      const smContent = consumer.sourcesContent[smIndex]
16      const rawLines = smContent.split(/\r?\n/g)
17
18      err.source = sm.source
19      err.line = sm.line
20      err.column = sm.column
21      err.code = rawLines[sm.line - 1]
22    } catch (rErr) {
23      err.source = '-'
24      err.code = '-'
25    }
26  }
27
28  return errors
29}

可以看到这里是用了Mozilla提供的source-map这个库做的解析,用到了SourceMapConsumer中的originalPositionForsourcessourcesContent等属性或方法。

这里我们定位到了一个关键的库:source-map,不过这个demo是针对具体的行、列编码还原代码片断的,还不能完全满足需求。

下面先看下source-map提供的SourceMapConsumer的文档。

查看SourceMapConsumer的文档

SourceMapConsumer中提供了如下的API:

  • originalPositionFor 根据编译后文件的 col/line 属性获取源代码中的col/line
  • sourceContentFor 根据文件名获取文件内容
  • sources 获取文件列表

其它API可参考其文档:https://www.npmjs.com/package/source-map#table-of-contents

通过上面的sourcessourceContentFor,似乎就可以获取到所有源码了。

shuji的代码中,搜索sourceContentFor也能看到类似代码:

1  if (consumer.hasContentsOfAllSources()) {
2    if (options.verbose) {
3      console.log('All sources were included in the sourcemap');
4    }
5
6    consumer.sources.forEach((source) => {
7      const contents = consumer.sourceContentFor(source);
8
9      console.log('source', source);
10      console.log('path.basename(source)', path.basename(source));
11
12      map[path.basename(source)] = contents;
13    });
14  }

下面我们通过实例验证一下:

实例验证

编写脚本

首先,根据上面的推论,写一个小脚本extract-source-from-source-map.ts

1#!/usr/bin/env ts-node
2import * as fs from 'fs'
3import * as path from 'path'
4import { promisify } from 'util'
5import { SourceMapConsumer } from 'source-map'
6
7const writeFile = promisify(fs.writeFile)
8
9const mapFile = process.argv[2]
10
11if (!mapFile) {
12  console.error('no input file given')
13  process.exit(1)
14}
15
16const mapFileContent = fs.readFileSync(mapFile, 'utf-8')
17const outputDir = path.join(__dirname, 'output')
18fs.mkdirSync(outputDir, {
19  recursive: true
20})
21new SourceMapConsumer(mapFileContent).then(consumer => {
22  Promise.all(consumer.sources.map(async (source) => {
23    const content = consumer.sourceContentFor(source)
24    const outputPath = path.join(outputDir, source)
25    fs.mkdirSync(path.dirname(outputPath), { recursive: true })
26    return writeFile(outputPath, content)
27  }))
28}).catch(console.error)

再给它加个可执行权限:

1chmod a+x extract-source-from-source-map.ts

测试单文件

使用babel为单文件生成source map

先编写一个简单的JS文件test.js:

1const foo = (...args) => {
2  console.log(args)
3}

使用下面的命令生成test.min.jstest.min.js.map:

1babel test.js --presets=@babel/preset-env --source-maps --out-file test.min.js

再使用extract-source-from-source-map.ts尝试提取:

1./extract-source-from-source-map.ts test.min.js.map

可以看到此时生成了一个output文件夹,里面有一个test.js文件,和测试文件内容相同。

说明简单的babel source map测试成功。

测试多文件

这里我们使用create-react-app生成一个简单的react工程,并测试其产出的 index.min.js.map

首先使用create-react-app生成一个工程my-app:

1npx create-react-app my-app

然后执行构建:

1yarn build

build/static/js目录中,生成了如下的文件:

1➜  my-app git:(master) tree build/static/js 
2build/static/js
3├── 2.763b34e5.chunk.js
4├── 2.763b34e5.chunk.js.LICENSE.txt
5├── 2.763b34e5.chunk.js.map
6├── main.ed0de294.chunk.js
7├── main.ed0de294.chunk.js.map
8├── runtime-main.83c3e0c4.js
9└── runtime-main.83c3e0c4.js.map
10
110 directories, 7 files

因为CRA默认做了bundle拆分,所以生成了多个bundle,这里我们以main为例,做下测试:

1./extract-source-from-source-map.ts main.ed0de294.chunk.js

生成的文件如下:

image-20201002092823117

可以看出,它恢复出了源代码中的文件。

在代码中试试加目录层级,也可以完美还原。

注意并不是所有的代码都能还原出原来的source,webpack loader可能会在中间修改代码,另外babel-loader等代码转译工具可能未提供完整的source map,这样恢复出来的source map就是babel处理之后的代码了。

以上就是source-map还原的整个过程,最新的还原脚本可以在如下的Gist路径找到:https://gist.github.com/banyudu/b17a9cb3f05296b76a9f3051f66c3dcd