SourceMap简介
什么是SourceMap
SourceMap,中文名叫"源映射"。在前端开发中,打包后的文件中除了我们写的代码与npm包的代码之外,经常还会出现一个后缀名为.map的文件。这就是SourceMap文件,也是我们今天要讲的主题。
我们写的代码一般并不直接作为成果提供,而且使用各类框架处理,否则大多数代码也没办法直接在浏览器运行。代码通常需要经过转义,打包,压缩,混淆等操作,最后作为成果提供。但这时候生成的代码与我们写的代码相比,已经面目全非了(尤其是代码量很多的项目)。如果此时代码在运行中报错,我们很难找到错误原因,以及源代码中的错误位置。
因此,很多前端工具在修改完代码后,会生成一个SourceMap文件,这个文件中记录了我们写的源代码和生成代码中标识符(主要包含变量名,属性名等)的文件位置对应关系。有了这个关系之后,当代码出错时,浏览器或者其它工具可以将出错位置定位到源代码的具体位置上,方便排查运行时问题。如果没有这个文件,则只能定位到生成代码的位置。例如这是经过压缩后的部分jQuery代码,在这种生成代码中排查问题太难了。

SourceMap的历史
- 在2009年时,Google推出了一个JavaScript代码压缩工具Cloure Compiler。在推出时,还附带了一个浏览器调试插件Closure Inspector,方便调试生成的代码。这个工具就是SourceMap的雏形。(第一版)
- 在2010年时,Closure Compiler Source Map 2.0中,SourceMap确定了统一的JSON格式,使用Base64编码等,这时候的SourceMap已经基本成型。(第二版)
- 在2011年,Source Map Revision 3 Proposal中,此时SourceMap已经脱离了Closure Compiler,成为了独立的工具。这一代使用Base64 VLQ编码,压缩了文件体积。这是第三版,也是现在广泛流行的,作为标准使用的版本。
这三个版本的map文件文件体积逐渐缩小,但即使是第三版,也要比源文件更大。SourceMap一开始作为一款Cloure Compiler的辅助小工具诞生,最后却被当作标准广泛应用,名气比Cloure Compiler本身要大的多。历史内容基本来源于网络。
转换代码工具生成SourceMap
JavaScript中有非常多转换代码的工具,这些工具大多数在转换代码的同时,都提供了SourceMap生成功能。这里我们选择两个来介绍一下。首先构造一个要被转换的源代码:
js
const globaljz = 123;
function fun() {
const jzplp1 = "a" + "b";
const jzplp2 = 12345;
const jzplp3 = { jz1: 1, jz2: 1221 };
try {
jzplp1();
} catch (e) {
console.log(e);
throw e;
}
console.log(jzplp1, jzplp2, jzplp3);
}
fun();
Babel生成SourceMap
Babel是一个JavaScript编译器,主要作用是将新版本的ECMAScript代码转义为兼容的旧版本JavaScript代码,之前我们有文章介绍过:解锁Babel核心功能:从转义语法到插件开发。Babel也带有生成SourceMap的功能,首先配置babel.config.json:
json
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1"
}
}
]
],
"sourceMaps": true
}
presets中是代码转义配置,"sourceMaps": true
是生成独立文件的SourceMap。执行命令行babel src/index.js --out-file dist.js
转义代码后,我们看一下生成结果。首先是生成的代码dist.js:
js
"use strict";
var globaljz = 123;
function fun() {
var jzplp1 = "a" + "b";
var jzplp2 = 12345;
var jzplp3 = {
jz1: 1,
jz2: 1221
};
try {
jzplp1();
} catch (e) {
console.log(e);
throw e;
}
console.log(jzplp1, jzplp2, jzplp3);
}
fun();
//# sourceMappingURL/*防止报错*/=dist.js.map
可以看到不仅行数变化,部分语法也被转义了。代码的最后一行有个注释,指向了SourceMap文件dist.js.map。这是一个JSON文件,我们看一下文件内容:
json
{
"version": 3,
"file": "dist.js",
"names": [
"globaljz",
"fun",
"jzplp1",
"jzplp2",
"jzplp3",
"jz1",
"jz2",
"e",
"console",
"log"
],
"sources": [
"src/index.js"
],
"sourcesContent": [
"const globaljz = 123;\r\nfunction fun() {\r\n const jzplp1 = \"a\" + \"b\";\r\n const jzplp2 = 12345;\r\n const jzplp3 = { jz1: 1, jz2: 1221 };\r\n try {\r\n jzplp1();\r\n } catch (e) {\r\n console.log(e);\r\n throw e;\r\n }\r\n console.log(jzplp1, jzplp2, jzplp3);\r\n}\r\nfun();\r\n"
],
"mappings": ";;AAAA,IAAMA,QAAQ,GAAG,GAAG;AACpB,SAASC,GAAGA,CAAA,EAAG;EACb,IAAMC,MAAM,GAAG,GAAG,GAAG,GAAG;EACxB,IAAMC,MAAM,GAAG,KAAK;EACpB,IAAMC,MAAM,GAAG;IAAEC,GAAG,EAAE,CAAC;IAAEC,GAAG,EAAE;EAAK,CAAC;EACpC,IAAI;IACFJ,MAAM,CAAC,CAAC;EACV,CAAC,CAAC,OAAOK,CAAC,EAAE;IACVC,OAAO,CAACC,GAAG,CAACF,CAAC,CAAC;IACd,MAAMA,CAAC;EACT;EACAC,OAAO,CAACC,GAAG,CAACP,MAAM,EAAEC,MAAM,EAAEC,MAAM,CAAC;AACrC;AACAH,GAAG,CAAC,CAAC",
"ignoreList": []
}
同时Babel还支持将SourceMap与生成代码放到同一个文件中,需要设置"sourceMaps": "inline"
。我们看一下生成结果:
js
"use strict";
var globaljz = 123;
function fun() {
var jzplp1 = "a" + "b";
var jzplp2 = 12345;
var jzplp3 = {
jz1: 1,
jz2: 1221
};
try {
jzplp1();
} catch (e) {
console.log(e);
throw e;
}
console.log(jzplp1, jzplp2, jzplp3);
}
fun();
//# sourceMappingURL/*防止报错*/=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJnbG9iYWxqeiIsImZ1biIsImp6cGxwMSIsImp6cGxwMiIsImp6cGxwMyIsImp6MSIsImp6MiIsImUiLCJjb25zb2xlIiwibG9nIl0sInNvdXJjZXMiOlsic3JjL2luZGV4LmpzIl0sInNvdXJjZXNDb250ZW50IjpbImNvbnN0IGdsb2JhbGp6ID0gMTIzO1xyXG5mdW5jdGlvbiBmdW4oKSB7XHJcbiAgY29uc3QganpwbHAxID0gXCJhXCIgKyBcImJcIjtcclxuICBjb25zdCBqenBscDIgPSAxMjM0NTtcclxuICBjb25zdCBqenBscDMgPSB7IGp6MTogMSwganoyOiAxMjIxIH07XHJcbiAgdHJ5IHtcclxuICAgIGp6cGxwMSgpO1xyXG4gIH0gY2F0Y2ggKGUpIHtcclxuICAgIGNvbnNvbGUubG9nKGUpO1xyXG4gICAgdGhyb3cgZTtcclxuICB9XHJcbiAgY29uc29sZS5sb2coanpwbHAxLCBqenBscDIsIGp6cGxwMyk7XHJcbn1cclxuZnVuKCk7XHJcbiJdLCJtYXBwaW5ncyI6Ijs7QUFBQSxJQUFNQSxRQUFRLEdBQUcsR0FBRztBQUNwQixTQUFTQyxHQUFHQSxDQUFBLEVBQUc7RUFDYixJQUFNQyxNQUFNLEdBQUcsR0FBRyxHQUFHLEdBQUc7RUFDeEIsSUFBTUMsTUFBTSxHQUFHLEtBQUs7RUFDcEIsSUFBTUMsTUFBTSxHQUFHO0lBQUVDLEdBQUcsRUFBRSxDQUFDO0lBQUVDLEdBQUcsRUFBRTtFQUFLLENBQUM7RUFDcEMsSUFBSTtJQUNGSixNQUFNLENBQUMsQ0FBQztFQUNWLENBQUMsQ0FBQyxPQUFPSyxDQUFDLEVBQUU7SUFDVkMsT0FBTyxDQUFDQyxHQUFHLENBQUNGLENBQUMsQ0FBQztJQUNkLE1BQU1BLENBQUM7RUFDVDtFQUNBQyxPQUFPLENBQUNDLEdBQUcsQ0FBQ1AsTUFBTSxFQUFFQyxNQUFNLEVBQUVDLE1BQU0sQ0FBQztBQUNyQztBQUNBSCxHQUFHLENBQUMsQ0FBQyIsImlnbm9yZUxpc3QiOltdfQ==
文件最后有一行注释,里面是Base64格式的数据,将数据放到浏览器地址栏,解析出数据内容和前面独立文件的sourceMap一致。
Terser生成SourceMap
Terser是一个代码压缩混淆工具,我们在命令行中执行terser src/index.js --compress --mangle -o dist.js --source-map url=dist.js.map
命令。其中compress表示代码开启压缩,去掉代码中未被使用和无意义的内容。mangle表示开启混淆,将代码转换为难以阅读的形式。我们看一下生成结果。首先是生成的代码dist.js:
js
const globaljz=123;function fun(){const o="ab";try{o()}catch(o){throw console.log(o),o}console.log(o,12345,{jz1:1,jz2:1221})}fun();
//# sourceMappingURL/*防止报错*/=dist.js.map
可以看到局部变量名都被重新命名了,有些简单的字面量计算如"a" + "b"也直接以结果的形式展现,甚至部分肯定不会被执行到的代码也被删除了。使用代码压缩混淆后,代码的样子和之前相比区别不小。我们再看看生成的sourceMap文件:
js
{
"version": 3,
"names": [
"globaljz",
"fun",
"jzplp1",
"e",
"console",
"log",
"jz1",
"jz2"
],
"sources": [
"src/index.js"
],
"mappings": "AAAA,MAAMA,SAAW,IACjB,SAASC,MACP,MAAMC,EAAS,KAGf,IACEA,GACF,CAAE,MAAOC,GAEP,MADAC,QAAQC,IAAIF,GACNA,CACR,CACAC,QAAQC,IAAIH,EARG,MACA,CAAEI,IAAK,EAAGC,IAAK,MAQhC,CACAN",
"ignoreList": []
}
sourceMap文件形式与Babel的基本一致,都是通用的。
浏览器使用SourceMap
上一节的示例代码中故意留了一个错误,而且为了输出栈,先捕捉异常再进行抛出。这里以上一节中使用Terser生成的代码为例,描述在Chrome浏览器中如何使用SourceMap。
不使用SourceMap
作为对比,首先来看一下不使用SourceMap的现象。执行命令terser src/index.js --compress --mangle -o dist.js
重新生成代码,但是不包含SourceMap。然后将代码使用HTML包裹,以便浏览器打开:
html
<html>
<script src="./dist.js"></script>
</html>

