混撰

Source Map原理及源码探索

戎码一生
0 条评论
5 人喜欢
295 次阅读
全文共 4162 预计阅读时长 18 分钟

简介

如今前端的代码发布到线上通常都经过了这个过程:输入 => 转换器 => 输出,经过这一过程后,线上的代码已经被压缩或者混淆,虽然可能代码体积减小了,减少了网络开销,但是对于开发者调试来说确是无比痛苦的。

Source Map的诞生就是为了解决上面的问题的。目前Source Map已经到了v3版了,最开始的v1版是由Joseph Schorr创建的,初衷是为了让闭包检查器(Closure Inspector)在优化JS代码的时候能进行源代码级别的调试,但是随着项目的规模不断的扩大,v1版所带来的映射结果也会变得异常冗长。所以v2版本做了一些优化,增加了映射文件的灵活性和简便性,减少了映射文件的总体大小(相较于v1版减少了20%~30%),但是前面的问题在v2版本依然存在。所以v3版也就应运而生了,更进一步地缩减了映射文件的大小(相较于v2版减少了50%左右)。

v3规范格式

{
  "version" : 3,                          // Source Map版本
  "file": "out.js",                       // 输出文件(可选)
  "sourceRoot": "",                       // 源文件根目录(可选)
  "sources": ["foo.js", "bar.js"],        // 源文件列表
  "sourcesContent": [null, null],         // 源内容列表(可选,和源文件列表顺序一致)
  "names": ["src", "maps", "are", "fun"], // mappings使用的符号名称列表
  "mappings": "A,AAAB;;ABCDE;"            // 带有编码映射数据的字符串
}

mappings数据有如下规则:

  • 生成文件中的一行的每个组用“;”分隔
  • 每一段用“,”分隔
  • 每个段由1、4或5个可变长度字段组成

预备知识

VLQ

VLQ背后的想法其实很简单

VLQ的10进制表示

如果你想按顺序记录4位10进制数字,最简单的办法就是用一个特殊字符来分隔这些数字

1|2|3|4

而且如果我们如果知道了每个数字只有1位,那么我们完全可以把特殊字符去掉只需要简单的记录成1234即可,这个时候因为去掉了特殊字符,所以长度缩减了快一半了,空间省了不少

但是,如果有的数字超过1位呢?

1|23|456|7

按照VLQ的编码方式,如果一个数字后面还有剩余数字,将其表示出来即可。所以上面的数字可以用下面这种来表示

1 2 3 4 5 6 7

使用下划线来标识一个数字后面根由其他数字

这段编码可以这么来解析:

  • 1没有下划线,所以第一个数字是1
  • 2有下划线,所以2后面跟的是3,而3没有下划线,所以第二个数字是23
  • 45有下划线,所以45后面跟的是6,而6没有下划线,所以第三个数字是456
  • 7没有下划线,所以第四个数字是7

这是10进制的VLQ的简化编码

VLQ的2进制表示

在上面的十进制表示里,其实下划线是不属于十进制的,它只是一个标记位,但是在二进制中却有两个问题

  • 二进制符号位如何表示?
  • 像45有下划线这种在二进制里如何表示?

VLQ使用由6位组成的二进制组(总共64个可能的值),并且用使用第一位作为连续标记位(上文的下划线,下图的C),0表示不连续,1表示连续。最后一位作为符号标记位(下图的S),0表示正,1表示负。剩余的四位从来表示数值

为什么是要用6位来表示呢?

B5 B4 B3 B2 B1 B0
C S
第一个字节组(四位作为值)

可以表示的范围如下:

二进制 数值
000000 0
000001 -0
000010 1
000011 -1
011110 15
011111 -15
100000 未结束的0
100001 未结束的-0
100010 未结束的1
100011 未结束的-1
111110 未结束的15
111111 未结束的-15

-0虽然没有实际意义,但是技术上确是存在的

未结束的字节组(用五位作为值)

当第一位已经确定符号后,后续的字节组其实就不用确定符号位了,这样就可以用五位作为值

B5 B4 B3 B2 B1 B0
C

现在我们重新用二进制的VLQ来编码上文中10进制的1|23|456|7

先看下每个数字的二进制对照:

十进制 二进制
1 1
23 10111
456 111001000
789 111
编码10进制的1
B5(C) B4 B3 B2 B1 B0(S)
0 0 0 0 1 0
编码10进制的23

23的二进制是10111,有5位,但是第一个字节组只能提供4位来记录值,所以需要用两组字节组来表示,将10111拆成两组,把4个最低有效位放在第一组(0111),剩下的(1)放在连续组

