从上到下的源地图
A 部分:源映射和编译器简介
第二部分:构建我们自己的编译器
本文对JS代码生成器和源映射的机制进行了基本概述和深入讲解。由于我们从零开始构建自己的生成器,因此篇幅较长。
对于大多数人来说,Source Maps 仍然是个谜。它们几乎存在于所有基于 Web 的编译场景中,从类型系统到 Web 打包工具都离不开它们。但由于 Source Maps 的使用本身就足够复杂,因此其构建细节往往并不完全透明。今天,我们将首先简要概述 Source Maps 的概念及其使用方法。然后,我们将深入探讨其底层机制,构建我们自己的编译器,该编译器将生成一些代码并生成供浏览器使用的 Source Map。
本文的完整视频讲解请点击此处观看。这是我的“幕后揭秘”系列视频的一部分。
今天的计划:
A 部分:源映射和编译器简介
- 什么是源图?它们有什么用?
- 使用常用工具的源地图
- AST是什么?
- 转换 JavaScript 的步骤
- 编译器如何构建源映射
第二部分:构建我们自己的编译器
- 构建一个 JavaScript 代码生成器
- 什么是 Base64 VLQ?
- 添加源映射支持
- 测试我们的源地图
让我们开始吧💪
A 部分:源映射和编译器简介
1. 什么是源映射?为什么源映射很有用?
首先,让我们来看看人们编写需要转译成原生 JavaScript 的 JavaScript 代码的一些原因:
- 使用类型系统,
- 使用最新的 ES 8、9、10 特性,
- 代码优化(例如代码压缩)
- 软件包优化(例如,供应商软件包与应用程序软件包)
现代编译器架构如下所示:
问题在于,在将模块转换为资源的过程中,代码本身对于人类来说变得难以阅读,因为其重点在于让计算机(通常是浏览器或服务器)来读取。
下面这段代码已经过编译,几乎无法理解其逻辑,如果出现问题,调试起来就更加困难了。
这时源映射就派上用场了!!
源映射的基本定义如下:
“源映射提供了一种将压缩文件中的代码映射回其在源文件中的原始位置的方法。”
所以它的用途相当简单明了。现代浏览器会自动解析源映射,使之看起来像是在运行未经压缩或合并的文件。
下面的示例展示了如何在浏览器中调试 TypeScript,这只有通过 Source Maps 才能实现。
这样一来,你现在可以在代码中设置断点,并在浏览器中检查调用堆栈、变量和任何运行时状态,所有这些都可以通过预编译的 TypeScript 代码实现。
2. 使用常用工具的源地图
将浏览器连接到源地图
有两种方法可以通知浏览器有源映射可用。
- 在 JavaScript 文件的页脚添加
//# sourceMappingURL=/path/to/file.js.map
- 在 JavaScript 文件的“头部”中添加
X-SourceMap: /path/to/file.js.map
需要注意以下几点
- 只有在开发者工具打开的情况下,Chrome 才会下载源映射文件(因为它们可能非常大)。
- 源地图不会作为网络请求显示(在网络选项卡中)。
- 有了源映射后,就可以在“源”代码(位于“源”选项卡下)中添加断点。
源地图规范
当前源映射必须遵循最新版本的源映射规范,即版本 3。完整规范可在此处查看,它主要由 Mozilla 和 Google 的工程师编写。版本 3 改进了整体大小,从而加快了下载和解析速度。
下面展示了一个示例源代码映射,其中一个重点是“映射关系”,这些是 Base64 VLQ 字符串,其中包含了从源代码到生成代码的实际映射关系。稍后我们将详细介绍,因为我们会创建自己的映射关系。
在常用工具中的使用:
Node.js
通过旗帜— enable-source-maps
缓存的源映射用于在发生异常时进行堆栈跟踪。
巴别塔
默认情况下,Babel 会在每个生成的 bundle 的底部添加一个源映射位置,例如:
//# sourceMappingURL=file.map.js
但是,通过标志,— source-maps — inline您可以告诉 Babel 使用内联源映射,如下所示(即对内容字符串进行 base64 编码)。
//# sourceMappingURL=data:application/json;charset=utf-8;base64,....
Webpack
通过配置属性devtool: ‘source-map’
值得注意的是,像 Webpack 这样的工具通常会同时使用多个处理器(例如 Babel 和 TypeScript)执行多种转换,因此它仍然可以生成单个源映射。每个处理器都会生成自己的源映射,但也有一些库可以连接 JavaScript 文件并合并相应的源映射文件。例如mapcat就是这样一个库。
3. 什么是AST?
在深入探讨之前,我们需要快速了解一下任何 JavaScript 编译器中的一个重要机制:抽象语法树 (AST)。
AST 代表“抽象语法树”,它本质上是一个由“节点”组成的树状结构,每个节点代表一个程序或代码。“节点”是最小的单元,本质上是一个 POJO(即普通的 JavaScript 对象),具有“类型”和“位置”属性。所有节点都具有这两个属性,但根据“类型”的不同,它们还可以拥有其他各种属性。
AST 形式的代码非常容易操作,因此可以执行添加、删除甚至替换等操作。
以下代码就是一个示例:
将成为如下的抽象语法树:
有些网站,例如https://astexplorer.net,非常适合让你编写 JavaScript 代码并立即查看其抽象语法树 (AST)。
树遍历
处理AST最重要的部分是了解不同的方法,每种方法都有其优点和缺点。
一种常用的搜索类型(也是我们今天将要使用的类型)叫做“深度优先搜索”,它的工作原理是从根节点开始,沿着每个分支尽可能地向左探索,然后再回溯。因此,它会按以下顺序处理树:
所以,如果我们有一段这样的代码块:
2 + 3 * 1
它会生成以下这棵树:
4. 转换 JavaScript 的步骤
JavaScript 转换分为 3 个步骤:
1)将源代码解析为抽象语法树(AST)。
- 词法分析 -> 将代码字符串转换为标记流(即数组)。
- 句法分析 -> 将词法单元流转换为其抽象语法树 (AST) 表示
2)转换抽象语法树上的节点
- 操作 AST 节点(任何库插件都可以在这里运行,例如 Babel)
3)生成源代码
- 将抽象语法树 (AST) 转换为 JavaScript 源代码字符串
今天我们将重点介绍发电机的工作原理!
不同图书馆的处理方式有所不同,有的只执行第一步,有的则执行全部三个步骤。
能够同时实现这三项功能的库示例:
只做一件事的库示例:
5. 编译器如何构建源映射
生成源映射包含 3 个部分,编译器必须完成所有这些部分:
1)转换代码并记录新生成的源位置
2) 检查原始代码和生成代码的位置是否存在差异
3)利用这些映射关系构建源图
这只是过于简化的说法,我们将在下面的B 部分中更详细地探讨它的细节。
第二部分:构建我们自己的编译器
1. 构建 JavaScript 代码生成器
我们将从以下架构开始。目标是在编译后生成转换后的文件(index.es5.js)和源映射(index.es5.js.map)。
我们的src/index.es6.js函数看起来会是这样(一个简单的“添加”函数):
function add(number) {
return number + 1;
}
globalThis.add = add;
现在我们有了预编译的源代码。接下来我们要开始研究编译器。
过程
我们的编译器必须执行以下几个步骤:
1. 将代码解析为抽象语法树 (AST)。
由于本文并非着重于解析,我们将使用一个基本的第三方工具(esprima或escodegen)。
2. 将每个节点的浅克隆添加到抽象语法树 (AST) 中。
这个想法借鉴自recast。其核心思想是,每个节点都会保存自身以及自身的一个克隆体(即原始节点)。克隆体用于检查节点是否发生了变化。稍后会详细介绍。
3. 转型
我们将手动完成这项工作。我们本可以使用像ast-types或@babel/types这样的库,因为它们提供了实用的API。
4. 生成源代码
将我们的抽象语法树转换为 JavaScript。
5. 添加源映射支持
步骤 4 和 5 与上述步骤同时进行。这涉及到遍历树结构,并检测 AST 节点在其“原始”属性发生更改的位置。对于这些位置,存储“原始”代码和“生成”代码之间的映射关系。
6. 写到build/
最后,将生成的源代码及其源映射写入相应的文件。
代码
让我们再来看一下这些步骤,但这次要更详细一些。
1. 将代码解析为抽象语法树 (AST)。
使用一个基本的第三方工具(我选择了一个名为ast的简单工具),我们获取文件内容并将其传递给库解析器。
import fs from "fs";
import path from "path";
import ast from "abstract-syntax-tree";
const file = "./src/index.es6.js";
const fullPath = path.resolve(file);
const fileContents = fs.readFileSync(fullPath, "utf8");
const sourceAst = ast.parse(fileContents, { loc: true });
2. 将每个节点的浅克隆添加到抽象语法树 (AST) 中。
首先,我们定义一个名为“visit”的函数,其任务是遍历树并在每个节点上执行我们的回调函数。
export function visit(ast, callback) {
callback(ast);
const keys = Object.keys(ast);
for (let i = 0; i < keys.length; i++) {
const keyName = keys[i];
const child = ast[keyName];
if (keyName === "loc") return;
if (Array.isArray(child)) {
for (let j = 0; j < child.length; j++) {
visit(child[j], callback);
}
} else if (isNode(child)) {
visit(child, callback);
}
}
}
function isNode(node) {
return typeof node === "object" && node.type;
}
这里我们执行的是如上所述的“深度优先搜索” 。对于给定的节点,它将:
- 执行回调
- 检查位置属性,如果存在则提前返回。
- 检查是否存在数组类型的属性,如果存在,则使用每个子属性调用自身。
- 检查是否存在 AST 节点类型的属性,如果存在,则使用该节点调用自身。
接下来我们将开始克隆产品的生产。
export const cloneOriginalOnAst = ast => {
visit(ast, node => {
const clone = Object.assign({}, node);
node.original = clone;
});
};
我们的cloneOriginalAst函数会生成节点的克隆体,并将其附加到原始节点上。
我们这里使用Object.assign浅克隆,只复制顶层属性。嵌套属性仍然通过引用传递,也就是说,更改嵌套属性也会更改克隆体。我们也可以使用扩展运算符,因为它也能达到同样的效果。我们将使用顶层属性进行比较,这足以比较两个抽象语法树(AST)节点,并判断节点是否发生了变化。
总的来说,我们的代码将返回相同的树,只是每个节点都具有“original”属性。
3. 转型
接下来我们将进行节点操作。为了简单起见,我们只交换程序中的两个节点。首先:
number + 1
最后会是:
1 + number
理论上很简单,对吧!
以下是我们用于执行交换的代码:
// Swap: "number + 1"
// - clone left node
const leftClone = Object.assign(
{},
sourceAst.body[0].body.body[0].argument.left
);
// - replace left node with right node
sourceAst.body[0].body.body[0].argument.left =
sourceAst.body[0].body.body[0].argument.right;
// - replace right node with left clone
sourceAst.body[0].body.body[0].argument.right = leftClone;
// Now: "1 + number". Note: loc is wrong
我们没有使用干净的 API 来完成这项操作(许多库都提供了这种 API),因为我们手动交换了这两个节点。
以下示例展示了如何使用具有实用 API 的库,该示例由ast-types文档提供。
这种方法无疑更安全、更容易上手,开发速度也更快。因此,我通常建议对任何复杂的抽象语法树(AST)操作都使用这种方法,大多数主流编译器也都是这么做的。
4. 生成源代码
代码生成器通常放在一个单独的文件中,长度可达数千行。例如,escodegen 的编译器有 2619 行(参见此处)。与其他编译器相比,这算是比较小的(是不是很不可思议!)。
我为我们的编译器使用了许多相同的代码(因为大多数生成器需要非常相似的逻辑来将 AST 处理成 JavaScript),但只保留了处理“index.es6.js”文件中的代码绝对必要的部分。
下面我定义了我们编译器内部的 3 种代码类型。
a) 节点处理器和字符实用程序
这些是用于处理抽象语法树 (AST) 节点(根据类型,例如函数声明会有一个标识符)和构建源代码的通用实用函数。它还包含一些常用字符常量(例如“空格”)。它们将在下一节的代码“类型语句”中被调用。
除非你打算编写编译器,否则我建议你不必过于担心这里的细节。这部分内容很大程度上借鉴了escodegen 中的生成器(链接在此)。
// Common characters
const space = " ";
const indent = space + space;
const newline = "\n";
const semicolon = ";"; // USUALLY flags on this
// Utility functions
function parenthesize(text, current, should) {
if (current < should) {
return ["(", text, ")"];
}
return text;
}
const generateAssignment = (left, right, operator, precedence) => {
const expression = [
generateExpression(left),
space + operator + space,
generateExpression(right)
];
return parenthesize(expression, 1, precedence).flat(); // FLATTEN
};
const generateIdentifier = id => {
return id.name;
};
const generateFunctionParams = node => {
const result = [];
result.push("(");
result.push(node.params[0].name); // USUALLY lots of logic to grab param name
result.push(")");
return result;
};
const generateStatement = node => {
const result = Statements[node.type](node);
return result;
};
const generateFunctionBody = node => {
const result = generateFunctionParams(node);
return result.concat(generateStatement(node.body)); // if block generateStatement
};
const generateExpression = node => {
const result = Statements[node.type](node);
return result;
};
b) 类型语句
这是一个包含与抽象语法树(AST)节点类型关联的函数的对象。每个函数都包含处理该AST节点类型并生成源代码所需的逻辑。例如,对于一个函数声明,它包含了所有可能的参数、标识符、逻辑和返回类型组合。这里存在一种常见的递归,即一个类型语句触发另一个类型语句,而该类型语句又可能触发另一个类型语句,依此类推。
这里我们只包含了处理“index.es6.js”文件所需的必要语句函数,因此功能相当有限。你可以看到,仅仅处理我们那3-4行代码的抽象语法树(加上上面那部分的代码)就需要多少代码。
这里再次借鉴了escodegen,所以除非你打算编写自己的编译器,否则请随意忽略细节。
const Statements = {
FunctionDeclaration: function(node) {
let id;
if (node.id) {
id = generateIdentifier(node.id);
} else {
id = "";
}
const body = generateFunctionBody(node);
return ["function", space, id].concat(body); // JOIN
},
BlockStatement: function(node) {
let result = ["{", newline];
// USUALLY withIndent OR for loop on body OR addIndent
result = result.concat(generateStatement(node.body[0])).flat();
result.push("}");
result.push("\n");
return result;
},
ReturnStatement: function(node) {
// USUALLY check for argument else return
return [
indent,
"return",
space,
generateExpression(node.argument),
semicolon,
newline
];
},
BinaryExpression: function(node) {
const left = generateExpression(node.left);
const right = generateExpression(node.right);
return [left, space, node.operator, space, right];
},
Literal: function(node) {
if (node.value === null) {
return "null";
}
if (typeof node.value === "boolean") {
return node.value ? "true" : "false";
}
return node.value;
},
Identifier: function(node) {
return generateIdentifier(node);
},
ExpressionStatement: function(node) {
const result = generateExpression(node.expression); // was []
result.push(";");
return result;
},
AssignmentExpression: function(node, precedence) {
return generateAssignment(node.left, node.right, node.operator, precedence);
},
MemberExpression: function(node, precedence) {
const result = [generateExpression(node.object)];
result.push(".");
result.push(generateIdentifier(node.property));
return parenthesize(result, 19, precedence);
}
};
c) 处理代码语句
最后,我们将遍历程序主体(即每一行代码),并开始运行我们的生成器。这将返回一个名为“code”的数组,其中包含我们新生成的源代码的每一行。
const code = ast.body
.map(astBody => Statements[astBody.type](astBody))
.flat();
6. 写到build/
我们暂时跳过步骤 5,先完成编译器的核心部分。因此,在这一步中,我们将……
- 为我们生成的代码添加源地图位置(我们将在下一节中构建它)。
- 将生成的代码打包(将我们的代码数组合并在一起),并复制原始代码,以便浏览器可以看到它(这只是实现此目的的一种方法)。
// Add sourcemap location
code.push("\n");
code.push("//# sourceMappingURL=/static/index.es5.js.map");
// Write our generated and original
fs.writeFileSync(`./build/index.es5.js`, code.join(""), "utf8");
fs.writeFileSync(`./build/index.es6.js`, fileContents, "utf8");
5. 添加源映射支持
构建源映射表需要满足以下 4 个要求:
- 存储源文件记录
- 存储生成的文件记录
- 存储行/列的映射关系
- 使用 spec version3 在源映射文件中显示
为了快速解决问题,我们可以使用几乎所有 JavaScript 代码生成器都使用的库source-map。它来自 Mozilla,可以处理第 1-3 点的存储,以及将映射处理成 Base64 VLQ 格式(步骤 4)。
简单回顾一下源映射图的样子(从上面看):
映射是 Base64 VLQ,但这是什么意思?
2. 什么是 Base64 VLQ?
首先简要概述一下 Base64 和 VLQ。
Base64
解决了处理不包含完整 ASCII 字符集的语言时遇到的 ASCII 问题。Base64 只包含 ASCII 字符集的一个子集,因此更容易在不同语言中进行处理。
VLQ(可变长度量)
将整数的二进制表示分解成一组可变比特的小块。
Base64 VLQ
经过优化,可以轻松建立大数字与源文件中相应信息之间的映射关系。
一行代码由一系列“段”表示。例如,数字“1”表示为:AAAA => 0000。
以下示例展示了如何将这些数字对应起来构建一个“段”:
在 JavaScript 中构建一个基本的地图结构大致如下:
// .. define "item"
const sourceArray = [];
sourceArray.push(item.generated.column);
sourceArray.push("file.es6.js");
sourceArray.push(item.source.line);
sourceArray.push(item.source.column);
const encoded = vlq.encode(sourceArray);
然而,这种方法无法处理行和段的分隔(这可能非常棘手),因此使用 Mozilla 的库仍然更有效率。
3. 添加源映射支持
返回编译器!
使用 Mozilla 的 SourceMapGenerator
为了充分利用 Mozilla 的库,我们将:
- 创建一个 sourceMap 实例来保存和构建我们的映射。
- 初始化并存储本地映射
因此,当节点发生变化时,我们会构建位置信息,然后将其添加到本地映射和 SourceMap 实例中。我们保留一个本地实例,以便记录当前位置的起始和结束时间,这对于构建下一个位置至关重要。
// SourceMap instance
const mozillaMap = new SourceMapGenerator({
file: "index.es5.js"
});
// Local mappings instance
const mappings = [
{
target: {
start: { line: 1, column: 0 },
end: { line: 1, column: 0 }
},
source: {
start: { line: 1, column: 0 },
end: { line: 1, column: 0 }
},
name: "START"
}
];
我们需要一个函数来实际处理这些地图实例的更新。下面的“buildLocation”函数处理所有位置生成逻辑。大多数库都有类似的函数,使用调用者提供的列和行偏移量。
它的作用是计算新的行号和列号的起始位置以及结束位置。它仅在节点发生变化时才会添加映射,从而限制了我们将要存储的映射类型。
const buildLocation = ({
colOffset = 0, lineOffset = 0, name, source, node
}) => {
let endColumn, startColumn, startLine;
const lastGenerated = mappings[mappings.length - 1].target;
const endLine = lastGenerated.end.line + lineOffset;
if (lineOffset) {
endColumn = colOffset;
startColumn = 0; // If new line reset column
startLine = lastGenerated.end.line + lineOffset;
} else {
endColumn = lastGenerated.end.column + colOffset;
startColumn = lastGenerated.end.column;
startLine = lastGenerated.end.line;
}
const target = {
start: {
line: startLine,
column: startColumn
},
end: {
line: endLine,
column: endColumn
}
};
node.loc = target; // Update node with new location
const clonedNode = Object.assign({}, node);
delete clonedNode.original; // Only useful for check against original
const original = node.original;
if (JSON.stringify(clonedNode) !== JSON.stringify(original)) {
// Push to real mapping. Just START. END is for me managing state
mozillaMap.addMapping({
generated: {
line: target.start.line,
column: target.start.column
},
source: sourceFile,
original: source.start,
name
});
}
return { target };
};
现在我们有了“buildLocation”,接下来需要将其引入到代码中。下面是一些示例。在“generateIdentifier”处理器实用程序和“Literal” AST类型语句中,您可以看到我们是如何集成“buildLocation”的。
// Processor utility
const generateIdentifier = id => {
mappings.push(
buildLocation({
name: `_identifier_ name ${id.name}`,
colOffset: String(id.name).length,
source: id.original.loc,
node: id
})
);
return id.name;
};
// AST type statement function (part of "Statements" object)
Literal: function(node) {
mappings.push(
buildLocation({
name: `_literal_ value ${node.value}`,
colOffset: String(node.value).length,
source: node.original.loc,
node
})
);
if (node.value === null) {
return "null";
}
if (typeof node.value === "boolean") {
return node.value ? "true" : "false";
}
return node.value;
};
我们需要将此应用于整个代码生成器(即所有节点处理器和 AST 类型语句函数)。
我发现这很棘手,因为节点到字符的映射并非总是 1-2-1 的。例如,一个函数的参数两侧可能带有括号,这在处理字符行位置时必须考虑在内。所以:
(one) =>
具有不同的角色位置:
one =>
大多数库的做法是利用抽象语法树(AST)节点的信息引入逻辑和防御性检查,从而覆盖所有场景。我原本也会采用同样的做法,但我当时只是为我们的“index.es6.js”文件添加绝对必要的代码。
要查看完整用法,请参阅此处仓库中的生成器代码。它还缺少很多部分,但基本功能已经具备,并且是构建真正代码生成器的基础。
最后一步是将源映射内容写入源映射文件。Mozilla 的库让这一步出奇地简单,因为它提供了一个“toString()”方法,可以处理 Base64 VLQ 编码并将所有映射构建成符合 v3 规范的文件。太棒了!!
// From our Mozilla SourceMap instance
fs.writeFileSync(`./build/index.es5.js.map`, mozillaMap.toString(), "utf8");
现在,我们之前引用的“./build/index.es5.js”文件已经存在了。
我们的编译器已经完成了!!!!🤩
编译器部分到此结束,最后一部分是确认编译是否成功。
编译代码后,应该会生成一个包含 3 个文件的构建文件夹。
npm 运行编译
这是原始地图、生成地图和源地图。
4. 测试我们的源地图
https://sokra.github.io/source-map-visualization/是一个非常棒的网站,它可以让你可视化源地图映射。
页面开头是这样的:
将这三个文件拖入其中,我们现在可以看到以下内容:
色彩真丰富啊!
它包含原始代码、生成的代码和解码后的映射(在底部)。
简要回顾一下我们之前的转变:
// Swap: "number + 1"
// - clone left node
const leftClone = Object.assign(
{},
sourceAst.body[0].body.body[0].argument.left
);
// - replace left node with right node
sourceAst.body[0].body.body[0].argument.left =
sourceAst.body[0].body.body[0].argument.right;
// - replace right node with left clone
sourceAst.body[0].body.body[0].argument.right = leftClone;
// Now: "1 + number". Note: loc is wrong
我们已经交换了:
number + 1
进入:
1 + number
我们能否确认映射工作已成功?
如果将鼠标悬停在角色或映射上,它将突出显示映射及其在生成位置和原始位置中的对应位置。
这张截图显示了我将鼠标悬停在数字“1”字符上时发生的情况。它清楚地表明存在映射关系。
这张截图显示了当我将鼠标悬停在变量标识符“number”上时发生的情况。它清楚地表明存在映射关系。
成功!!💪
我们错过了什么?
那么,像这样构建编译器有哪些局限性呢?
- 并非所有 JavaScript 语句都已涵盖(仅涵盖我们文件所需的语句)
- 目前它只支持 1 个文件。Web 打包器会跟踪应用程序,构建依赖关系图,并对这些文件应用转换(有关更多信息,请参阅我的“Web 打包器内部原理”文章)。
- 输出文件与打包文件。Web 打包工具会生成可在特定 JavaScript 环境中运行的代码包,我们的环境非常有限(更多信息请参阅我的“Web 打包工具内部原理”)。
- 基本转换。如果不编写大量新代码,进行额外的优化并不容易。
非常感谢您的阅读。这个话题涉及面很广,我在研究过程中学到了很多东西。我真心希望这篇文章能帮助大家更好地理解 JavaScript 编译器和源映射是如何协同工作的,包括其中涉及的机制。
可以在craigtaub/our-own-babel-sourcemap找到该源代码。
谢谢,克雷格😃
文章来源:https://dev.to/craigtaub/source-maps-from-top-to-bottom-1j73
















