Source Map详解

阿凡达2018-06-26 14:46

在这个年代,对于前端开发来说,用户浏览器的运行代码和我们写的原始代码已经很不一样了。因为我们的代码一般都要经过压缩、合并,还有的是经过Sass, Less, Stylus, CoffeeScript, TypeScript等语言的预编译。这就使代码调试变得困难重重。

通常JavaScript的解释器会告诉你,第几行第几列代码出错,但这对于转换后的代码毫无用处。举例来说,jquery-1.11.2.js压缩后只有3行,每行3万多字符,所有内部变量都改了名字。你看着报错信息,会感到毫无头绪(:晕:),根本不知道它所对应的原始位置。

Source Map就是为了解决这个问题而生的。


示例

不说废话,先上例子。

首先我们用CoffeeScript写一个阶乘函数,将文件命名为sample.coffee

factorial = (num) ->
    if not /^\d+$/.test(num)
        throw 'Error: Not an integer!'

    _factorial = (num) ->
        return (if num <= 1 then 1 else num * _factorial(num - 1))

    return _factorial(num)

window.factorial = factorial

然后将代码编译再压缩,会得到sample.min.js

(function(){var r;r=function(r){var t;if(!/^\d+$/.test(r))throw"Error: Not an integer!";return(t=function(r){return 1>=r?1:r*t(r-1)})(r)},window.factorial=r}).call(this);

当需要调试时,我们的处境就是看着压缩混淆后的JS代码来修改CoffeeScript代码。

当然,对于当前这个例子来说没多大困难,但如果是一个大工程的话,难度就不亚于看着二进制中间码来修改Java代码了。

如果我们有从sample.coffee转换到sample.min.js对应的Source Map文件,情况就大不一样。

重新将代码编译再压缩,同时生成sample.min.map

{"version":3,"file":"sample.min.js","sources":["sample.coffee"],"names":["factorial","num","_factorial","test","window"],"mappings":"CAAA,WAAA,GAAAA,EAAAA,GAAY,SAACC,GACZ,GAAAC,EAAA,KAAG,QAAYC,KAAKF,GACnB,KAAM,wBAKP,QAHAC,EAAa,SAACD,GACb,MAAkB,IAAPA,EAAc,EAAOA,EAAMC,EAAWD,EAAM,KAEtCA,IAEnBG,OAAOJ,UAAYA"}

然后再打开浏览器的调试工具。

可以发现Sources中能够直接找到sample.coffee原始文件,并且可以调试CoffeeScript了耶!

妈妈再也不用担心我调试压缩混淆代码头大了。Demo下载


什么是Source Map?

Source maps offer a language-agnostic way of mapping production code to the original code that was authored.Source Map提供了一种从生成代码到原始代码的映射方法,并且这种方法与语言无关。

简单来说,Source Map就是一个信息文件sample.min.map,里面储存着代码转换前后对应的位置信息,通常该文件的扩展名是.map

如果要使用Source Map调试代码,生成代码sample.min.js的末尾就必须有如下注释,用于指定Source Map的路径:

//# sourceMappingURL=/path/to/sample.min.map

该注释通常由Source Map的生成工具添加。

也可以在生成代码sample.min.js的Response Headers中设置X-SourceMap,来指定Source Map的路径:

X-SourceMap: /path/to/sample.min.map

另外也可以使用Date URI的形式来指定Source Map:

//# sourceMappingURL=data:application/json;base64,Asdi...

这样调试工具加载转换后的代码,就能找到对应的Source Map,再根据Source Map中的信息找到原始代码。从而我们就可以直接用原始代码调试bug和设置断点了。


浏览器支持

目前,Chrome、Firefox、Safari和IE11都支持Javascript和CSS的Source Map了,并且默认都是启用的,你可以用下面的方式确认一下。

Chrome

按F12打开Developer Tools,在Setting设置中找到Sources部分,选中Enable JavaScript source mapsEnable CSS source maps

Firefox

按F12打开Developer Tools,在调试器Tab页的右上角找到调试器选项,选中显示原始来源

Safari

我木有Safari。。(:伤不起:)

IE11

按F12打开Developer Tools,在调试程序Tab页中先选择对应的JS文件,然后在上方选中加载已映射到此生成文件的源


Source Map的生成

Closure

最常用的方法是使用Google的Closure编辑器

生成命令的格式如下:

$ java -jar compiler.jar --js=sample.js --create_source_map=sample.min.map --source_map_format=V3 --js_output_file=sample.min.js

各个参数的意义如下:

  • --js:转换前的代码文件路径;
  • --create_source_map:生成的Source Map文件路径;
  • --source_map_format:Source Map的版本,目前一律采用V3;
  • --js_output_file:转换后的代码文件路径。

uglifyjs

uglifyjs是nodejs下的一款优秀的JS压缩优化工具,由于jQuery改用uglifyjs作为其压缩工具令其声名远播,支持Source Map的生成。

首先安装uglifyjs:

$ npm install uglify-js -g

然后生成命令的格式如下:

$ uglifyjs sample.js -cm -o sample.min.js --source-map=sample.min.map