B5(C) B4 B3 B2 B1 B0(S) B5(C) B4 B3 B2 B1 B0
1 0 1 1 1 0 0 0 0 0 0 1
编码10进制的456

原理同上

B5(C) B4 B3 B2 B1 B0(S) B5(C) B4 B3 B2 B1 B0
1 1 0 0 0 0 0 1 1 1 0 0
编码10进制的7
B5(C) B4 B3 B2 B1 B0(S)
0 0 1 1 1 0

所以我们最终二进制VLQ编码的结果如下:

000010 101110 000001 110000 011100 001110

Base64编码

到了这里,上面的问题(为什么是要用6位来表示呢?)也就得到答案了,因为Base64编码定义了64个字符的字母表,所以VLQ为了和Base64匹配上,也就选用了6位来表示

VLQ本身其实是有8位的,这里的6位其实是VLQ的一个变种

所以上面二进制VLQ编码在用Base64编码后得到的字符为:CuBwcO

base64对照表

《base64对照表》

简单转换示例

先看一个简单示例,假设我有个input.txt文件,它被转换器转换后的内容如下:

we are family => 转换器 => family we are

那么转换前后各字符的位置对应如下:

输出位置(行,列) 输入文件 输入位置(行,列) 字符
1,0 input.txt 1,7 f
1,1 input.txt 1,8 a
1,2 input.txt 1,9 m
1,3 input.txt 1,10 i
1,4 input.txt 1,11 l
1,5 input.txt 1,12 y
1,7 input.txt 1,0 w
1,8 input.txt 1,1 e
1,10 input.txt 1,3 a
1,11 input.txt 1,4 r
1,12 input.txt 1,5 e

这里之所以把输入文件也列举了出来,是因为有时可能有多个源文件进行合并转换,所以记录下输入文件对于我们还原代码很有必要

将上述表格整理成一个映射编码如下:

mappings(203字符):1|0|input.txt|1|7,1|1|input.txt|1|8,1|2|input.txt|1|9,1|3|input.txt|1|10,1|4|input.txt|1|11,1|5|input.txt|1|12,1|7|input.txt|1|0,1|8|input.txt|1|1,1|10|input.txt|1|3,1|11|input.txt|1|4,1|12|input.txt|1|5

这样经过一定的逆向转换后可以映射回原来的文件,但是很明显有一个致命的缺陷,随着内容的增多,映射编码会快速增多并且占用大量空间,这里源文件只有11个有效字符,但是映射编码却有203个字符之多,所以这个映射规则还需要改进

改进1:省略输出文件的行号,使用“;”换行

一般情况下,输出文件的行数都会比源文件少(例如js压缩等),不必每条映射都写上行号,使用“;”来代替换行可以节省大量空间

将上述映射重新编码后如下:

mappings(182字符):0|input.txt|1|7,1|input.txt|1|8,2|input.txt|1|9,3|input.txt|1|10,4|input.txt|1|11,5|input.txt|1|12,7|input.txt|1|0,8|input.txt|1|1,10|input.txt|1|3,11|input.txt|1|4,12|input.txt|1|5;

改进2:名称和输入文件列表按索引引用

是不是觉得上面mappings里重复的东西有点多,其实我们这个例子一共有3个单词,只要我们确认了每个单词的第一个字母的位置,那么剩下的其实也就不用再记录映射了,所以我们提取出两个索引表:

索引 输入
0 input.txt
索引 输入
0 family
1 we
2 are

{
    "sources": ["input.txt"],
    "names": ["family", "we", "are"]
}

那么最开始的对应的表格可以简化:

输出位置(行,列) 输入索引 输入位置(行,列) 名称索引
1,0 0 1,7 0
1,7 0 1,0 1
1,10 0 1,3 2

所以映射可以优化成:

mappings(31字符):0|0|1|7|0,7|0|1|0|1,10|0|1|3|2;

改进3:使用相对偏移,而不是绝对偏移

当文件内容变得巨大的时候,上面的映射编码也会随着数字位数的增多而变得很长,我们可以用相对偏移来解决这个问题

看下用相对偏移来简化第二步优化中的表格:

输出位置(行,列) 输入索引 输入位置(行,列) 名称索引
1,0 0 1,7 0
1,+7 +0 +0,-7 +1
1,+3 +0 +0,+3 +1

所以映射可以优化成:

mappings(31字符):0|0|1|7|0,7|0|0|-7|1,3|0|0|3|1;

改进4 通过VLQ+Base64编码进一步压缩

有了上面预备知识的铺垫,现在我们可以把第三步的映射进一步编码

mappings(18字符):AACOA,OAAPA,GAAGA;

所以最后我们得到的Source Map看起来如下:

{
    “version”: 3,
    "file": "input.txt",
    "sources": ["input.txt"],
    "names": ["family", "we", "are"],
    "mappings": "AACOA,OAAPA,GAAGA;"
}

改进5 省略不必要的字段

上面例子中每个映射都是5位的,但在现实场景中,有些字段可能没有提供任何有用的信息,那么其实可以忽略这些字段,这时映射长度就有可能变成1,4或者5了:

  • 5个字段:输出列,输入文件名索引,输入行,输入列,名称索引
  • 4个字段:输出列,输入文件名索引,输入行,输入列
  • 1个字段:输出列

源码探索

上面其实是介绍了一个简单的例子的映射的生成过程,但是我们还是需要去源码中探索的。

看了下uglify-js的依赖,它生成SourceMap的功能是有 source-map 这个库提供的,并且uglify-js中是这样调用的

// uglify-js/lib/sourcemap.js

function SourceMap(options) {
    // 省略
    // MOZ_SourceMap是构建的时候注入进来的source-map库
    var generator = new MOZ_SourceMap.SourceMapGenerator({
        file: options.file,
        sourceRoot: options.root
    });
    // ...
    return {
        add: function(source, gen_line, gen_col, orig_line, orig_col, name) {
            // 省略
            // addMapping是在AST解析过程中调用的,为了添加对应的映射mapping
            generator.addMapping({
                name: name,
                source: source,
                generated: {
                    line: gen_line + options.dest_line_diff,
                    column: gen_col
                },
                original: {
                    line: orig_line + options.orig_line_diff,
                    column: orig_col
                }
            });
        },
        get: function() {
            return generator;
        },
        toString: function() {
            return JSON.stringify(generator.toJSON());
        }
    }
}

uglify-js生成SourceMap的整体流程如下

生成AST => 压缩 => 调用addMapping方法,添加对应的映射mapping => 输出压缩后代码时调用toString方法生成SourceMap

可以看到用到了source-map的SourceMapGenerator类,并且输出SourceMap用的是generator.toJSON(),所以去看了下SourceMapGenerator的toJSON方法

// source-map/lib/source-map-generator.js

SourceMapGenerator.prototype.toJSON =
  function SourceMapGenerator_toJSON() {
    var map = {
      version: this._version,
      sources: this._sources.toArray(),
      names: this._names.toArray(),
      // 关键是这一句,用了_serializeMappings方法来序列化输出
      mappings: this._serializeMappings()
    };
    // 省略

    return map;
  };

再去看看_serializeMappings方法

// source-map/lib/source-map-generator.js

SourceMapGenerator.prototype._serializeMappings =
  function SourceMapGenerator_serializeMappings() {
    // 省略
    // mappings其实就是外部传进来的压缩前后的代码位置映射集,可以看下方的例子
    for (var i = 0, len = mappings.length; i < len; i++) {
      mapping = mappings[i];
      next = ''

      // 这一段其实就是为了修正如果压缩后文件头部有注释,那么需要在最终生成的映射
      // 前加上N个“;”来表示换行
      if (mapping.generatedLine !== previousGeneratedLine) {
        previousGeneratedColumn = 0;
        while (mapping.generatedLine !== previousGeneratedLine) {
          next += ';';
          previousGeneratedLine++;
        }
      }
      else {
        if (i > 0) {
          // 比较相邻的两个mapping,如果相同就跳过,继续下一个
          if (!util.compareByGeneratedPositionsInflated(mapping, mappings[i - 1])) {
            continue;
          }
          // 同一行的每段映射后需要加上“,”
          next += ',';
        }
      }

      // 输出列 编码
      next += base64VLQ.encode(mapping.generatedColumn
                                 - previousGeneratedColumn);
      previousGeneratedColumn = mapping.generatedColumn;

      // 没有找到源文件的话,就会出现只有1个字段的映射的情况
      if (mapping.source != null) {
        sourceIdx = this._sources.indexOf(mapping.source);
        // 输入文件名索引 编码
        next += base64VLQ.encode(sourceIdx - previousSource);
        previousSource = sourceIdx;

        // 输入列 编码(SourceMap v3规定起始行是0)
        // lines are stored 0-based in SourceMap spec version 3
        next += base64VLQ.encode(mapping.originalLine - 1
                                   - previousOriginalLine);
        previousOriginalLine = mapping.originalLine - 1;

        // 输出行 编码
        next += base64VLQ.encode(mapping.originalColumn
                                   - previousOriginalColumn);
        previousOriginalColumn = mapping.originalColumn;

        // 没有名称的话,会出现4个字段的映射的情况
        // 主要是一些关键字或者符号等,不会被压缩,所以记录名称也就没有意义,但是还是
        // 需要记录它们的位置的
        if (mapping.name != null) {
          nameIdx = this._names.indexOf(mapping.name);
          next += base64VLQ.encode(nameIdx - previousName);
          previousName = nameIdx;
        }
      }

      result += next;
    }
  };

