简介
随着JavaScript脚本越发复杂,大部分代码都需要经过转换才能投入生产。而转换后的代码位置、变量名都已改变,如果定位转换后的代码,这就是source-map要解决的问题
source-map 存储了压缩后代码相对于源代码的位置信息,常用于JavaScript代码调试;一般以.map结尾的json文件存储
source-map的构成
基本结构
- version:source-map的版本,目前为3
- sources:转换前的文件url数组
- names:转换前可以用于映射的变量名和属性名
- file:源文件的文件名
- sourceRoot:源文件的目录前缀(url)
- sourcesContent: sources对应的源文件内容
- mappings:记录位置信息的VLQ编码字符串,下文讲解
JSON示例(webpack)
{
# @param {Number} source-map的版本,目前为3
"version": 3,
# @param {Array<String>} 转换前的文件url数组
"sources": [
"webpack://utils/webpack/universalModuleDefinition",
"webpack://utils/./src/utils.js"
],
# @param {Array<String>} 转换前可以用于映射的变量名和属性名
"names": ["add", "a", "b", "Error"],
# @param {String} base64的VLQ编码
"mappings": "AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,CAAC;AACD,O;;;;;;;;;;ACVO,SAASA,GAAT,CAAcC,CAAd,EAAiBC,CAAjB,EAAoB;AACvB,QAAMC,KAAK,CAAC,MAAD,CAAX;AACA,SAAOF,CAAC,GAAGC,CAAX;AACH,C",
# @param {Array<String>} sources对应的源文件内容
"sourcesContent": [
"(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"utils\"] = factory();\n\telse\n\t\troot[\"utils\"] = factory();\n})(self, function() {\nreturn ",
"export function add (a, b) {\r\n throw Error('test')\r\n return a + b\r\n}"],
# @param {String} 源文件的目录前缀(url)
"sourceRoot": "/path/to/static/assets",
# @param {String} 源文件的文件名
"file": "utils.js"
}
复制代码
【注】JSON没有注释语句,代码中#仅作解释说明
mappings属性
VQL编码后
VQL编码后的mappings字符串划分为三层:
每行用分号(;)划分,行内每个位置信息用逗号(,)划分,具体的行列位置记录用VLQ编码存储,即
mappings:"AAAAA,BBBBB;CCCCC"
复制代码
表示转换后的源码有两行,第一行记录有两个变量名和属性名位置,第二行只有一个两个位置记录
【注意】
-
通过以上可知基于位置的映射关系是模糊映射,即仅能指出具体某个(行列)位置所在的局域内
-
VLQ编码后的字符串是变长,如上文
JSON示例(webpack)
中的mapping属性所示:GAAGC,CAAX;AACH,C
VLQ编码前
编码前的位置信息由五位组成:
- 第一位,表示这个位置在(转换后的代码的)的第几列。
- 第二位,表示这个位置属于sources属性中文件(名)的序列号。
- 第三位,表示这个位置属于转换前代码的第几行。
- 第四位,表示这个位置属于转换前代码的第几列。
- 第五位,表示这个位置属于names属性中(变量名和属性名)数组的序列号。
【注意】
- 第五位不是必需的,如果该位置没有对应names属性中的变量,可以省略第五位
- 在实际应用中,每行的VLQ编码前映射信息是相对前一位置的相对位置(节省空间?)
// 编码后:
const mappings = "AACA;AACA,CAAC;"
// 解码(下文说明):
const decodedMappings = decode(mappings) // [[[0, 0, 1, 0]], [[0, 0, 1, 0],[1, 0, 0, 1]]]
复制代码
VLQ解码
应用vlq库可对mapping信息进行解码,但由于行内的位置编码是相对位置,所以获取每行的标志位置的绝对定位需要累计位置信息
/* @param {Object} rawMap 原json格式的source-map文件
* @return {Array} decoded 以编译后的文件行信息数组
[
// 第一行
[ // 第一个位置
segment,
segment,
...
]
...
]
*/
function decode(rawMap) {
const {mappings, sources, names} = rawMap
const {decode} = require('vlq')
// 相对位置解码
const lines = mappings.split(';');
const linesWithRelativeSegs = lines.map((line) => {
const segments = line.split(',')
return segments.map((x) => {
return decode(x)
})
})
const absSegment = [0, 0, 0, 0, 0]
const decoded = linesWithRelativeSegs.map((line) => {
// 每行的第一个segment的列位置需要重置
absSegment[0] = 0
if (line.length == 0) {
return []
}
return line.map((segment) => {
const result = []
for (let i = 0; i < segment.length; i++) {
// 累计转换为绝对位置
absSegment[i] += segment[i]
result.push(absSegment[i])
}
return result
// 返回完整的绝对路径segment
// return absSegment.slice()
})
})
// fwz: 为了行号和Array的index对应
decoded.unshift([])
return decoded
}
复制代码
实际场景
以前端性能监控场景为例,通过前端回传的文件路径及行列号信息,即可通过source-map映射到源文件的具体位置
// 前端报错信息返回的文件路径(拆分文件不同source-map映射时使用)、行列号
window.addEventListener('error', e => {
console.log(e)
const {filename, lineno, colno} = e
})
复制代码
根据编译后的行列号及对应的source-map,获取源文件行列内容
const { codeFrameColumns } = require("@babel/code-frame")
// 通过编译后文件的行列位置 获取 源文件行列位置
originalPositionFor(rawMap, lineno, colno) {
const decodedMap = decode(rawMap)
const lineInfo = this.decodedMap[line]
if (!lineInfo) throw Error(`不存在该行信息:${line}`)
const columnInfo = lineInfo[column]
for (let i = lineInfo.length - 1; i > -1; i--) {
const seg = lineInfo[i]
// fwz: 不能用全等(===)匹配列号:因为输入列不一定是VLQ编码记录的位置
if(seg[0] <= column){
const [column, sourceIdx, origLine, origColumn] = seg;
const source = rawMap.sources[sourceIdx]
const sourceContent = rawMap.sourcesContent[sourceIdx];
// 即可获得lineno, colno对应的位置是 `source`文件`sourceContent`内容的 第`origLine+1`行, 第`origColumn+1`列(行号=数组位置+1)
// 通过 @babel/code-frame 的 codeFrameColumns 可清楚展示具体代码内容,下图所示
const result = codeFrameColumns(sourceContent, {
start: {
line: origLine+1,
column: origColumn+1
}
}, {forceColor:true})
return {
source,
line: origLine,
column: origColumn,
frame: result
}
}
}
throw new Error(`不存在该行列号信息:${line},${column}`)
}
}
复制代码
展示结果 @babel/code-frame
source-map 库实现
实际上以上过程,source-map库已实现并封装,可通过直接应用调用API进行解析
source-map使用(consume)
// 官网例子
const sourceMap = require('source-map')
const {SourceMapConsumer} = sourceMap
SourceMapConsumer.with(rawSourceMap, null, consumer => {
console.log('consumer: ', consumer)
// { source: 'http://example.com/www/js/two.js',
// line: 2,
// column: 10,
// name: 'n' }
consumer.originalPositionFor({line: 19, column: 9})
// mappings属性各行中的segment位置对应信息
consumer.eachMapping(function(m) {
/* {
generatedLine: 21,
generatedColumn: 0,
lastGeneratedColumn: null,
source: 'webpack://utils/src/index.js',
originalLine: 12,
originalColumn: 2,
name: null
}*/
console.log(m)
});
})
复制代码
source-map生成
source-map的生成过程贯穿整个js重新生成的过程:通过解析器(如jison库)将JavaScrip解析t为抽象语法树(AST),在遍历AST生成压缩代码的同时生成存储关联信息的source map
source-map库提供了两级接口:
(1)高层接口SourceNode :
function compile(ast) {
switch (ast.type) {
case "BinaryExpression":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, [
compile(ast.left),
" + ",
compile(ast.right)
]);
case "Literal":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, String(ast.value));
// ...
default:
throw new Error("Bad AST");
}
}
const ast = parse("40 + 2", "add.js");
// { code: '40 + 2', map: [object SourceMapGenerator] }
console.log(
compile(ast).toStringWithSourceMap({
file: "add.js"
})
);
复制代码
(2)底层接口SourceMapGenerator : 还需提供生成后(generated)的位置信息
var map = new SourceMapGenerator({ file: "source-mapped.js" })
map.addMapping({
generated: {
line: 10,
column: 35
},
source: "foo.js",
original: {
line: 33,
column: 2
},
name: "christopher"
});
// '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'
console.log(map.toString());
复制代码
SourceMap 全链路支持
在多类型多文件加载的情况下会更复杂,如将A编译生成B with SourceMap, 再将B进一步编译为C with Source Map2, 如何将source map 合并,并从C反解回A呢?
幸运的是,部分工具在转换bundle时提供响应的接口,以Rollup 为例
import ts from 'typescript';
import { minify } from 'terser';
import babel from '@babel/core';
import fs from 'fs';
import remapping from '@ampproject/remapping';
const code = `
const add = (a,b) => {
return a+b;
}
`;
const transformed = babel.transformSync(code, {
filename: 'origin.js',
sourceMaps: true,
plugins: ['@babel/plugin-transform-arrow-functions']
});
console.log('transformed code:', transformed.code);
console.log('transformed map:', transformed.map);
const minified = await minify(
{
'transformed.js': transformed.code
},
{
sourceMap: {
includeSources: true
}
}
);
console.log('minified code:', minified.code);
console.log('minified map', minified.map);
const mergeMapping = remapping(minified.map, (file) => {
if (file === 'transformed.js') {
return transformed.map;
} else {
return null;
}
});
fs.writeFileSync('remapping.js', minified.code);
fs.writeFileSync('remapping.js.map', minified.map);
//fs.writeFileSync('remapping.js.map', JSON.stringify(mergeMapping));
复制代码
该小节全沿用ByteDance Web Infra, 请看原文
【注】原文Error Stack Trace分析很开眼界!
参考
git库/工具
article
阮一峰 JavaScript Source Map 详解
Introduction to JavaScript Source Maps
Source Map Revision 3 Proposal