在浏览器中打开调试工具的Console(上图中左侧),可以看到白底的字是我捕获并打印的错误栈,红底是抛出的错误。错误栈中标明了报错的具体位置:文件名,行号和列号。点击蓝色的位置文字可以跳到右边查看具体的报错代码。红底因为被浏览器解析过了所以没有列号,但是点击蓝字同样可以跳过去。
因为dist文件只有一行,因此可以看到行号都是1。看右边的所有文件目录树中并没有源码文件,定位到的位置是生成代码中的出错位置(红色波浪线和x号的位置)。生成的代码实际只有一行,浏览器在这里美化展示了,但从错误栈的行号上还是能看到只有一行。在复杂代码的情况下,这样是很难定位到源码出错位置和逻辑的。
使用SourceMap
首先打开浏览器的SourceMap开关(默认是打开状态):

然后使用前面的Terser工具生成代码与SourceMap:terser src/index.js --compress --mangle -o dist.js --source-map url=dist.js.map
。然后用HTML包裹,在浏览器中打开,查看Console:

首先看左边的图中,虽然我们执行的是生成后的dist.js文件,且报错信息给出的变量名并不是我们写的源码,但这里给出的错误栈中的报错文件已经不是dist.js了,而是生成前的源码index.js,行号也不是1了,而是实际源码中的行号。点击蓝色文字切换到右边,可以看到目录树中多了src/index.js
,这是我们的源码,且精确定位出了我们源码中报错的具体位置。这样排查错误方便多了。
我们切换到目录树中的生成的代码dist.js,下方还可以看到SourceMap已经加载的提示(下方左图);如果加载失败,那么会有黄色的提示(下方右图):