其中最开始循环的mappings是如下格式:

[ { generatedLine: 1,       // 压缩后代码所在行数
    generatedColumn: 0,     // 压缩后代码所在列数
    originalLine: 1,        // 压缩前代码所在行数
    originalColumn: 0,      // 压缩前代码所在列数
    source: 'src/test.js',  // 源文件
    name: undefined },     // 代码关键字 undefined说明这个位置的代码没有被压缩混淆
  { generatedLine: 1,
    generatedColumn: 9,
    originalLine: 1,
    originalColumn: 9,
    source: 'src/test.js',
    name: 'get' }
    // ...
]

ok,现在已经看到1,4,5这三种长度映射的原因了,并且只到了各个字段的意思,那么关键的问题就在base64VLQ.encode这个方法里了,去瞅一眼

// source-map/lib/base64-vlq.js

var VLQ_BASE_SHIFT = 5;                  // 不考虑第一位连续位,剩下有5位数,循环的时候需要进行 >>> 无符号右移操作

// binary: 100000
var VLQ_BASE = 1 << VLQ_BASE_SHIFT;       

// binary: 011111
var VLQ_BASE_MASK = VLQ_BASE - 1;         // 与二进制数字进行 & 操作,截取除连续位外的5位数字(0&任何数字都是0,所以第一位就不用考虑了)

// binary: 100000
var VLQ_CONTINUATION_BIT = VLQ_BASE;      // 最后生成连续位的时候,需要进行 | 操作

// 给10进制数字添加VLQ末尾 符号位
// 1 => 2 (10),  -1 => 3 (11)
// 2 => 4 (100),  -2 => 5 (101)
function toVLQSigned(aValue) {
  return aValue < 0
    ? ((-aValue) << 1) + 1
    : (aValue << 1) + 0;
}

// 10进制数字 => VLQ+Base64编码
exports.encode = function base64VLQ_encode(aValue) {
  var encoded = "";
  var digit;
  // 先添加末尾符号位,因为符号位在整个字段组中只占据一位,所以可以最开始就添加上
  var vlq = toVLQSigned(aValue);

  // 循环判断是否有后续数字
  do {
    // 截取除连续位外的5位数字
    digit = vlq & VLQ_BASE_MASK; // vlq & 011111
    // 无符号右移5位,用于判断有无后续数字
    vlq >>>= VLQ_BASE_SHIFT; // vlq >>> 5
    if (vlq > 0) {
      // 有后续数字,进行 | 操作来添加连续位
      digit |= VLQ_CONTINUATION_BIT; // digit | 100000
    }
    // 进行base64编码
    encoded += base64.encode(digit);
    // 循环知道没有后续数字
  } while (vlq > 0);

  return encoded;
};

所以最终的流程如下:

https://static.jooger.me/img/undefined134599162_1551899756489.png

source-map/lib/base64-vlq.js文件中还有个decode方法是用来反向解析的,一些SourceMap反向解析也是基于此方法解决的,这里源码就不贴了,有兴趣的可以去看看 源码

JS压缩示例

那一个简单的例子来说吧

// test.js

// 压缩前                                 // 压缩后
function get (value) {       =>          function get(n){return n}
  return value
}

根据我们上面所盘点的,可以得到如下信息:

{
    "sources:" ["test.js"],
    "names": ["get", "value"] // get不是关键字,后续也有可能被压缩
}
输出位置(行,列) 输入索引 输入位置(行,列) 名称索引 代表内容 VLQ+Base64
0,0 0 0,0 function AAAA
0,+9 0 +0,+9 0 get SAASA
0,+4 0 +1,+5 1 入参value IAAKC
0,+3 0 +1,-12 return GACZ
0,+7 0 +0,+7 0 返回值value OAAOA

所以最终生成的SourceMap如下:

{
  "version": 3,
  "sources": ["test.js"],
  "names": ["get", "value"],
  "mappings": "AAAA,SAASA,IAAKC,GACZ,OAAOA"
}

自己本地用命令行压缩验证一下,结果应该是一样的

uglifyjs test.js -o test.min.js --source-map -c -m

webpack的devtool

可以看下这两篇文章,总结的都还不错

特别是第二篇文章最后的结论,各个关键字的记忆方法:

  • evel => rebuild
  • inline => dataURI
  • cheap => lines-only
  • module => loader

参考文章

source-maps-under-the-hood-vlq-base64-and-yoda
Source Map的原理探究

相关文章
0 条评论

暂无评论