sourcemap 这么讲,我彻底理解了

5,556 阅读12分钟

什么是 sourcemap

我们在项目打包时,常常会看到 .js.map 的文件,这就是 sourcemap 文件。sourcemap 记录了源代码和编译后代码的相关信息,可以将编译后代码映射为源代码,以帮助开发人员排查问题代码出现的位置,进而对问题代码进行修复。

实际使用场景

下面我写了一个 sort 函数,它接受一个 array 类型的参数并返回其升序排序后的结果,我故意调用不传参数导致其出错:

function sortArrayInAsc(arr) {
  return arr.sort((a, b) => a - b); // 第 5 行调用 sortArrayInAsc 代码时没传 arr 参数,会导致这里出错
}

console.log(sortArrayInAsc());

然后我们用 webpack 来启动一个开发环境,html 中引入这个文件,看一下在开启和关闭 sourcemap 的情况下,报错信息的提示位置是什么样子的。

  • 不开启 sourcemap

    首先我们不设置 webpack.config.js 中的 devtool 选项,我们发现报错信息指向了压缩混淆后的代码,根据这段代码很难判断出错的源代码是哪里:

  • 开启 sourcemap

    然后我们将 webpack.config.js 中的 devtool 选项设置为 sourcemap,再来看一下报错位置的结果,发现直接指向了报错的源代码位置,我们可以快速的修复问题:

对比发现,通过 sourcemap 我们可以根据编译后的代码位置找到源代码报错位置,从而快速进行修复。生产环境下,为了防止别人获取源代码,通常不会将 sourcemap 文件上传到静态资源服务器,而是上传到内部服务器上。当用户触发 js 错误时,通过前端监控系统或者其他手段收集到出错信息,然后根据内部服务器的 sourcemap 结合出错信息,找到出错的源代码位置。

sourcemap 设计思路

sourcemap 的作用就是将编译后代码映射为编译前源代码,所以要搞明白 sourcemap 的核心原理,就是搞明白如何将编译后代码映射为编译前源代码,下面让我们看一下其具体的设计思路

映射需要哪些因素

假如我们有如下的代码:

  • 编译前 /src/index.js

    function print(variable) {
      console.log(variable);
    }
    print(Date.now());
    print(Math.random());
    
  • 编译后 /dist/index.js

    (()=>{function o(o){console.log(o)}o(Date.now()),o(Math.random())})();
    //# sourceMappingURL=index.js.map
    

先让我们想想,我们想通过编译后代码,映射到编译前的代码,都需要哪些信息?我们已知了编译后的代码,那么要做映射,需要知道是哪个编译后文件映射到哪个源文件以及编译后的哪个变量映射到源文件的哪个变量。

例如我们先进行 print 函数的映射,通过上述代码,我们大体可以对应编译后的 o 函数,那么映射就是:

但是我们发现编译后的代码中,print 函数的形参 variable 也被编译成了 o,那么怎么知道编译后的每个 o 对应编译前的哪个变量呢?我们还需要加上编译后和编译前变量的位置,即加上变量所在的行、列的开始位置:

如此一来,我们便知道了确定映射关系需要 8 个要素,这 8 个元素组成了一个映射段: 编译后文件|编译后变量起始行|编译后变量起始列|编译后变量名|源文件|源代码起始行|源代码起始列|源代码变量名,在 sourcemap 中,通过一个 mappings 字段,将所有映射段以 , 链接形成一个字符串,那这个字符串就可以确定完整的代码映射:

精简映射所需元素

上面我们已经知道了确定映射关系所需的要素,一共 8 个元素,这 8 个元素组成的段我们暂称为映射段,假如我们一共有 100 个变量的映射,那我们需要写 100 个 /dist/index.js | 0 | 15 | o | /src/index.js | 0 | 9 | print 这样的映射段。我们需要尽可能要 sourcemap 的体积精简,不占用太多的空间,那我们看看能不能想办法精简映射的元素。

  • 编译后文件:一个编译后文件只会指向一个 sourcemap 文件,所以在一个 sourcemap 文件中,编译后文件名都是一样的,sourcemap 标准中就是用一个 file 字段记录下编译后文件名称,就可以在映射段去除掉编译后文件这个要素。

  • 编译后变量起始行:我们解析编译后的代码,都是从头到尾按照顺序来的,也就是说行也是从头到尾按照顺序解析的。那么这种顺序结构,我们通常可以用数组记录,例如第一行编译后代码的映射放到数组下标 0、第二行放到下标 1……而 sourcemap 标准中所有映射段都放在一个字符串中,采用 ; 分隔每一行的映射,就可以去掉映射段中的编译后代码起始行。

  • 编译后变量起始列:许多时候,编译后代码只有一行,列可能达到几万甚至几十万,意味着到了靠后的映射段我们需要一个很大的数字去记录起始列。我们知道编译后的代码是从头到尾按顺序的,那么根据这个思路,我们可以使用增量来记录,即记录的是当前这个变量相对于同一行上一个变量的所在的起始列的增量,例如function print(variable)print 起始列下标为 9variable 其实列下标是 11,那么增量就是 variable 相对于 print 的起始列增量就是 2,我们记录 2 来代替 11

  • 编译后变量名:我们都知道,js 变量名都是以字母、数字、$ 或者 _ 组成的连续字符串(不能以数字开始),我们已经有了编译后的代码和编译后变量的起始列,自然知道编译后的变量名是什么,所以不需要再映射段中记录编译后变量名:

  • 源文件:上面说到我们编译后文件名可以省略,因为一个 sourcemap 只对应一个编译后文件,那源文件能省略吗?答案是不能。以 webpack 为例,打包过程中可能将多个源文件打包到一个 chunk 中(编译后文件),也就是说一个 sourcemap 对应多个编译前文件,所以源文件的信息我们需要记录。但是我们可以不必再每个映射段中都记录源文件,因为通常很多个映射段对于一个源文件,所以我们可以将源文件作为一个数组提出来,sourcemap 用 sources 字段记录了源文件的数组,然后每个映射段记录的是对应源文件在 sources 数组总的下标,和前面列的思路一样,我们可以记录相对前一个映射段中 sources 下标的增量:

  • 源代码起始行:mappings 中映射段的顺序是按照编译后代码顺序来的,其对应的编译前代码不一定是按序的,所以源代码的起始行我们无法通过数组或者 ; 等形式省略,但是我们同样可以用相对于上一个映射段中源代码起始行的相对增量来记录行数,以减小记录的行数位数:

  • 源代码起始列:同上,我们使用相对增量记录源代码起始列:

  • 源代码变量名:上面我们通过已知编译后代码和编译后变量的起始位置得到了编译后变量名,所以可以在映射段中省略编译后变量名的记录。但是我们是不知道源代码具体内容的,所以我们无法省略源代码变量名。但是同源文件的思路一样,源代码中同一个变量是被多次使用的,所以 sourcemap 用 names 字段记录了源代码中的变量数组,然后在映射段中记录对应变量名在 names 数组中下标,同样使用增量:

经过上述的精简过程,我们一个映射段中记录的内容从 8 个变量 编译后文件|编译后变量起始行|编译后变量起始列|编译后变量名|源文件|源代码起始行|源代码起始列|源代码变量名 就缩减到了 5 个变量:编译后变量起始列(增量)|对应sources源文件下标(增量)|源代码起始行(增量)|源代码起始列(增量)|对应names变量名下标(增量),且五个变量都是数字。

进一步精简(base64 VLQ 编码)

上述步骤中我们将映射段缩减到了五个数字的串,例如有个映射段 11 | 1 | 0 | 0 | 0,那我们能否再进一步缩减呢?看到这个字符串,我们可以知道进一步缩减有两个方法:

  • 通过 base64 编码减少记录的数字位数
  • 去掉分隔符 |

第一个方法蛮好理解的,第二个去掉分隔符 | 能做到吗?例如上面的映射段去掉分隔符就变成了 111000,我们如何知道从哪里划分呢?显然 | 代表一个部分结束的标识,所以我们如果能给数据带上一个是否该部分结束的标识,那我们就知道该如何划分了,sourcemap 通过 base64 VLQ 编码实现了这一点。

