sourcemap知多少

4,274 阅读8分钟

想必大家都对这一段截图耳熟能详:

image

因为检测并加载了sourcemap,所以可以直接定位到编译前代码的特定位置。所以用一句话解释sourcemap很简单,就是一段维护了前后代码映射关系的json描述文件。但如果细究其内容,可以泡杯茶说一段故事了~

历史渊源

在2009年google的一篇 文章 中,在介绍Cloure Compiler(一款js压缩优化工具,可类比于uglify-js)时,google也顺便推出了一款调试工具:firefox插件Closure Inspector,以方便调试编译后代码。这就是sourcemap的最初代啦!

You can use the compiler with Closure Inspector , a Firebug extension that makes debugging the obfuscated code almost as easy as debugging the human-readable source.

2010年,在第二代即 Closure Compiler Source Map 2.0 中,sourcemap确定了统一的json格式及其余规范,已几乎具有现在的雏形。最大的差异在于mapping算法,也是sourcemap的关键所在。第二代中的mapping已决定使用base 64编码,但是算法同现在有出入,所以生成的.map相比现在要大很多。

2011年,第三代即 Source Map Revision 3.0 出炉了,这也是我们现在使用的sourcemap版本。从文档的命名看来,此时的sourcemap已脱离Clousre Compiler,演变成了一款独立工具,也得到了浏览器的支持。这一版相较于二代最大的改变是mapping算法的压缩换代,使用VLQ编码生成base64前的mapping,大大缩小了.map文件的体积。

Sourcemap发展史的有趣之处在于,它作为一款辅助工具被开发出来。最后它辅助的对象日渐式微,而它却成为了技术主体,被写进了浏览器中。

.map文件

{
    "version": 3,
    "sources": ["test.es6.js"],
    "names": [],
    "mappings": ";;AAAA,IAAM,MAAM,GAAG,SAAT,MAAM,CAAI,CAAC;SAAK,CAAC,GAAG,CAAC;CAAA,CAAC",
    "file": "test.js",
    "sourcesContent": ["const square = (x) => x * x;"]
}

这就是一段简单的map文件,其中

  • version:sourcemap版本(现在都是v3)
  • sources:源文件列表(如果是打包成bundle.js的,那源文件就有很长一堆了)
  • sourcesContent: 原文件内容(如果是webpack压缩的,可以看到这里显示的代码是经webpack连接后的__webpack_require__那一层)
  • names: 原变量名与属性名(压缩时可能会改变变量名称)
  • mapping:映射json
  • file:编译后文件

接下来,就来详细瞅瞅主要元素mapping把!

mapping策略全攻略

html5rocks的sourcemap教程 (这也是最经典的一篇sourcemap博文了)所说,

sourcemap v1最开始生成的sourcemap文件大概有转换后文件的10倍大。sourcemap v2将之减少了50%,v3又在v2的基础上减少了50%。所以目前133k的文件对应的sourcemap文件大小大概在300k左右。

那么它是如何用最精简的方式维护现文件到源文件的映射的呢?对于整个映射而言,重要的几要素无非是“原文件、原行数、原列数、原变量名”,所以对映射算法的优化,实质也就是对这一段数据的存储优化。

我发现了一篇非常好的文章How do source maps work,作者通过简单的示例解释了map的大致工作原理。接下来我会介(梗)绍(概)下作者的几个要点。

sourcemap示例分析

原代码:

const square = (x) => x * x;

Babel编译后代码:

"use strict";

var square = function square(x) {
  return x * x;
};

//# sourceMappingURL=test.js.map

Mappings(接下来的分析都会以这份mapping为准喔):

";;AAAA,IAAM,MAAM,GAAG,SAAT,MAAM,CAAI,CAAC;SAAK,CAAC,GAAG,CAAC;CAAA,CAAC"

如上,可以看到这段mappings有几个关键元素:分号; 逗号, 及分隔出的一串一眼看不懂的字母。其中:

  • 分号:代表分隔行
  • 逗号:代表分隔列
  • 上文四字字母:经过base64转换过后的VLQ编码,代表了每个segment的原位置定位,比如var, square, =…

附:【base64 VLQ编码 - 十进制转换表 】(先仅供参考,具体转换规则见下文VLQ编码部分)

image