后添加SourceMap
假设我们页面访问时没有提供SourceMap,浏览器也支持我们后添加SourceMap进去。这里我们把生成代码中的最后一行注释去掉,模拟没有提供SourceMap的场景。去掉的是这一行://# sourceMappingURL/*防止报错*/=dist.js.map
。然后在浏览器运行,如下面作图,此时的报错信息没有经过SourceMap处理。

我们点击报错文件位置信息到右侧查看dist.js,在空白处点鼠标右键,选择Add source map,可以将SourceMap添加到这个文件上。我们添加之后的的效果如下:

可以看到文件下方出现SourceMap加载成功的通知,左侧文件目录出现了我们的源码文件。此时回到Console,发现以前产生的报错栈文件位置信息,也已经被修改为SourceMap处理之后的位置了。
这种场景适用于工程构建时生成SourceMap,但并不直接附加到页面上。这种情况下用户无法访问到源代码。当遇到有错误需要排查的场景,再将SourceMap文件附加到浏览器中进行调试,这样兼顾了安全性和可调试性。
SourceMap浏览器请求
在实际开发中,由于前端工程化工具的广泛应用,SourceMap是非常常用的调试工具。有些同学也会好奇,既然SourceMap是独立的文件,为啥我们在浏览器调试工具中的Network中从来没看到过。这是因为SourceMap相关的请求并不在这里展示,而是在Developer resources这个模块中展示(需要在More tools中将它选中展示)。

