当前位置: 首页 > news >正文

快速定位源码问题:SourceMap的生成/使用/文件格式与历史

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生成功能。这里我们选择两个来介绍一下。首先构造一个要被转换的源代码:

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:

{"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:

"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文件,我们看一下文件内容:

{"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"。我们看一下生成结果:

"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:

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文件:

{"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><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会造成额外的资源请求,而且这个文件还挺大(比生成的代码本身更大),那么它是什么时间请求的?会不会造成过多请求浪费服务器资源?从上面的浏览器调试工具中看不出来,我们自己搞个简易的服务试一下。

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的时机。我们的操作流程如下:

  1. 访问http://localhost:8000/index.html。(此时浏览器调试工具未打开)
  2. 10秒后,打开浏览器调试工具。
  3. 再10秒后,点击错误文件位置信息,查看浏览器中展示的出错源码文件。

在这里插入图片描述

由于人手操作,因此时间并不是那么精确,但已经能得到规律了:正常访问页面时不请求,只有打开调试时才请求SourceMap文件。这样不会因此SourceMap造成服务器请求过多,也不会阻碍调试。

  • 正常访问页面的时候,只请求页面相关的内容,不请求SourceMap文件。
  • 打开浏览器调试工具的时候,浏览器会发送SourceMap文件请求。
  • 当在浏览器中查看对应错误源码时,浏览器会发送源码文件请求。

SourceMap文件

前面我们介绍了如何使用转换代码工具来生成SourcaMap,还列出了用Babel与Terser生成的SourceMap,是一个JSON文件。这里介绍一下文件内容:

字段名类型必填示例值含义描述
versionnumber3SourcaMap版本号
filestring“dist.js”转换后代码的文件名
sourcesArray<string>[“index1.js”, “index2.js”]转换前代码的文件名,多个文件可以包含在一个转换后文件内,因此是一个数组
namesArray<string>[“a”, “jzplp1”]转换前代码中的变量/属性名
mappingsstring“;;AAAA,IAAMA”转换前后代码中的变量/属性名的位置关系记录
sourcesContentArray<string>[“const a = 1”]转换前代码的文件内容
sourceRootstring“src”转换前代码的文件所在的目录,如果和转换后代码一致则省略

其中版本号我们在前面介绍SourceMap历史的时候介绍过,现在使用的都是第三版。mappings中保存着最核心的转换关系。

转换前代码的文件名sources是个数组,这是因为可以将多个文件打包到一个转换后文件中,因此来源可能有多个(多对一)。那有人会问:有没有一个转换前文件被多个转换后文件打包的情况(一对多)?有的。这种情况每个转换后文件中都有同一个转换前文件。sourcesContent中是对应转换前文件的源码,可以省略。关于这些字段具体起到的作用,在以后描述SourceMap原理的时候再详细说。

source-map包

有一个source-map包,提供生成和使用SourceMap数据的功能,支持在Node.js和浏览器中使用,很多前端工具都是引用这个包来生成SourceMap。这里我们简单介绍下它在Node.js中的使用方法。

使用SourceMap数据

当我们有了SourceMap数据之后,可以使用source-map包转换代码位置。这里还是使用前面Terser生成的代码和SourceMap。首先创建SourceMapConsumer对象,用来解析已创建的SourceMap。

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函数,用生成代码的位置获取源代码的位置。

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列10globaljz中的a行1列10globaljz中的a行1列6globaljz
行1列11globaljz中的l行1列11globaljz中的l行1列6globaljz
行1列33函数的{行2列15函数的{行2列9函数名fun
行1列34const中的c行3列2const中的c行3列2-
行1列40变量o行3列8jzplp1行3列8jzplp1

可以看到,实际位置并不是完全精准的,尤其是程序关键字和符号。但是对于标识符(变量名,属性名等)的位置还是能精准识别到源代码中的变量位置的,不过对于标识符内部的字符不能精确识别。这是因为SourceMap实际记录的就是标识符的对应位置关系,其他内容的位置关系并不会记录。

再介绍一下反向定位的功能,即有了源代码的位置,尝试获取生成代码的位置,这次的方法是generatedPositionFor。

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列10globaljz中的a行1列10globaljz中的a行1列6-14globaljz
行1列11globaljz中的l行1列11globaljz中的l行1列6-14globaljz
行2列15函数的{行1列33函数的{行1列28-33fun(){
行3列2const中的c行1列34const中的c行1列34-39const
行3列8jzplp1中的j行1列40变量o行1列40-41变量o

SourceMapConsumer对象还有遍历每个位置关系的方法eachMapping,可以按照生成代码或源代码顺序输出每个位置关系:

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。这里我们举例试一下:

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,这里我们试一下:

const sourceMap = require("source-map");async function jzplpfun() {// 第一个SourceMapconst 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);// 第二个SourceMapconst 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对象合并。合并的方式也不是简单的把位置关系合并,而是映射。这里我们先列举一个错误例子:

// 错误例子
const sourceMap = require("source-map");async function jzplpfun() {// 第一个SourceMapconst 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);// 第二个SourceMapconst 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中的内容(即使指向文件是同一个,位置关系也不冲突)。因此上面的例子并不是其真正的用法。下面列举一个正确用法:

const sourceMap = require("source-map");async function jzplpfun() {// 第一个SourceMapconst 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);// 第二个SourceMapconst 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对象结构:

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本身也可以获取到。

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还有其它方法,例如添加元素,遍历等,这里也给一下示例:

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这么讲,我彻底理解了
    https://juejin.cn/post/7199895323187347514
  • JavaScript Source Map 详解
    https://www.ruanyifeng.com/blog/2013/01/javascript_source_map.html
  • 万字长文:关于sourcemap,这篇文章就够了
    https://juejin.cn/post/6969748500938489892
  • Source Map Github
    https://github.com/mozilla/source-map
  • HTTP标头 SourceMap MDN
    https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Reference/Headers/SourceMap
  • terser Github
    https://github.com/terser/terser
  • node-source-map-support Github
    https://github.com/evanw/node-source-map-support
  • SourceMap详解
    https://juejin.cn/post/6948951662144782366
  • 深入浅出之 Source Map
    https://juejin.cn/post/7023537118454480904
  • 绝了,没想到一个 source map 居然涉及到那么多知识盲区
    https://juejin.cn/post/6963076475020902436
  • Terser 文档
    https://terser.org/
  • Closure Compiler Source Map 2.0
    https://docs.google.com/document/d/1xi12LrcqjqIHTtZzrzZKmQ3lbTv9mKrN076UB-j3UZQ
  • Source Map Revision 3 Proposal
    https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k
  • Babel 文档
    https://babeljs.io/
  • 解锁Babel核心功能:从转义语法到插件开发
    https://jzplp.github.io/2025/babel-intro.html
  • Terser 中文文档
    https://terser.nodejs.cn/
  • 探究 source map 在编译过程中的生成原理
    https://cloud.tencent.com/developer/article/1528134
  • source-map-visualization
    https://sokra.github.io/source-map-visualization/
  • Source Map Visualization
    https://evanw.github.io/source-map-visualization/
http://www.dtcms.com/a/503823.html

相关文章:

  • 湖南移动官网网站建设wordpress 菜单 分隔
  • 邯郸网站建设服务报价全国住房和城乡建设厅官网
  • leetcode 143 重排链表
  • 元宇宙与职业教育的深度融合:重构技能培养的实践与未来
  • 坪山网站建设哪家便宜帝国cms 网站地图 自定义
  • 双拼输入法:提升打字效率的另一种选择
  • 如何做论坛网站 知乎整套网页模板
  • XSS平台xssplatform搭建
  • SQL入门(structured query language)
  • SAP SD客户主数据查询接口分享
  • RedPlayer 视频播放器在 HarmonyOS 应用中的实践
  • 网站怎么做彩页wordpress 微信打赏
  • Altium Designer创建一个空白工程
  • SciPy 稀疏矩阵
  • 上海网站制作维护南京网站建设索q.479185700
  • 运用API开放接口获取淘宝商品价格信息,对比全网价格
  • 笔记【数据类型,常量,变量】
  • 翠峦网站建设做众筹网站
  • FFmpeg 基本API avformat_find_stream_info函数内部调用流程分析
  • 面试(1)——Java 数据类型和语法基础
  • 网站流量的主要来源有产品设计方案3000字
  • 厦门 网站建设闽icp网站重定向过多
  • 安康市建设规划局网站网站内容建设出现的问题
  • 块元素、行内元素、HTML5新增标签(本文为个人学习笔记,内容整理自哔哩哔哩UP主【非学者勿扰】的公开课程。 > 所有知识点归属原作者,仅作非商业用途分享)
  • 东莞易赢seo推广员招聘
  • linux常用命令(8)——用户管理
  • 义乌网站制作公司建设广告网站
  • 智能宠物用品店分类架构设计 (台湾市场)
  • 第二十一周 学习周报
  • Linux操作系统学习之---线程控制