深入了解 sourceMap

avatar
前端切图仔 @豌豆公主

sourceMap 于今前端er来讲,已经不再陌生,因为我们随手都能写出比较超前的代码,为了更好的运行这些代码,我们的代码就会经过各种加工处理,这使得实际运行中的代码跟我们手写的代码存在着很大的gap。时光冉冉,我们终会产生根据报错的行和列找到原文件行和列的需求。sourceMap 就是这解决问题的关键,它就记录着已转换代码源代码的映射关系。一旦我们知道报错的行列号,通过 sourceMap 我们就能找出源代码的对应位置。

在浏览器中怎么使用的?

  • 生产一个source map
  • 加入一个注释在转换后的文件,它指向source map。注释的语法类似

一个 sourceMap 的基本结构如下

{
  "version": 3, // sourcemap 版本号
  "file": "main.js", // 转换后的文件名
  "sources": ["a.js", "b.js"], // 源文件列表,一个文件可能是合并多个小文件而来
  "names": [], // 原变量名和属性名,比如使用 uglify 等压缩工具,会修改变量名
  "mappings": ";;AAAA,IAAM", // 映射关系
  "sourceContent": ["..."] // 源代码(可能没有)
}

深入 sourceMap

怎么才能深入的了解呢,那就是深入灵魂,基于 sourceMap 的作用,我们可以提取出4个关键信息:转换后文件的行(r)、转换后文件的列(c)、原文件的行(r‘)、原文件的列(c’)。因此 soucemap 文件就必然存储着这种映射关系,看看上面的文件结构中的属性名,没错 mappings 就储存着这些关键信息。

我用tsc编译了一段代码,来做具体分析:

源代码(index.ts):

class Hello {
  constructor() {}
}

编译成ES5之后的代码(index.js):

"use strict";
var Hello = /** @class */ (function () {
    function Hello() {
    }
    return Hello;
}());
//# sourceMappingURL=index.js.map

sourceMap文件(index.js.map):

{
  "version": 3,
  "file": "index.js",
  "sources": [
    "index.ts"
  ],
  "names": [],
  "mappings": ";AAAA;IACE;IAAe,CAAC;IAClB,YAAC;AAAD,CAAC,AAFD,IAEC"
}

mappings 的值就是一个字符串,它包含了一堆字母,还有;,这两个符号,很是有规律。其中的原理呢,就是字符串通过;,的切割,就能拿到的对应关系了:通过 ; 切割出每行的信息,再用 , 切割行信息得到列信息。也就意味着:通过数;的个数,就能确定编译后文件的行号r,通过解析 , 切割出来的数据,就能得到列号 c原文件行 r'原文件列 c'

在这个实际的例子,我们可以看到第一个;号前是空的,返观上面的代码,我们能发现 "use strict",在原文件中的确没有,因此第一行没有任何映射关系。那么后面的 AAAA ,怎么得到其中的信息呢,这段字符其实是被一种压缩算法压缩了,我们只要找到对应的算法解码就行。这个算法就是 VLQ 了,我们直接用现成的库来解码就行 AAAA => 0, 0, 0, 0,诶?怎么会有四个数字呢,我来解释一下每一个数字的含义:

第一位: 代表在转换后文件的第几列 (同行相对上一个列的偏移量)

第二位: 代表源文件在sources里对应的下标

第三位: 代表在源文件的第几行(相对于上一个坐标行的偏移量)

第四位: 代表在源文件的第几列(相对于上一个坐标列的偏移量)

我们翻译过来就是 index.js [1, 0] --- index.ts [0, 0],下面直接进行超能力翻译了:

第0行
第1行  AAAA =>  0, 0,  0,   0 => index.js [1,             0] --- index.ts [          0,              0];
第2行  IACE =>  4, 0,  1,   2 => index.js [2,             4] --- index.ts [(0 + 1) = 1, ( 0 +  2) =  2];
第3行  IAAe =>  4, 0,  0,  15 => index.js [3,             4] --- index.ts [(1 + 0) = 1, ( 2 + 15) = 17],
第3行  CAAC =>  1, 0,  0,   1 => index.js [3, (4 +  1) =  5] --- index.ts [(1 + 0) = 1, (17 +  1) = 18];
第4行 IAClB =>  4, 0,  1, -18 => index.js [4,             4] --- index.ts [(1 + 1) = 2, (18 - 18) =  0],
第4行  YAAC => 12, 0,  0,   1 => index.js [4, (4 + 12) = 16] --- index.ts [(2 + 0) = 2, ( 0 +  1) =  1];
第5行  AAAD =>  0, 0,  0,  -1 => index.js [5,             0] --- index.ts [(2 + 0) = 2, ( 1 -  1) =  0],
第5行  CAAC =>  1, 0,  0,   1 => index.js [5, (0 +  1) =  1] --- index.ts [(2 + 0) = 2, ( 0 +  1) =  1],
第5行  AAFD =>  0, 0, -2,  -1 => index.js [5, (1 +  0) =  1] --- index.ts [(2 - 2) = 0, ( 1 -  1) =  0],
第5行  IAEC =>  4, 0,  2,   1 => index.js [5, (1 +  4) =  5] --- index.ts [(0 + 2) = 2, ( 0 +  1) =  1]

通过上上面的数字含义的,结合我在上面用括号计算过程中加入的括号,不知道大家有没有看明白。编译后文件的行号,我们直接可以获取到,每行的第一个列数据可以直接使用,但是如果这一行里面有多个列数据,那么就需要从左往右依次计算,公式就是前一个列号+偏移量(每行独立计算),如果是第一个列数据,那前列号就是0,反复分析第3行和、第4行和第5行,可以看出端倪。源文件的行号列号需要从第一个计算出来的点开始,按顺序一直加上偏移量(偏移量也有可能是负数,比如第4行第一个列数据)。

我们可以发现,解析列信息并不是想象中那么简单,需要通过一定的算法计算才能获得正确的信息,这也是 sourceMap 迭代到第三版的成果,这里面最大的目的就是为了压缩 sourceMap 的体积,但只要我们了解其生成的原理,sourceMap 也就不神秘了,看到这里,我相信你也可以徒手分析 sourceMap 了。

总结

sourceMap 本质上仅仅只是一种位置映射关系,我们可以根据一个坐标,反推出源文件的坐标,但它并不能在运行环境中建立对应的变量映射关系,比如源代码 const name = 1; , 被转换成了 var a = 1;,我们在 debug 的时候,如果直接把鼠标放在源代码中的 name 这个变量上,我们并不会得到变量值的提示。由于某些原因,转换后的代码和源代码,并不是每一行,每一列都是有对应关系(最常见的是 tree shaking 它会删除一些代码),当然我们也会遇上在 debug 模式想在某行加断点却加不上的情景😊。

Refs