从上图中可以看到,Network中并不展示SourceMap相关请求,而是在下方的Developer resources中出现。而且除了dist.js.map,还有src/index.js,也就是我们的源码文件。这是因为SourceMap中只有对应关系,没有真正的源码,如果希望像前面一样在浏览器中表示具体出错代码,那还是要请求源码文件。
那么这里还有一个疑问,SourceMap会造成额外的资源请求,而且这个文件还挺大(比生成的代码本身更大),那么它是什么时间请求的?会不会造成过多请求浪费服务器资源?从上面的浏览器调试工具中看不出来,我们自己搞个简易的服务试一下。
js
const http = require("http");
const fs = require("fs");
http
.createServer((req, res) => {
try {
const data = fs.readFileSync("." + req.url);
console.log(new Date(Date.now()).toLocaleString(), `Url: ${req.url}`);
res.end(data);
} catch (e) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
}
})
.listen(8000, () => {
console.log("server start!");
});
上面的代码启动了一个简单的Node.js服务,当收到请求时,读取本地文件并返回。请求到来时还会输出当前时间,这使我们可以看到浏览器请求SourceMap的时机。我们的操作流程如下:
- 访问
http://localhost:8000/index.html
。(此时浏览器调试工具未打开) - 10秒后,打开浏览器调试工具。
- 再10秒后,点击错误文件位置信息,查看浏览器中展示的出错源码文件。