一段segment中的VLQ编码,其实既可以是4位,也可以是5位。每一位都有特定含义:

  • 第一位[0]:代表在转换后文件的第几列(相对于前一个position的相对位置)
  • 第二位[1]:代表源文件在sources里对应的文件序列// 上述mapping的第二位都是A,因为原文件都是第0位
  • 第三位[2]: 代表在源文件的第几行 // 上述mapping的第三位都是A,因为源文件都在也只有第1行
  • 第四位[3]:代表在转换后文件的第几列(相对于前一个position的相对位置)
  • 第五位[4]:代表属于names里的哪一个变量

带着解释,再看以下两个问题

上述mapping为什么以;;开头呢?

上述mapping以;;开头,是因为babel编译后新增的use strict及下面空行没有队对应的原文~

上述mapping的一、二个segement [AAAA, IAAM] 是怎么map到原文件位置的?

[AAAA,IAAM] 转换为VLQ编码是[0000, 4006]。第二个segment里的4代表square在转换后文件的第4列,而6代表square在现文件的第6列。

0|1|2|3|4|  
v|a|r| |s|q|u|a|r|e|  
0|1|2|3|4|5|6|  
c|o|n|s|t| |s|q|u|a|r|e|

以上,相信再看见一段mappings时,不说能立马知道它代表了哪一段源文件,但至少不会两眼一抹黑了~

其实从以上过程读来,在知道了规则的情况下,反编译一段编码总是容易的。难的其实是创造和优化规则的过程。比如将源文件名放在一个数组中,通过存储原文件在数组中的index,而不是源文件name本身;比如存储的column index是相对位置而不是绝对位置;比如通过;分隔行通过,分隔列,而不是记录行号和列号;比如为什么选择了经base64转换的VLQ编码…正是这些微小的一点点比对出来的细节,才能聚沙成塔,将mapping文件最大化的压缩。

VLQ(Variable Length Quantities)编码

要了解VLQ编码,本来我是拒绝的…我也许永远也不会用到…但是一想来都来了,不如也顺便了解一下叭!就当做陶冶情操呢~

So..了解完成后,感觉文本编码和网络技术通常会讲到传递xx包要怎么封装一串数据一样,重点都是差不多的。一是如何分割数字(即表示连续),比如1和17在一起组成117的时候,怎么来表示这是1和17而不是一百一十七。二是如何表示正负。

一个VLQ小节有6位(所以刚好适合通过base64转换成字符呀),第一位代表连续,最后一位一般代表正负,所以中间至少有4位可以表示value。

image

如上,可以看到-15~15都可以用一个VLQ小节来表示。当|Number|>15时,则需要用到多个VLQ小节。另外,正负只需用第一小节的最后一位表示。所以多个小节时,第>1个小节的第6位可以不代表正负,也代表内容。

如果自己有需要使用VLQ编码的话,则可以参考市面上现成的库~比如GitHub - Rich-Harris/vlq

番外:webpack的几种sourcemap选项

用webpack的人都知道,它有好多种sourcemap选项Devtool | webpack。在没了解sourcemap的深层本质之前,我对这几个选项都一知半解,就死记硬背着cheap-module-eval-source-map是最优解╮( ̄▽ ̄")╭

其实这几个选项,都是关键字cheap, module, eval, inline, hidden + sourcemap的排列组合,只要清楚各自的作用及意义就好了。

  • cheap:一看就是简略版的source-map,cheap不包含两样东西。①不映射列名②不映射loader编译前的代码。如果存在loader,比如babel或者jsx,那么就存在双重sourcemap了。生成后代码 => loader处理后代码 => 源代码。
  • module:映射到loader处理前代码。一般和cheap搭配使用,这样cheap-module属性就仅仅是关闭列映射了。
  • eval:eval本身指通过每一个模块主体都被eval()包裹并执行。而eval-source-map则意味着在每个模块下,sourceMappingUrl都通过DataURI的方式加载。
    image
  • inline:直接放在编译后js底部的sourceMappingUrl也是DataURI形式的。
    image
  • hidden:隐藏属性。编译后js底部不会标明sourceMappingUrl,需要手动加载。可用于错误监控服务器上报错误日志时确定位置etc。

对我而言,行数必需列数不必需,映射loader编译前的代码也必需,再加上eval在重新构建时比较快,所以cheap-module-eval-source-map确实是一个不错的选择。

另外,可别把source-map带到线上去了呀,那会给人人带来视奸你代码的机会。

最后,当在开发模式下debug代码时,要记得感谢sourcemap和浏览器的支持。每一个小小的细节和好用之处,都离不开前人长久的努力呀。

Refs & Recommend Readings: