本文首发于我的博客(点此查看),欢迎关注。

source map 是开发时调试代码的利器之一。现代的构建工具如 webpack 早已对 source map 有了完备的支持,对照文档就能很容易在打包时顺手生成然后在现代浏览器如 Chrome/Firefox 中使用。关于相关配置的介绍使用已经有很多文章,这里就不再赘述。本文想探究的是 source map 在编译器中的实现原理。

source map 介绍

首先对于 source map 还不是特别清楚其原理及使用方式的同学可以先看一下阮一峰老师对其的介绍。一句话总结就是 source map 是一种存储了源代码和编译后代码映射关系的信息文件。当你的编译后代码出现问题时,根据 source map 就能精准定位到源代码对应的位置。否则,直接在天书一般的编译后(加上可能压缩后)代码中进行调试,难度不小。

AST 中的位置信息

source map 揭示了源代码和处理后代码之间的映射关系,而从源码到处理后代码的过程自然离不开编译。一个典型的编译过程如下:

AST,即抽象语法树,是源代码语法结构的一种抽象表示。其以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构(来自维基百科解释)。感兴趣的同学可访问 https://astexplorer.net/ 查看代码经 parser 处理为 AST 的过程。在 AST 中,每个节点都会保存自己的来源信息,如下所示:

interface Node {
    type: string;
    loc: SourceLocation | null;
}

interface SourceLocation {
    source: string | null;
    start: Position;
    end: Position;
}

interface Position {
    line: uint32 >= 1;
    column: uint32 >= 0;
}

每个 Node 的 loc 属性(视 parser 不同可能解析为其他名称,如 traceur 将其解析为 location)包含其 start 和 end 的行列位置信息。在 generate 环节,start 位置信息就是生成 source map 的关键。而通常 generator 不会自己去做映射关系的 VLQ 编码(source map 的位置信息存储方式),而是交由专业的库来处理,比如 Mozilla 出品的 source-map

source-map

source-map 库封装了底层的映射关系计算的逻辑,在生成 source map 时向开发者提供了两种类型的 API,一种是低级 API,其单纯地通过向结果中插入源代码和编译后代码的行列对应关系来生成 source map,官方示例如下:

var map = new SourceMapGenerator({
  file: "source-mapped.js"
});

map.addMapping({
  generated: {
    line: 10,
    column: 35
  },
  source: "foo.js",
  original: {
    line: 33,
    column: 2
  },
  name: "christopher"
});

console.log(map.toString());
// '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'

另一种高级 API 则直接侵入了编译过程。在 generate 步骤,source-map 提供了 SourceNode 用于在保留原有节点信息的同时添加该节点对应源代码的行列信息。最后再借助 SourceNode 提供的 toStringWithSourceMap 方法同时输出代码和 source map。官方示例如下:

function compile(ast) {
  switch (ast.type) {
  case 'BinaryExpression':
    return new SourceNode(
      ast.location.line,
      ast.location.column,
      ast.location.source,
      [compile(ast.left), " + ", compile(ast.right)]
    );
  case 'Literal':
    return new SourceNode(
      ast.location.line,
      ast.location.column,
      ast.location.source,
      String(ast.value)
    );
  // ...
  default:
    throw new Error("Bad AST");
  }
}

var ast = parse("40 + 2", "add.js");
console.log(compile(ast).toStringWithSourceMap({
  file: 'add.js'
}));
// { code: '40 + 2',
//   map: [object SourceMapGenerator] }

显然,高级 API 对于 source-map 的依赖和耦合性比较高。不过笔者在探究各个 generator 对于 source map 的支持时发现两种 API 均有使用。比如 @babel/generator 使用了低级 API,而 escodegen 则使用了高级 API。

生成原理

生成 source map 的原理并不复杂,使用 source-map 的低级 API 时, generator 的生成代码是一个遍历 AST node 然后根据其类型将对应的语句逐个拼装的过程,这其中会维护生成代码的行列信息,而在 node 中则保存有源代码的位置信息,如此便可调用 source-map 的低级 API 去生成 source-map。而使用高级 API 的原理则更简单,generator 处理好各个 node 对应生成的代码语句,拿到 node 中的源位置信息,然后调用 new SourceNode()toStringWithSourceMap 交给 source-map 去处理和生成代码和 srouce map 即可。

最后,回到 source-map 库的实现上来。在其代码库的 lib/source-node.js 中我们可以看到,SourceNode 实例的 toStringWithSourceMap 方法本质上做的工作也无非就是将生成好的代码片段拼接起来并同时调用低级 API 来生成 source map。至于 VLQ 编码的方式,源码里也有,读者有兴趣可结合原理自行查看。


逆葵
726 声望11 粉丝

做一名优秀的 JavaScript 技术栈工程师