由于人手操作,因此时间并不是那么精确,但已经能得到规律了:正常访问页面时不请求,只有打开调试时才请求SourceMap文件。这样不会因此SourceMap造成服务器请求过多,也不会阻碍调试。
- 正常访问页面的时候,只请求页面相关的内容,不请求SourceMap文件。
- 打开浏览器调试工具的时候,浏览器会发送SourceMap文件请求。
- 当在浏览器中查看对应错误源码时,浏览器会发送源码文件请求。
SourceMap文件
前面我们介绍了如何使用转换代码工具来生成SourcaMap,还列出了用Babel与Terser生成的SourceMap,是一个JSON文件。这里介绍一下文件内容:
字段名 | 类型 | 必填 | 示例值 | 含义描述 |
---|---|---|---|---|
version | number | 是 | 3 | SourcaMap版本号 |
file | string | 否 | "dist.js" | 转换后代码的文件名 |
sources | Array<string> |
是 | ["index1.js", "index2.js"] | 转换前代码的文件名,多个文件可以包含在一个转换后文件内,因此是一个数组 |
names | Array<string> |
是 | ["a", "jzplp1"] | 转换前代码中的变量/属性名 |
mappings | string | 是 | ";;AAAA,IAAMA" | 转换前后代码中的变量/属性名的位置关系记录 |
sourcesContent | Array<string> |
否 | ["const a = 1"] | 转换前代码的文件内容 |
sourceRoot | string | 否 | "src" | 转换前代码的文件所在的目录,如果和转换后代码一致则省略 |
其中版本号我们在前面介绍SourceMap历史的时候介绍过,现在使用的都是第三版。mappings中保存着最核心的转换关系。
转换前代码的文件名sources是个数组,这是因为可以将多个文件打包到一个转换后文件中,因此来源可能有多个(多对一)。那有人会问:有没有一个转换前文件被多个转换后文件打包的情况(一对多)?有的。这种情况每个转换后文件中都有同一个转换前文件。sourcesContent中是对应转换前文件的源码,可以省略。关于这些字段具体起到的作用,在以后描述SourceMap原理的时候再详细说。
source-map包
有一个source-map包,提供生成和使用SourceMap数据的功能,支持在Node.js和浏览器中使用,很多前端工具都是引用这个包来生成SourceMap。这里我们简单介绍下它在Node.js中的使用方法。
使用SourceMap数据
当我们有了SourceMap数据之后,可以使用source-map包转换代码位置。这里还是使用前面Terser生成的代码和SourceMap。首先创建SourceMapConsumer对象,用来解析已创建的SourceMap。
js
const sourceMap = require('source-map');
const fs = require('fs');
const data = fs.readFileSync('./dist.js.map', 'utf-8');
async function jzplpfun() {
const consumer = await new sourceMap.SourceMapConsumer(data);
// do some thing
}
jzplpfun();
然后再介绍一下最常见的用法,originalPositionFor函数,用生成代码的位置获取源代码的位置。
js
const oplist = [];
oplist[0] = consumer.originalPositionFor({ line: 1, column: 10 });
oplist[1] = consumer.originalPositionFor({ line: 1, column: 11 });
oplist[2] = consumer.originalPositionFor({ line: 1, column: 33 });
oplist[3] = consumer.originalPositionFor({ line: 1, column: 34 });
oplist[4] = consumer.originalPositionFor({ line: 1, column: 40 });
console.log(oplist);
/* 输出结果
[
{ source: 'src/index.js', line: 1, column: 6, name: 'globaljz' },
{ source: 'src/index.js', line: 1, column: 6, name: 'globaljz' },
{ source: 'src/index.js', line: 2, column: 9, name: 'fun' },
{ source: 'src/index.js', line: 3, column: 2, name: null },
{ source: 'src/index.js', line: 3, column: 8, name: 'jzplp1' }
]
*/
例子尝试获取了五个源代码位置,生成前后实际内容对比如下:
生成代码位置 | 生成代码内容 | 实际源代码位置 | 实际源代码内容 | 获取源代码位置 | 获取源代码内容 |
---|---|---|---|---|---|
行1列10 | globaljz中的a | 行1列10 | globaljz中的a | 行1列6 | globaljz |
行1列11 | globaljz中的l | 行1列11 | globaljz中的l | 行1列6 | globaljz |
行1列33 | 函数的{ | 行2列15 | 函数的{ | 行2列9 | 函数名fun |
行1列34 | const中的c | 行3列2 | const中的c | 行3列2 | - |
行1列40 | 变量o | 行3列8 | jzplp1 | 行3列8 | jzplp1 |
可以看到,实际位置并不是完全精准的,尤其是程序关键字和符号。但是对于标识符(变量名,属性名等)的位置还是能精准识别到源代码中的变量位置的,不过对于标识符内部的字符不能精确识别。这是因为SourceMap实际记录的就是标识符的对应位置关系,其他内容的位置关系并不会记录。
再介绍一下反向定位的功能,即有了源代码的位置,尝试获取生成代码的位置,这次的方法是generatedPositionFor。
js
const gplist = [];
gplist[0] = consumer.generatedPositionFor({ source: "src/index.js", line: 1, column: 10 });
gplist[1] = consumer.generatedPositionFor({ source: "src/index.js", line: 1, column: 11 });
gplist[2] = consumer.generatedPositionFor({ source: "src/index.js", line: 2, column: 15 });
gplist[3] = consumer.generatedPositionFor({ source: "src/index.js", line: 3, column: 2 });
gplist[4] = consumer.generatedPositionFor({ source: "src/index.js", line: 3, column: 8 });
console.log(gplist);
/* 输出结果
[
{ line: 1, column: 6, lastColumn: 14 },
{ line: 1, column: 6, lastColumn: 14 },
{ line: 1, column: 28, lastColumn: 33 },
{ line: 1, column: 34, lastColumn: 39 },
{ line: 1, column: 40, lastColumn: 41 }
]
*/
与获取源代码位置不同,获取的生成代码位置是有一个行号范围的。这里使用的源代码位置就是上个例子的"实际源代码位置"。这里我们依然列个表格对比一下。
实际源代码位置 | 实际源代码内容 | 生成代码位置 | 生成代码内容 | 获取生成代码位置 | 获取生成代码内容 |
---|---|---|---|---|---|
行1列10 | globaljz中的a | 行1列10 | globaljz中的a | 行1列6-14 | globaljz |
行1列11 | globaljz中的l | 行1列11 | globaljz中的l | 行1列6-14 | globaljz |
行2列15 | 函数的{ | 行1列33 | 函数的{ | 行1列28-33 | fun(){ |
行3列2 | const中的c | 行1列34 | const中的c | 行1列34-39 | const |
行3列8 | jzplp1中的j | 行1列40 | 变量o | 行1列40-41 | 变量o |
SourceMapConsumer对象还有遍历每个位置关系的方法eachMapping,可以按照生成代码或源代码顺序输出每个位置关系:
js
consumer.eachMapping(m => console.log(m));
/* 部分输出结果
Mapping {
generatedLine: 1,
generatedColumn: 0,
lastGeneratedColumn: null,
source: 'src/index.js',
originalLine: 1,
originalColumn: 0,
name: null
}
Mapping {
generatedLine: 1,
generatedColumn: 6,
lastGeneratedColumn: null,
source: 'src/index.js',
originalLine: 1,
originalColumn: 6,
name: 'globaljz'
}
...
*/
SourceMapConsumer对象还提供了其他使用SourceMap数据的方法,这里就不多描述了。
低级API生成SourceMap
source-map包生成SourceMap数据有两种方式,分别是低级API与高级API。低级API使用SourceMapGenerator,是通过直接提供位置关系本身来生成SourceMap。这里我们举例试一下:
js
const sourceMap = require("source-map");
const generator1 = new sourceMap.SourceMapGenerator({
file: "dist1.js",
});
generator1.addMapping({
source: "src1.js",
original: { line: 11, column: 11 },
generated: { line: 1, column: 1 },
});
generator1.addMapping({
source: "src1.js",
original: { line: 22, column: 22 },
generated: { line: 2, column: 2 },
});
const data1 = generator1.toString();
console.log(data1);
async function jzplpfun() {
const consumer = await new sourceMap.SourceMapConsumer(data1);
consumer.eachMapping(m => console.log(m));
}
jzplpfun();
/* 输出结果
{"version":3,"sources":["src1.js"],"names":[],"mappings":"CAUW;EAWW","file":"dist1.js"}
Mapping {
generatedLine: 1,
generatedColumn: 1,
lastGeneratedColumn: null,
source: 'src1.js',
originalLine: 11,
originalColumn: 11,
name: null
}
Mapping {
generatedLine: 2,
generatedColumn: 2,
lastGeneratedColumn: null,
source: 'src1.js',
originalLine: 22,
originalColumn: 22,
name: null
}
*/
可以看到,我们创建了一个SourceMapGenerator对象,然后使用addMapping往里面一个一个添加位置关系,最后输出SourceMap结果。SourceMapGenerator对象还可以从已存在的SourceMapConsumer对象来创建SourceMap,这里我们试一下:
js
const sourceMap = require("source-map");
async function jzplpfun() {
// 第一个SourceMap
const generator1 = new sourceMap.SourceMapGenerator({
file: "dist1.js",
});
generator1.addMapping({
source: "src1.js",
original: { line: 11, column: 11 },
generated: { line: 1, column: 1 },
});
const data1 = generator1.toString();
const consumer1 = await new sourceMap.SourceMapConsumer(data1);
// 第二个SourceMap
const generator2 = sourceMap.SourceMapGenerator.fromSourceMap(consumer1);
generator2.addMapping({
source: "src2.js",
original: { line: 22, column: 22 },
generated: { line: 2, column: 2 },
});
// 输出结果
const data2 = generator2.toString();
console.log(data2);
const consumer2 = await new sourceMap.SourceMapConsumer(data2);
consumer2.eachMapping((m) => console.log(m));
}
jzplpfun();
/* 输出结果
{"version":3,"sources":["src1.js","src2.js"],"names":[],"mappings":"CAUW;ECWW","file":"dist1.js"}
Mapping {
generatedLine: 1,
generatedColumn: 1,
lastGeneratedColumn: null,
source: 'src1.js',
originalLine: 11,
originalColumn: 11,
name: null
}
Mapping {
generatedLine: 2,
generatedColumn: 2,
lastGeneratedColumn: null,
source: 'src2.js',
originalLine: 22,
originalColumn: 22,
name: null
}
*/
我们先创建了第一个SourceMap,将其转换为SourceMapConsumer对象;然后让第二个SourceMap利用这个对象创建SourceMapGenerator对象,并向其中继续添加位置关系。最后输出结果,可以看到所有数据都包含在其中。
低级API映射生成SourceMap
在生成SourceMap的低级API中,还有一个applySourceMap方法,它同样是接受一个SourceMapConsumer对象,但并不是创建SourceMapGenerator对象,而是对已有的SourceMapGenerator对象合并。合并的方式也不是简单的把位置关系合并,而是映射。这里我们先列举一个错误例子:
js
// 错误例子
const sourceMap = require("source-map");
async function jzplpfun() {
// 第一个SourceMap
const generator1 = new sourceMap.SourceMapGenerator({
file: "dist1.js",
});
generator1.addMapping({
source: "src1.js",
original: { line: 11, column: 11 },
generated: { line: 1, column: 1 },
});
const data1 = generator1.toString();
const consumer1 = await new sourceMap.SourceMapConsumer(data1);
// 第二个SourceMap
const generator2 = new sourceMap.SourceMapGenerator({
file: "dist1.js",
});
generator2.addMapping({
source: "src2.js",
original: { line: 22, column: 22 },
generated: { line: 2, column: 2 },
});
// 第二个合并第一个
generator2.applySourceMap(consumer1);
// 输出结果
const data2 = generator2.toString();
console.log(data2);
const consumer2 = await new sourceMap.SourceMapConsumer(data2);
consumer2.eachMapping((m) => console.log(m));
}
jzplpfun();
/* 输出结果
{"version":3,"sources":["src2.js"],"names":[],"mappings":";EAqBsB","file":"dist1.js"}
Mapping {
generatedLine: 2,
generatedColumn: 2,
lastGeneratedColumn: null,
source: 'src2.js',
originalLine: 22,
originalColumn: 22,
name: null
}
*/
在上面的例子中,我们创建了两个SourceMap,指向的文件是相同的。第一个被转换为SourceMapConsumer对象,使用applySourceMap方法从合并进第二个SourceMap中。但是输出第二个SourceMap,里面却没有包含第一个SourceMap中的内容(即使指向文件是同一个,位置关系也不冲突)。因此上面的例子并不是其真正的用法。下面列举一个正确用法:
js
const sourceMap = require("source-map");
async function jzplpfun() {
// 第一个SourceMap
const generator1 = new sourceMap.SourceMapGenerator({
file: "dist1.js",
});
generator1.addMapping({
source: "src1.js",
original: { line: 1, column: 1 },
generated: { line: 2, column: 2 },
});
const data1 = generator1.toString();
const consumer1 = await new sourceMap.SourceMapConsumer(data1);
// 第二个SourceMap
const generator2 = new sourceMap.SourceMapGenerator({
file: "dist2.js",
});
generator2.addMapping({
source: "dist1.js",
original: { line: 2, column: 2 },
generated: { line: 3, column: 3 },
});
// 第二个合并第一个
generator2.applySourceMap(consumer1);
// 输出结果
const data2 = generator2.toString();
console.log(data2);
const consumer2 = await new sourceMap.SourceMapConsumer(data2);
consumer2.eachMapping((m) => console.log(m));
}
jzplpfun();
/* 输出结果
{"version":3,"sources":["src1.js"],"names":[],"mappings":";;GAAC","file":"dist2.js"}
Mapping {
generatedLine: 3,
generatedColumn: 3,
lastGeneratedColumn: null,
source: 'src1.js',
originalLine: 1,
originalColumn: 1,
name: null
}
*/
在这个例子中,同样是两个SourceMap使用applySourceMap方法合并,同样最后结果中只留下了一个位置关系,但这个位置关系却与上一个错误例子不同。上一个错误例子只保留了第二个SourceMap的位置关系,第一个根本没合并进来;这个例子中生成的位置关系却是由两个SourceMap的位置关系映射而来。我们仔细观察:
- 第一个SourceMap的源代码是 src1.js 1行1列;生成代码是 dist1.js 2行2列
- 第二个SourceMap的源代码是 dist1.js 2行2列;生成代码是 dist2.js 3行3列
- 合并后SourceMap的源代码是 src1.js 1行1列;生成代码是 dist2.js 3行3列

第一个SourceMap的生成代码是第二个SourceMap的源代码。事实上它们的生成路径应该是:src1.js生成dist1.js,dist1.js再生成dist2.js。这两次生成产生了两个SourceMap,而applySourceMap方法可以使得SourceMap合并,可以实现源头到最终产物代码的位置关系映射。
高级API生成SourceMap
高级API使用SourceNodes对象,在生成代码的时候同时保留了源码的位置信息,最后将生成代码合并的同时,也创建了SourceMap。我们以一个例子为例说明一下。
假设我们的源代码文件是src.js,其中的内容为jz = src1 + src2
。我们生成文件是dist.js,其中的代码为 jz = out1 + out2
。那么我们可以创建这样一个SourceNodes对象结构:
js
const { SourceNode } = require("source-map");
// new SourceNode([line, column, source[, chunk[, name]]])
const node = new SourceNode(1, 0, "src.js", [
new SourceNode(1, 0, "src.js", "jz", "jz"),
" = ",
new SourceNode(1, 5, "src.js", [
new SourceNode(1, 5, "src.js", "out1", "src1"),
" + ",
new SourceNode(1, 12, "src.js", "out2", "src2"),
]),
]);
line, column表示源文件中的位置,source表示源文件名,chunk表示要生成的代码。通过上面的例子可以看到,SourceNode可以嵌套字符串或者SourceNode数组,其中标识符(也就是SourceMap要记录的核心信息)是SourceNode对象,而非标识符可以直接使用字符串。事实上这是一棵树形结构,而且这个结构和抽象语法树AST类似,其中的源码行列信息可以直接通过AST数据得到。因此,可以用遍历抽象语法树的形式生成SourceNode树。
SourceNode树组成之后,通过toString方法可以直接获取生成的源码。而且此时SourceMap本身也可以获取到。
js
console.log(node.toString());
const data = node.toStringWithSourceMap({ file: "dist.js" });
console.log(data);
const mapString = data.map.toString();
console.log(mapString);
async function jzplpfun() {
const consumer2 = await new SourceMapConsumer(mapString);
consumer2.eachMapping((m) => console.log(m));
}
jzplpfun();
/* 输出结果
jz = out1 + out2
{
code: 'jz = out1 + out2',
map: SourceMapGenerator { ...省略 }
}
{"version":3,"sources":["src.js"],"names":["jz","src1","src2"],"mappings":"AAAAA,EAAA,GAAKC,IAAA,GAAOC","file":"dist.js"}
Mapping {
generatedLine: 1,
generatedColumn: 0,
lastGeneratedColumn: null,
source: 'src.js',
originalLine: 1,
originalColumn: 0,
name: 'jz'
}
Mapping {
generatedLine: 1,
generatedColumn: 2,
lastGeneratedColumn: null,
source: 'src.js',
originalLine: 1,
originalColumn: 0,
name: null
}
Mapping {
generatedLine: 1,
generatedColumn: 5,
lastGeneratedColumn: null,
source: 'src.js',
originalLine: 1,
originalColumn: 5,
name: 'src1'
}
Mapping {
generatedLine: 1,
generatedColumn: 9,
lastGeneratedColumn: null,
source: 'src.js',
originalLine: 1,
originalColumn: 5,
name: null
}
Mapping {
generatedLine: 1,
generatedColumn: 12,
lastGeneratedColumn: null,
source: 'src.js',
originalLine: 1,
originalColumn: 12,
name: 'src2'
}
*/
使用toStringWithSourceMap方法,SourceNode树可以同时生成代码和SourceMapGenerator对象,里面存放的就是SourceMaps数据。通过转换成SourceMapConsumer对象并遍历,我们发现其中不仅有标识符,连其它元素(在SourceNode树中是字符串形式)也生成了映射关系。所以看到映射关系一共有5条。SourceNode还有其它方法,例如添加元素,遍历等,这里也给一下示例:
js
const { SourceNode } = require("source-map");
const node = new SourceNode(1, 0, "src.js", [
new SourceNode(1, 0, "src.js", "jz", "jz"),
" = ",
]);
node.add(
new SourceNode(1, 5, "src2.js", [
new SourceNode(1, 5, "src2.js", "out1", "src1"),
" + ",
new SourceNode(1, 12, "src2.js", "out2", "src2"),
])
);
node.walk(function (code, loc) {
console.log("walk:", code, loc);
});
/* 输出结果
walk: jz { source: 'src.js', line: 1, column: 0, name: 'jz' }
walk: = { source: 'src.js', line: 1, column: 0, name: null }
walk: out1 { source: 'src2.js', line: 1, column: 5, name: 'src1' }
walk: + { source: 'src2.js', line: 1, column: 5, name: null }
walk: out2 { source: 'src2.js', line: 1, column: 12, name: 'src2' }
*/
source-map-visualization
source-map-visualization是一个可视化查看SourceMap代码位置关系的应用,网络上提供了很多在线工具可供使用(参考链接有描述)。上传源文件,生成文件和SourceMap文件,即可查看位置关系。例如这样上传文件:

然后选中某个生成代码,对应的源代码位置也会变为高亮。有些工具还提供了更多功能,这里就不介绍了。

总结
这篇文章对SourceMap进行了基础介绍,包括SourceMap的历史,文件格式,转换代码工具生成SourceMap,浏览器使用SourceMap,以及source-map包的使用。而通过source-map包生成SourceMap,可以看到即使并非标识符,也可以生成位置对应关系。
第一次见SourceMap的时候,感觉很神奇,好奇它是如何实现"将代码反过来转换的"。当了解通过标识符位置关系记录这种并不复杂的机制,就能解决了前端工程化中打包后代码难以理解不好调试的困难,还是觉得挺有意思的。
关于SourceMap,我还有其它想要了解和介绍的,包括Wbepack配置中非常多的SourceMap配置项都是什么含义与效果;SourceMap文件中mappings这个最重要的内容,是如何记录转换前后代码中标识符的位置关系的。这些内容后面会有单独文章介绍。
参考
- sourcemap这么讲,我彻底理解了
juejin.cn/post/719989... - JavaScript Source Map 详解
www.ruanyifeng.com/blog/2013/0... - 万字长文:关于sourcemap,这篇文章就够了
juejin.cn/post/696974... - Source Map Github
github.com/mozilla/sou... - HTTP标头 SourceMap MDN
developer.mozilla.org/zh-CN/docs/... - terser Github
github.com/terser/ters... - node-source-map-support Github
github.com/evanw/node-... - SourceMap详解
juejin.cn/post/694895... - 深入浅出之 Source Map
juejin.cn/post/702353... - 绝了,没想到一个 source map 居然涉及到那么多知识盲区
juejin.cn/post/696307... - Terser 文档
terser.org/ - Closure Compiler Source Map 2.0
docs.google.com/document/d/... - Source Map Revision 3 Proposal
docs.google.com/document/d/... - Babel 文档
babeljs.io/ - 解锁Babel核心功能:从转义语法到插件开发
jzplp.github.io/2025/babel-... - Terser 中文文档
terser.nodejs.cn/ - 探究 source map 在编译过程中的生成原理
cloud.tencent.com/developer/a... - source-map-visualization
sokra.github.io/source-map-... - Source Map Visualization
evanw.github.io/source-map-...