VLQ 通过 6 位二进制数进行存储,第一位表示连续位(1 表示连续,0 表示不连续,也就是说如果是 1 代表这部分还没结束),最后一位表示是正数还是负数(1 是负数,0 是正数),中间的 4 位用了存储数据,所以一个 6 位二进制存储的范围是 [-15, 15],超过就需要用连续 6 位二进制数(连续的 6 位二进制数从第二个开始不需要记录是正负数了,所以第二个之后后 5 位存储数据)。以下面两个例子来理解:

经过转化,十进制的 -721 分别被转化成了 base64 VLQ 编码的 PqB

  • 当我们读取一个映射段中读到了 p 时,根据上述方法倒推可以得到 6 位二进制数 001111,以 0 开头,说明这部分不连续,那么 P 就代表了映射段中一部分
  • 而读到 q 时,我们倒推出 q 的 6 位二进制数 101010,以 1 开头,说明这部分是连续的还没结束,要继续读一下个 B,而 B 的 6 位二进制数 0000010 开头,说明不连续,qB 两个字符构成了映射段中的一部分。

上述 VLQ 编码和 base64 编码的对应关系如下表:

通过上述的简化关系,11 | 1 | 0 | 0 | 0 就可以精简为 WCAAA,得到的映射段字符串就非常精简了。

base64vlq 这个网站可以验证 base64 VLQ 转换为 10 进制数的结果。

sourcemap 原理

最后我们看一下一个真实的 sourcemap 文件中的内容:

{
  "version": 3, // sourcemap 版本
  "file": "index.js", // 编译后文件名
  "mappings": "MAAA,SAASA,EAAMC,GACbC,QAAQC,IAAIF,EACd,CAEAD,EAAMI,KAAKC,OACXL,EAAMM,KAAKC,S", // 映射段字符串
  "sources": [
    "webpack://wp/./src/index.js"
  ], // 源文件数组
  "sourcesContent": [
    "function print(variable) {\n  console.log(variable);\n}\n\nprint(Date.now());\nprint(Math.random());\n"
  ], // 编译前源代码
  "names": [
    "print",
    "variable",
    "console",
    "log",
    "Date",
    "now",
    "Math",
    "random"
  ], // 源代码中变量数组
  "sourceRoot": "" // 源文件根目录
}

上述的信息中,mappings 是编译后代码与源代码的映射关系,可以分为三层:

  • ;: 代表编译后代码的换行,用来定位是编译后代码第几行。(上述代码打包后只有一行,所以没有分号)
  • ,: 用于分词,分隔编译后代码的变量。(例如上述代码编译后为 console.log((void 0).sort((o, l) => o - l));,按次序划分为了 9 个变量)
  • 英文串: 每段英文串表示了编译后代码和源代码的位置关联的 base64VLQ 编码,一段英文串又可以分为五部分:
    • 第一部分:编译后代码所在的列

    • 第二部分:sources 对应源文件的下标(相对上一段中下标的增量)

    • 第三部分:源代码的第几行(相对于上一段内容中的增量)

    • 第四部分:源代码的第几列(相对于上一段内容中的增量)

    • 第五部分:names 对应变量的下标(相对上一段中下标的增量)

      英文串并不是一定只有 5 部分,也可能只有第一部分或者只有第 1-4 部分,例如下述情况:

      • 只有第 1 部分:例如上面 mappings 中最后一段 S,它后面跟的内容是打包工具编译时自己加的自执行函数,不对应编译前的任何代码,这个情况不需要往源代码中映射:
      • 只有第 1-4 部分:例如上面 mappings 中的 MAAA 这部分,它对应的编译后代码如下,function 是 js 内置的关键词,不是一个变量名,所以不需要在映射段中包括这部分,映射时缺少第 5 部分只需要映射为编译后代码当前的关键词就行。

整体的映射原理其实就是我们上面讲的 sourcemap 设计思路,看不懂此部分的话可以再倒回去捋一遍,应该就比较明白了。