如何从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
中的originalPositionFor
、sources
、sourcesContent
等属性或方法。
这里我们定位到了一个关键的库: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
通过上面的sources
和sourceContentFor
,似乎就可以获取到所有源码了。
在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.js
和test.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
生成的文件如下:
可以看出,它恢复出了源代码中的文件。
在代码中试试加目录层级,也可以完美还原。
注意并不是所有的代码都能还原出原来的source,webpack loader可能会在中间修改代码,另外babel-loader等代码转译工具可能未提供完整的source map,这样恢复出来的source map就是babel处理之后的代码了。
以上就是source-map还原的整个过程,最新的还原脚本可以在如下的Gist路径找到:https://gist.github.com/banyudu/b17a9cb3f05296b76a9f3051f66c3dcd