各个参数的意义如下:

  • -c:使用压缩器。
  • -m:使用混淆器。
  • -o:转换后的代码文件路径。
  • --source-map:生成的Source Map文件路径;
  • --source-map-root:(可选)用于填充Source Map文件的sourceRoot属性。
  • --source-map-url:(可选)Source Map的URL路径,用于添加到生成代码的注释//# sourceMappingURL=中,默认对应--source-map的值;
  • --source-map-include-sources:(可选)是否将原始代码的内容添加到Source Map文件的sourcesContent属性中。
  • --in-source-map:(可选)Source Map输入,如果要压缩的JS是从其他原始代码生成的,就要使用这一项。

Less

首先安装Less:

$ npm install less -g

然后生成命令的格式如下:

$ lessc sample.less sample.css --source-map=sample.css.map

各个参数的意义如下:

  • --source-map:生成的Source Map文件路径;
  • --source-map-rootpath:(可选)用于填充Source Map文件的sourceRoot属性。
  • --source-map-inline:(可选)以Data URI Scheme的形式将Source Map文件内容内嵌到css文件中。
  • --source-map-url:(可选)Source Map的URL路径,用于添加到生成代码的注释//# sourceMappingURL=中,默认对应--source-map的值。

CoffeeScript

生成命令的格式如下:

$ coffee -cm sample.coffee

  • -c:将CoffeeScript编译成JavaScript。
  • -m:生成Source Map文件,后缀为.js.map

可以进一步将生成的代码压缩:

$ uglifyjs sample.js -cm -o sample.min.js --source-map=sample.min.map --in-source-map=sample.js.map

这样就可以将压缩后的代码sample.min.js直接映射回CoffeeScript的原始代码sample.coffee了。

文章开头示例中的Source Map就是这样生成的。


Source Map的内容

打开Source Map文件,它大概是这个样子:

{
    version: 3,
    file: "out.js",
    sourceRoot: "",
    sources: ["foo.js", "bar.js"],
    sourceContent: null,
    names: ["src", "maps", "are", "fun"],
    mappings: "AAgBC;SAAQ,CAAEA;EAAA,KAAG,QAAYC"
}

整个文件就是JSON格式,主要有以下几个属性:

  • version:Source Map的版本,目前为3;
  • file:转换后的文件名;
  • sourceRoot:转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空;
  • sources:转换前的文件。该项是一个数组,表示可能存在多个文件合并的情况;
  • sourceContent:转换前文件的内容。当没有配置sources的时候会使用该项;
  • names:转换前的所有变量名和属性名;
  • mappings:记录位置信息的映射表,下文详细介绍。

mappings属性

这部分才是真正有趣的部分,从这里可以看到代码转换前后中各个位置是如何对应的。

mappings属性是一个很长的字符串,它分成三级。

  • mappings字符串用;分割的一组对应生成代码中的一行;
  • 每一组用,分割的一段对应生成代码中的一个位置信息;
  • 每一段里是5个数字的Base64 VLQ编码,比如[9,0,0,1,1]这5个数字的编码是SAACC
    • 第0个数字,表示这个位置在生成代码的第几列;
    • 第1个数字,表示这个位置属于sources属性中的哪一个文件;
    • 第2个数字,表示这个位置属于原始代码的第几行;
    • 第3个数字,表示这个位置属于原始代码的第几列;
    • 第4个数字,表示这个位置属于names属性中的哪一个变量。这个数字不是必需的,可以省略。
    • 由于Base64 VLQ编码是变长的,所以每一段可以由多个字符构成。

关于Base64 VLQ编码本文不再详述,其实并不复杂,请参考:Base64 VLQ编码规则Source Map中VLQ编码的JS实现VLQ在线编码解码工具

示例1

假设mappings属性的内容如下:

mappings: ";AAAA;AAAA,MAAA,SAAA;AACX,QAAA,UAAA"

这表示生成代码分成4行,第0行为空行,第1行有1个位置信息,第2行有3个位置信息,第3行有3个位置信息。

示例2

我们将文章开头示例中的Map文件的mappings属性拿过来试一下:

mappings: "CAAA,WAAA,GAAAA,EAAAA,GAAY,SAACC,GACZ"

这例子中的生成代码只有1行。我们使用VLQ在线编码解码工具把内容解码一下:

[1,0,0,0], [11,0,0,0], [3,0,0,0,0], [2,0,0,0,0], [3,0,0,12], [9,0,0,1,1], [3,0,1,-12]

注意: 每组值都是相对于前一组值的。

每一组加上前一组的值可以得到:

[1,0,0,0], [12,0,0,0], [15,0,0,0,0], [17,0,0,0,0], [20,0,0,12], [29,0,0,13,1], [32,0,1,1,1]

整理一下可以得出如下结果:

(a,b)=>name(m,n)表示生成代码中的a行b列对应原始代码中的m行n列的位置,并且在原始代码中这个位置的变量名是name。

(0,1)=>(0,0)
(0,12)=>(0,0)
(0,15)=>factorial(0,0)
(0,17)=>factorial(0,0)
(0,20)=>(0,12)
(0,29)=>num(0,13)
(0,32)=>num(1,1)

大家可以根据开头的示例代码验证一下上面的结果。

扩展阅读

本文来自网易实践者社区,经作者赵雨森授权发布。