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

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

  1. 访问http://localhost:8000/index.html。(此时浏览器调试工具未打开)
  2. 10秒后,打开浏览器调试工具。
  3. 再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这个最重要的内容,是如何记录转换前后代码中标识符的位置关系的。这些内容后面会有单独文章介绍。

参考

相关推荐
samroom3 小时前
iframe实战:跨域通信与安全隔离
前端·安全
fury_1233 小时前
vue3:数组的.includes方法怎么使用
前端·javascript·vue.js
weixin_405023373 小时前
包资源管理器NPM 使用
前端·npm·node.js
宁&沉沦3 小时前
Cursor 科技感的登录页面提示词
前端·javascript·vue.js
Dragonir3 小时前
React+Three.js 实现 Apple 2025 热成像 logo
前端·javascript·html·three.js·页面特效
古一|4 小时前
Vue3中ref与reactive实战指南:使用场景与代码示例
开发语言·javascript·ecmascript
peachSoda74 小时前
封装一个不同跳转方式的通用方法(跳转外部链接,跳转其他小程序,跳转半屏小程序)
前端·javascript·微信小程序·小程序
@PHARAOH5 小时前
HOW - 浏览器兼容(含 Safari)
前端·safari