二. Ignition解释器(中)
1. 再说AST
在第一篇解析篇中,我们虽然学习了从源码到 AST 的解析过程,但当时为了方便理解,我们更多地使用了“节点”、“左手右手”、“金光一闪”这样形象却略显模糊的比喻。
关于 AST 的存储,我们也只是简要提及了 Zone Allocation方式, V8 提前在堆中圈了一块地,可以闪电般的快速分配内存。
之所以当时没有深入,是因为在 V8 内部,AST 是以 C++ 对象树 的形式存在的。这些对象之间恩怨情仇错综复杂纠葛满满,充斥着 V8 私有的指针和元数据(比如源码位置、节点类型标记等),绝大部分都是仅供v8内部使用的东东。我们完全没必要去深陷进去。
但是,AST 本身不仅是 V8 的私有财产,它更是一种通用的结构化思维。
为了方便调试和测试,V8 的调试工具 d8 提供了以 JSON 格式 输出 AST 的功能。这种树状结构,其实和我们在前端工程化中天天打交道的 AST在逻辑上是高度一致的。
了解 AST 的真实结构,对我们来说是很重要的
- 退可守,可以还原源代码:当你调用
Function.toString()时,引擎某种程度上就是依赖源码位置信息或 AST 结构来回溯出代码字符串的。 - 进可攻,可以生成字节码:这是我们现在这篇的重点。AST 是字节码生成器唯一的输入。很有必要了解AST。
- 横可跳,是前端基建的基石:在日常开发中,AST 无处不在。
- Babel 把 ES6 转 ES5,是先转成 AST,修改树结构,再生成新代码。
- ESLint 检查语法错误,是遍历 AST,看有没有不符合规则的节点。
- Prettier 格式化代码,是忽略原本的空格格式,重新根据 AST 打印出漂亮的代码。
所以,我们必须要了解AST,但并不是v8内部私有的形式,而是通用的兼容的AST。通用 JSON格式的 AST 和 V8 内部 AST,只是对同一语义的不同存储形式(一个是标准化 JS/JSON 结构,一个是 V8 私有的 C++ 对象结构),两者的核心节点对应关系、语义表达完全一致,不会因为 AST 格式不同,改变字节码生成的核心逻辑。另外,通用的符合js语言estree标准的AST早已被广泛使用,学了不吃亏不上当 性价比拉满。
2. AST学习专场
因为这个V8系列,我的写作初衷,并不是为了能让阅读的朋友们 前端入门, 而是 V8入门 浏览器入门 前端进阶,所以,会默认读者朋友们具备基本的前端知识,当然,即使是前端0起点,但是只要具备了计算机组成原理 数据结构 等一些基础的知识,也是足够学习了解的。毕竟,我们不会太深入,文章定位就是V8的漫游,而不是V8的源码级详解。
我们在学习AST这部分内容的时候,你可以回忆一下解析篇中的内容,对照一下,有些地方,就会理解更深。
为了方便工具链(Babel, ESLint, Prettier)的互通,前端社区制定了一套名为 ESTree 的规范。这是 JavaScript AST 的 事实标准。虽然 V8 的内部实现与 ESTree 在属性名上略有不同,但其 逻辑拓扑结构 是高度一致的。
- AST 是一棵树,树由节点(Node)组成。在 ESTree 规范中,万物皆节点。 不管是函数、变量,还是一个简单的数字
1,它们都是一个节点。
{
"type": "Identifier", // 我是谁:节点的类型
"start": 0, "end": 1, // 我在哪:字符索引范围(用于高亮)
"loc": { // 我的精准定位:二维坐标
"start": { "line": 1, "column": 0 },
"end": { "line": 1, "column": 1 }
},
"range": [0, 1] // 另一种位置表示法
}
其中的 loc 是位置的定位,也是比较重要的,比如,当 V8 抛出 Uncaught ReferenceError: a is not defined at line 1 时,靠的就是这里保留的坐标信息。
-
变量声明:
VariableDeclaration代码:
var a = 1;我们可能认为的 AST样子: 一个节点,名字叫
a,值是1。而实际的 AST: 三层嵌套。
JSON
{
"type": "VariableDeclaration", // 第一层:声明语句
"kind": "var", // 也可能是 let/const
"declarations": [ // 第二层:数组
{
"type": "VariableDeclarator", // 第三层:声明符
"id": { "type": "Identifier", "name": "a" },
"init": { "type": "Literal", "value": 1 }
}
]
}
为什么要这么复杂? 因为 JS 允许 var a = 1, b = 2, c;。
VariableDeclaration代表 “这一行代码”(语句)。VariableDeclarator代表 “这一个变量”(声明)。- V8 的视角: 生成器在处理时,不能直接生成赋值指令,必须先遍历
declarations数组,把它们拆解成多个独立的初始化过程。
在前面学习解析的时候,我们也讲过 通用性 这个问题,在AST这里,同样也是,它需要用一种统一的结构,兼容 JS 语言中所有可能的声明形态。所以对于变量声明,不管是一个节点还是多个,都要使用三层的嵌套。
我们再用几个例子来加深一下对变量声明的理解。
马上就是春节了,很多朋友又该回家相亲了吧,嘿嘿嘿,我还暂时不用,我才18岁,不着急不着急。
说春运 ,就离不开火车。
我们想象一下,AST 中的变量声明语句,就是一列火车。
- 第一层:火车头 (VariableDeclaration)
- 它的作用是 确定性质。
- 它是高铁 (
const)?还是绿皮车 (var)?还是动车 (let)? - 关注点: 火车头只有一个,它决定了整列车的性质(作用域规则)。
- 第二层:车厢 (VariableDeclarators)
- 它的作用是 装载单位。
- 一列火车可以挂 1 节车厢,也可以挂 100 节车厢。
- 每一节车厢就是一个
VariableDeclarator。
- 第三层:货物 (Id 和 Init)
- 它的作用是 具体内容。
- 这节车厢里装的人是谁(变量名
id)? - 这节车厢里装了什么货(初始值
init)?
下面我们用这个火车模型,来具体讲几个变量声明的例子。
例一: 单人火车
代码:
JavaScript
var a = 1;
这就相当于:“一列绿皮车 (var),只挂了 1 节车厢,车厢里坐着 a,带着货物 1。”
{
// 第一层:火车头 (决定是 var)
"type": "VariableDeclaration",
"kind": "var",
"declarations": [
// 第二层:车厢 (数组中只有 1 节)
{
"type": "VariableDeclarator",
// 第三层:货物 (Id 和 Init)
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1
}
}
]
}
为什么要三层? 虽然只有一节车厢,但它依然是一列“火车”。你不能因为只有一节车厢,就把“火车头”和“车厢”焊死在一起。万一下一站要挂新车厢呢? 这就是 AST 设计的 通用性 , 哪怕只有一个变量,也要按列表的格式来存。
例二:超长火车
代码:
JavaScript
let a = 1, b = 2, c = 3;
{
// 第一层:火车头 (决定大家都是 let)
"type": "VariableDeclaration",
"kind": "let",
"declarations": [
// 第二层:车厢列表 (数组里有 3 个对象)
// 车厢 A
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "a" },
"init": { "type": "Literal", "value": 1 }
},
// 车厢 B
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "b" },
"init": { "type": "Literal", "value": 2 }
},
// 车厢 C
{
"type": "VariableDeclarator",
"id": { "type": "Identifier", "name": "c" },
"init": { "type": "Literal", "value": 3 }
}
]
}
AST 三层结构
- 第一层 (火车头):
VariableDeclaration { kind: "let" }- 后面挂的所有车厢,全部按
let的规则办事(不能重复声明,有块级作用域)
- 后面挂的所有车厢,全部按
- 第二层 (车厢列表):
declarations: [ 车厢A, 车厢B, 车厢C ]- 这里是一个数组。
- 第三层 (各自的货物):
- 车厢A: 我叫
a,我有值1。 - 车厢B: 我叫
b,我有值2。 - 车厢C: 我叫
c,我有值3。
- 车厢A: 我叫
例三:半空半满的火车
代码:
JavaScript
let x, y = 10;
{
"type": "VariableDeclaration",
"kind": "let",
"declarations": [
// 车厢 1:x (没装货)
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "x"
},
"init": null // 这里要注意,木有初始值,就是 null
},
// 车厢 2:y (装了 10)
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "y"
},
"init": {
"type": "Literal",
"value": 10
}
}
]
}
这行代码最能体现 Declarator (第二层) 的独立性。
- 火车头:
let。 - 车厢 1 (x):
- 乘客:
x。 - 货物 (
init):空 (null)。 - 这节车厢虽然挂上了,但是里面没装货。
- 乘客:
- 车厢 2 (y):
- 乘客:
y。 - 货物 (
init):10。
- 乘客:
如果只有两层, AST 设计成 { type: "LetStatement", names: ["x", "y"], value: 10 }。 解析器会搞不清这个 10 到底是给 x 的,还是给 y 的,还是它俩一人一份? 必须有 第二层 (Declarator) 作为隔离,才能让每个变量拥有自己独立的初始化状态。
例四:能变形的火车
代码:
JavaScript
const { name, age } = person;
{
"type": "VariableDeclaration",
"kind": "const",
"declarations": [
{
"type": "VariableDeclarator",
// 第三层左边 (id):这是一个 ObjectPattern (对象模式)
"id": {
"type": "ObjectPattern",
"properties": [
// 解构里的 name
{
"type": "Property",
"key": { "type": "Identifier", "name": "name" },
"value": { "type": "Identifier", "name": "name" },
"shorthand": true, // 因为是简写 { name }
"kind": "init"
},
// 解构里的 age
{
"type": "Property",
"key": { "type": "Identifier", "name": "age" },
"value": { "type": "Identifier", "name": "age" },
"shorthand": true,
"kind": "init"
}
]
},
// 第三层右边 (init):就是一个普通的变量引用
"init": {
"type": "Identifier",
"name": "person"
}
}
]
}
这是es6中重点,解构赋值,这里没有简单的变量名,左边是一个 模式 (Pattern)。
AST 三层结构是如何工作的?
- 第一层 (火车头):
const。- 所有声明的变量都不能修改
- 第二层 (车厢): 只有 1 节车厢。
- 第三层 (货物 重点在这里):
- 左边 (
id): 这次坐的不是一个人,是一个 结构体- 类型是
ObjectPattern(对象模式)。 - 里面包含属性
name和age。
- 类型是
- 右边 (
init): 变量person。
- 左边 (
如果没有 Declarator 这一层来承载左边的 id,我们是无法描述 { name, age } 这种复杂的解构语法的, AST 的灵活性就在于:第三层的 id 位置,不仅可以放简单的 Identifier (a),还可以放复杂的 ObjectPattern ({a,b})。
例五:火车被打包了
代码:
JavaScript
export var a = 1;
这个也是常见常用的形式,var a = 1 既是一个声明,又是模块导出的内容。
{
// 最外层大箱子:导出声明
"type": "ExportNamedDeclaration",
"specifiers": [],
"source": null,
// 核心内容:把刚才的整列“火车”塞进 declaration 属性里
"declaration": {
"type": "VariableDeclaration", // 火车头
"kind": "var",
"declarations": [
{
"type": "VariableDeclarator", // 车厢
"id": {
"type": "Identifier",
"name": "a"
},
"init": {
"type": "Literal",
"value": 1
}
}
]
}
}
正因为 VariableDeclaration 是一个独立的、封装好的 “整列火车”,它才可以被完整地塞进 ExportNamedDeclaration 这个更大的箱子里。 除了变量声明的三层嵌套结构,这个例子也体现了 AST 的可以组合的特点。
我们用了稍微大点的篇幅,学习了AST的变量的声明,重点是三层嵌套结构。我觉得这是学习AST的一个很好的切入点。 刷了5个例子,应该对于变量声明的结构有些感觉了吧。
-
AST的各种例子
在 AST 的 JSON 世界里,优先级 只有一种表现形式:对象属性的嵌套深度。
被包裹在属性里的对象(子对象),必须先被求值,外层对象才能继续执行。这就是 “后序遍历” (Post-order Traversal) , 即先处理子节点,最后处理父节点。
要注意一点,在不同的解析器规范中(如 ESTree, Babel, Acorn) 在字段命名或元信息上可能稍有些差异,但通过“父子嵌套”来体现优先级是所有 AST 的通用法则。
下面,我们稍稍的提高一点难度,全部使用JSON形式的AST表示法,可能刚开始会有些不习惯,但是,多看一会,就会发现,特别好看 特别顺眼 。 当然你也可以自行转化脑补成简化的树形图。
例一: 乘法在后
1 + 2 * 3回忆一下,在第一部分 解析篇 中,我们详细描述了这个表达式的解析过程,忘记了的朋友,可以返回重新瞄一眼。
现在我们从AST的生成角度,简单的回顾一下。
-
第一步:解析器读取了
1,然后遇到了+。+的优先级比较低(假设是 12)。 此时,解析器生成了一个 半成品的节点,它正在焦急地等待它的 右手 (right)。 -
第二步:解析器继续往后读,读到了
2。 本来2可以直接作为+的右手。但是,紧接着出现了 * , 那么关键时刻来了:
解析器发现
*的优先级(假设是 13) 大于+的优先级 (12)。然后规则触发,
+号虽然先来,但它抢不过后来的*号。所以,解析器决定,暂缓 构建加法节点。它要把
2让给*,并且 递归调用 去解析后面的乘法表达式。
- 第三步:层级就是这么出来的,因为递归调用了,解析器进入了 更深一层 的函数堆栈去处理
2 * 3。在这深一层里,它构建出了一个完整的 乘法节点:
{
"type": "BinaryExpression",
"operator": "*",
"left": { "value": 2 },
"right": { "value": 3 }
}
这个就不用说了吧,很简单的描述。
等这个乘法节点构建完毕,解析器函数 返回 (Return)。
返回给谁呢?返回给上一层那个还在苦苦等待“右手”的 + 号。
- 完工了: 于是,那个乘法节点,作为一个完整的整体,被塞进了加法节点的
right属性里。
{
"type": "BinaryExpression",
"operator": "+", // 根节点,加法,最后执行
"left": {
"type": "Literal",
"value": 1
},
"right": {
// 这是重点:right 属性不是一个简单的数字,而是一个完整的“对象”
// 这就是“嵌套”。解释器必须先把这个对象“解开”算出结果,才能配合左边的 1 做加法。
"type": "BinaryExpression",
"operator": "*", // 子节点,乘法,被包裹在里面,深一度,先执行
"left": { "type": "Literal", "value": 2 },
"right": { "type": "Literal", "value": 3 }
}
}
在这个嵌套结构中:
-
外层(加法)依赖内层(乘法):
根节点
+的right属性,不是一个现成的值,而是一个 Object。 -
我们提前预习一下,从解释器的角度来看一眼:
当
BytecodeGenerator看到这个结构时,它会想:“我要算加法,左手是1,右手是。。。哎呀,右手是个乘法任务?”“那我没法直接算加法,我必须 先下沉 到
right对象里,把那个乘法算出来,拿到结果,才能回来算加法。”所以 我们要理解, 解析时的高优先级,导致了 AST 结构的深层嵌套。
AST结构的深层嵌套,导致了 执行时的优先计算。
所以我们并不需要给 AST 写“先乘除后加减”的规则。
树的形状,就是规则本身。
例二:括号改变计算顺序 (1 + 2) * 3
在大多数标准 AST 中,括号本身通常不会成为独立的语法节点(虽然某些解析器可能会保留括号信息作为元数据)。括号的真正作用体现在 AST 的父子关系上,它改变了“谁包谁”,从而改变了评估顺序。
在这个例子中,括号强行改变了树的形状,让原本处于顶层的加法,被迫成为了底层的子节点.
-
我们依旧先复习一下解析过程
-
遇到
(:开启副本- 解析器读到
(。 - 它立刻明白:这里开始了一个新的层级。
- 关键动作: 它直接递归调用了
parseExpression()。
- 解析器读到
-
在副本中解析
1 + 2:- 在这个递归调用的副本里,解析器读到了
1,然后是+,然后是2。 - 因为这是在递归函数内部,外界的任何优先级(比如括号外面的乘法)都管不到这里。
- 解析器按照正常的逻辑,构建出了一个 加法节点
{ op: '+', left: 1, right: 2 }。
- 在这个递归调用的副本里,解析器读到了
-
遇到
):退出副本- 解析器读到了
)。 - 这意味着刚才那次递归调用结束了。
parseExpression()函数执行完毕,返回 (Return) 了刚才构建好的 加法节点。- 这个节点现在被看作是一个整体(一个 Value)。
- 解析器读到了
-
**遇到 * **
- 现在的解析器回到了主线(外层函数),紧接着看到了
*。 *说:“我要一个左操作数。”- 谁是左操作数? 正是刚才从副本里带回来的那个 加法节点
- 现在的解析器回到了主线(外层函数),紧接着看到了
-
最终组装:
-
解析器构建 乘法节点。
-
把 加法节点 挂在
left上。 -
把
3挂在right上。 -
结果: 树的形状被彻底改变了,加法被“埋”在了乘法下面。
-
最终生成的json形式的AST树是这样的。
-
{
// 1. 根节点 BinaryExpression (*)
// 为什么根节点是乘法?
// 因为从逻辑上讲,最后一步操作是“某数乘以3”。
// 只有把左边的 (1+2) 算完了,才能执行这最后一步。
// 所以 * 站在了金字塔的顶端,最后被执行。
"type": "BinaryExpression",
"operator": "*",
// 2. 左子树 left
// 这里是重点:这里的 left 不是一个简单的数字,而是一个庞大的“对象”。
// 这就是括号的作用,它把 "1+2" 打包成了一个整体,扔给了乘法的左边。
"left": {
// 内层节点 加法 (+)
// 此时,加法被“降级”了。它不再是根,它是乘法的一个“零件”。
// 根据“越深越先执行”的规则,这个节点必须优先计算。
"type": "BinaryExpression",
"operator": "+",
// 内层左叶子 1
"left": {
"type": "Literal",
"value": 1
},
// 内层右叶子 2
"right": {
"type": "Literal",
"value": 2
}
},
// 3. 右子树 right
// 乘法的右边很简单,就是数字 3。
"right": {
"type": "Literal",
"value": 3
}
}
-
上面是解析以后 生成了AST,现在我们还是提前预习一下 BytecodeGenerator 的流程。
Generator遵循 后序遍历 (Post-order Traversal) 的规则:先搞定子节点,再搞定父节点。
这里需要注意的是 对于字节码生成,AST是唯一的原材料,AST是有足够的信息的。我们在使用json来描述AST时,很多时候 没有写/忽略了 一些非关键信息 比如前面讲的 位置等等等信息。
- 第一步:站在根节点 (*)
- 生成器看到根是
*。 - 规则:先算左边 (
left)。 - 生成器看向
left属性,发现这不是个数字,是个 加法对象。 - 动作: 暂停乘法任务,下沉 (Recursion) 到左子树。
- 生成器看到根是
- 第二步:站在子节点 (+)
- 现在生成器进入了内层。
- 规则:先算左边 (
left)。 - 生成指令:
LdaSmi [1](加载 1)。 - 规则:再算右边 (
right)。 - 生成指令:
Star r0(暂存 1),LdaSmi [2](加载 2)。 - 规则:最后算自己 (
root)。 - 生成指令:
Add r0(计算 1+2)。 - 此时,累加器 Acc 里的值是 3。内层任务完成,向上返回。
- 第三步:回到根节点 (*)
- 左边算完了(结果 3 在 Acc 里)。
- 规则:再算右边 (
right)。 - 动作: 乘法也要用 Acc,所以先把刚才加法的结果存起来。
- 生成指令:
Star r1(暂存加法结果 3)。 - 生成器看向
right属性,是Literal(3)。 - 生成指令:
LdaSmi [3]。
- 第四步:完成乘法
-
左边在
r1,右边在 Acc。 -
生成指令:
Mul r1。 -
最终结果:9。
-
归纳一下就是:
括号 在源码里表示顺序,强行把解析器圈在里面先干活。
顺序 在 AST 里变成了深度,把加法节点按到了乘法节点的下面。
深度 在字节码生成时变成了时间,越深的节点,生成指令的时间越早。
例三:逻辑运算的先后 a || b && c
这行代码等价于
a || (b && c)。这说明&&会先把b和c抢走,结成一个小团体,然后再去和a玩。AST 结构: 根节点必须是优先级 低 的那个(最后才执行)。所以 根节点是
||。JSON
- 第一步:站在根节点 (*)
{
"type": "LogicalExpression", // 注意类型:逻辑表达式
"operator": "||", // 根节点 逻辑或 (最后执行)
"left": {
"type": "Identifier",
"name": "a"
},
"right": {
// 右子树 逻辑与
// 因为 && 优先级高,所以它被打包成了一个整体,作为 || 的右操作数
// 这体现了 AST 的 深度优先 原则
"type": "LogicalExpression",
"operator": "&&", // 子节点 逻辑与 (先结合)
"left": { "type": "Identifier", "name": "b" },
"right": { "type": "Identifier", "name": "c" }
}
}
这个js中的 短路逻辑 也很简单,对于生成器来说,
AST 决定短路逻辑:
- 根节点 (
||): 生成器首先生成测试a的指令。 - 跳转指令: 生成器会生成一条特殊的指令:
JumpIfTrue。如果a是真,直接跳过整个right节点(即跳过了b && c的计算)。 - 子节点 (
&&): 只有当a为假时,生成器才会走进right节点,去生成b && c的指令。
AST 的树形结构不仅决定了计算顺序,对于逻辑运算来说,它还直接决定了 控制流(Control Flow) 的跳转路径。
例四:左结合 a+b+c
还有一种情况叫 “结合性” (Associativity)。 当运算符的优先级一模一样时,树是往左边长,还是往右边长?在 AST 里,这决定了计算的流向。
代码: a + b + c
我们都知道,加法是从左往右算的,等价于 (a + b) + c。
AST 长什么样? 它是一棵 “向左倾斜” 的树。
JSON
{
// 根节点 第二个加号 (+)
// 它的右手是 c。左手是谁?是前面算完的结果。
"type": "BinaryExpression",
"operator": "+",
"left": {
// 左子树 第一个加号 (+)
// 被埋在了下面,深一度,先执行。
"type": "BinaryExpression",
"operator": "+",
"left": { "type": "Identifier", "name": "a" },
"right": { "type": "Identifier", "name": "b" }
},
"right": {
"type": "Identifier",
"name": "c"
}
}
我们依旧简单预习一下,看看生成器视角:
-
站在根节点(第二个
+)。 -
先去左边:下沉到内层,计算
a + b。 -
拿到结果后,回到根节点,再和
c相加。左结合 = 树向左歪 = 先算左边。
例五:右结合 a=b=c
a = b = c 这个就不一样了。把 c 赋值给 b,再把结果赋值给 a。等价于 a = (b = c)。
AST 长什么样? 它是一棵 “向右倾斜” 的树。
JSON
{
// 根节点 第一个等号 (=)
// 左手是 a。右手是谁?是后面那一坨赋值的结果。
"type": "AssignmentExpression",
"operator": "=",
"left": {
"type": "Identifier",
"name": "a"
},
"right": {
// 右子树 第二个等号 (=)
// 被埋在了右边下面,深一度,先执行。
"type": "AssignmentExpression",
"operator": "=",
"left": { "type": "Identifier", "name": "b" },
"right": { "type": "Identifier", "name": "c" }
}
}
生成器角度来看:
-
站在根节点(第一个
=)。 -
先处理右边(赋值语句的特殊性,右边是值):下沉到内层,计算
b = c。 -
拿到结果(即
c的值),回到根节点,赋给a。右结合 = 树向右歪 = 先算右边。
例六:连续赋值 a=b=1
代码: a = b = 1
js中的赋值运算 (=) 是 右结合 运算。 它的意思是:先把 1 赋给 b,算出个结果来,再把这个结果赋给 a。
AST 结构: 这是一棵 “向右倾斜” 的树。根节点是 第一个等号。
JSON
{
"type": "AssignmentExpression",
"operator": "=", // 根节点 第一个等号
"left": {
"type": "Identifier",
"name": "a"
},
"right": {
// 右子树 重点:右边是一个完整的赋值表达式
// 必须先把右边这一坨算出来,才能给 a 赋值
"type": "AssignmentExpression",
"operator": "=", // 子节点 第二个等号 (先执行)
"left": {
"type": "Identifier",
"name": "b"
},
"right": {
"type": "Literal",
"value": 1
}
}
}
-
第一篇学过的解析器视角: 解析器读到
a =时,发现后面还跟着b = 1。根据优先级规则,赋值号的右边吸力极强,它会贪婪地吞噬后面所有的东西。 -
生成器视角:
-
站在根节点(第一个
=)。 -
发现
right是个对象,下沉。 -
在子节点算出
b = 1(此时b变成了 1,累加器也是 1)。 -
带着 1 回到根节点,执行
a = 1。这个例子和上个例子,都是赋值右结合
这种向右嵌套的结构,实现了“从右向左赋值”的逻辑。
-
例七:成员访问 a.b.c
代码: a.b.c
这是前端代码里最常见的写法。它的优先级极高(比加减乘除都高),而且是标准的 左结合。 意思是:(a.b).c 先找到 a 的 b,再找它的 c。
AST 结构: 这是一棵 “向左倾斜” 的树。根节点是 最后的那个点号。
JSON
{
"type": "MemberExpression", // 根节点 求 .c
"property": {
"type": "Identifier",
"name": "c"
},
"object": {
// 左子树 对象本身又是一个 MemberExpression
// 必须先算出 a.b 是个啥,才能去访问它的 .c
"type": "MemberExpression", // 子节点 求 .b (先执行)
"object": {
"type": "Identifier",
"name": "a"
},
"property": {
"type": "Identifier",
"name": "b"
}
}
}
-
重点是: 代码是
a.b.c,但根节点却是.c。 -
解析器的逻辑链:
- 最底层的
object是a。 - 包裹一层变成
a.b。 - 再包裹一层变成
(a.b).c。
- 最底层的
-
生成器的角度:
-
生成器想要访问
.c,会先问一声 “对象是谁?” -
对象是
left里的a.b。 -
所以只能先去把
a.b找出来。
-
例八:一元运算和二元运算 !a && b
代码: !a && b
这是一个非常基础但也非常重要的规则:一元运算符 (Unary) 的优先级 高于 二元运算符 (Binary)。 就是说 ! 这种只要一个操作数的,比 && 这种需要两个操作数的,绑定吸力更强。它会紧紧抱住 a。
AST 结构: 根节点是 优先级低 的 &&。
JSON
{
"type": "LogicalExpression",
"operator": "&&", // 根节点 最后算
"left": {
// 左子树 一元运算
// ! 抢先执行,把 a 取反
"type": "UnaryExpression",
"operator": "!",
"argument": { "type": "Identifier", "name": "a" },
"prefix": true
},
"right": {
"type": "Identifier",
"name": "b"
}
}
生成器的角度来看:
- 站在根节点
&&。 - 必须先算左边(
!a)。 - 于是下沉到
UnaryExpression,生成ToBoolean+LogicalNot指令。 - 拿着这个结果,再回来决定是否要短路,或者继续算右边的
b。
例九:三元运算符的右结合 a ? b : c ? d : e
代码: a ? b : c ? d : e
这是除赋值以外,JS 里唯一的 右结合 运算符。
AST 结构: 这是一棵 “向右下方” 无限延伸的树。
JSON
{
"type": "ConditionalExpression", // 根节点 第一个问号
"test": { "name": "a" },
"consequent": { "name": "b" }, // 如果 a 为真,取 b
"alternate": {
// 右子树 重点:else 部分是一个新的三元表达式
// 这就是右结合:后面的问号被打包成了前面问号的 "否则" (else) 部分
"type": "ConditionalExpression",
"test": { "name": "c" },
"consequent": { "name": "d" },
"alternate": { "name": "e" }
}
}
三元运算的优先级很重要 如果三元运算符是左结合的,这行代码就会变成 (a ? b : c) ? d : e,逻辑就完全乱了(变成了用 b 或 c 的结果去判断 d/e)。 AST 的这种右倾结构,保证了我们写 else if 逻辑时的直觉是正确的。
例十:await和数学运算 await x + 1
代码: await x + 1
这里应该是 (await x) + 1 还是 await (x + 1)?
正确的是第一个,在 AST 解析规则中,await 被视为 一元运算符 (Unary Operator)。 一元运算符(如 !, typeof, delete, await)的优先级 高于 二元运算符(如 +)。
JSON
{
"type": "BinaryExpression",
"operator": "+", // 根节点 加法
"left": {
// 左子树 await
// await 紧紧抱住了 x,先执行
"type": "AwaitExpression",
"argument": {
"type": "Identifier",
"name": "x"
}
},
"right": {
"type": "Literal",
"value": 1
}
}
我们依旧使用生成器的视角来瞄一眼:
- 站在根节点
+。 - 先处理左边
AwaitExpression。 - 生成器动作: 这里会生成极其复杂的指令——暂停当前函数的执行(Suspend),把控制权交还给 Event Loop,等待 Promise 解决。
- 恢复执行: 等
x回来了,拿到结果,恢复现场(Resume)。 - 拿着
await的结果,再去和1做加法。
如果写成 await (x + 1): 那 AST 的根节点就会变成 AwaitExpression,里面包着一个 BinaryExpression。那就是先算加法,再等待结果了。
例十一:构造函数 new 的有参和无参
代码: new Date().getTime()
为了看清这个例子的真相,我们需要引入一个长得很像的“双胞胎”来做对比:
- A:
new Date().getTime()(我们的例子) - B:
new Date.getTime()(没有括号)
优先级: 在 JS 语法定义中,new 并不是一个单一优先级的运算符,它有两种形态:
- 形态一(带参数):
new Foo(...)- 优先级: 18(极高,和
.还有()平起平坐)。 - 特点: 括号是它的保镖,一旦带了括号,它就变得极其强势,必须先执行。
- 优先级: 18(极高,和
- 形态二(无参数):
new Foo- 优先级: 17(稍低)。
- 特点: 如果后面跟了点号
.,它会认怂服软。
对于我们的例子 new Date().getTime():
- 解析器读到
new。 - 紧接着读到了
Date和()。 - 判定: 触发“形态一(带参)”。
- 结果:
new Date()被瞬间锁死,打包成一个NewExpression节点。 - 后续: 后面的
.getTime只能乖乖地挂在这个节点上面。
AST 结构是层层递进: 这是一棵 “底座很深” 的树。
JSON
{
"type": "CallExpression", // 根节点 最后的调用 ()
"callee": {
"type": "MemberExpression", // 中间层 访问 .getTime 属性
"property": { "type": "Identifier", "name": "getTime" },
"object": {
// 最底层 创建对象 (NewExpression)
// 因为带了括号,new Date() 优先级极高,作为整体成为了 Member 的底座
"type": "NewExpression",
"callee": { "type": "Identifier", "name": "Date" },
"arguments": []
}
},
"arguments": []
}
对比 B:new Date.getTime() 如果少了那个括号,AST 就会发生天壤之别的变化。
- 解析器读到
new。 - 后面是
Date.getTime。 - 判定: 触发“形态二(无参)”。
- 规则: 因为
.(18) 的优先级 高于 无参new(17)。 - 结果: 解析器会先处理
Date.getTime(把它当成一个整体),然后再对这个整体执行new。 - AST 根节点: 变成了
NewExpression(而不是CallExpression)。
Ignition 生成器视角 是如何处理我们的例子A的: 生成器的执行顺序,就是 AST 从下往上 的回溯顺序:
- 最底层 (
NewExpression): 先执行Construct Date,在堆里造出一个 Date 实例(假设存入寄存器r0)。 - 中间层 (
MemberExpression): 拿着r0,去查它的getTime属性(拿到函数地址r1)。 - 根节点 (
CallExpression): 执行Call r1,调用这个方法。
括号不仅是参数的容器,更是 优先级的“锁定”。在 AST 中,new Date() 的括号让它变成了一个不可分割的原子节点,从而成为了 .getTime() 的宿主对象。
刷了这么多例子,我们总结一下:
- 谁是根节点? 那个 最后 被执行的操作符,永远是根节点。
- 谁被埋得深? 那个 最先 被执行的操作符,永远在树的最底层。
- 往哪边歪?
- 左结合(加减乘除、成员访问):树向 左下方 生长。
- 右结合(赋值):树向 右下方 生长。
-
前面我们首先讲了AST,然后讲了AST的变量声明,然后又刷了十几个比较简单的各种例子。
在讲变量声明时,我们重点是三层结构,因为一个var可以对应多个变量,AST被迫加了一个中间层 declarations数组。
那么其他声明,比如函数声明,是否也是三层结构呢?
并不是。函数声明是两层的。
两层结构:函数声明 (FunctionDeclaration)
代码:function foo() {}
AST结构:
JSON
{
"type": "FunctionDeclaration", // 第一层:火车头
"id": { // 第二层:直接就是名字,木有中间的车厢列表
"type": "Identifier",
"name": "foo"
},
"body": { ... }
}
为什么函数声明只有两层结构呢?
因为 JS 语法规定:一个 function 关键字只能声明一个函数。 你写 function foo(), bar() {} 是 语法错误。 既然是一对一的关系,就不需要中间那个“数组列表”了。火车头直接焊死在车厢上,不可分割。
一层结构:原子节点
代码: this
在函数里用到 this 时,它在 AST 里就是一个光杆司令。
AST 结构:
JSON
{
"type": "ThisExpression"
}
它既没有 name,也没有 value,也没有子节点。它自己就是全部。它就像一个 孤单滑板,没有车头也没有车厢,踩上去就走。
无限层结构:二元运算 (BinaryExpression)
代码: 1 + 2 + 3 + 4 + ...
代码 a + b + c
这是最能体现 AST “树” 特征的地方。层数理论上是 无限 的。
AST 结构是左结合 如果代码写成 a + b + c + d + e...,这棵树就会像 俄罗斯套娃 一样,一直往深处长。因为数学运算是可以无限嵌套的。AST 必须忠实地记录这种嵌套关系,才能保证计算顺序不出错。
左结合已经讲了很多了,例子就不举了。
爆炸层结构:类声明 (ClassDeclaration)
JavaScript
class Person {
getName() {}
}
这在 ES6 里看起来很简洁,但在 AST 里简直就是灾难。起步就是 4-5 层。
AST 结构:
ClassDeclaration(类声明)ClassBody(类体 - 大括号里的部分)MethodDefinition(方法定义 -getName)FunctionExpression(函数表达式)BlockStatement(函数体)
为什么需要这么多层?
-
因为类里面可以有方法、有属性、有静态块 (
static)。 -
方法又分构造函数 (
constructor)、普通方法、Getter/Setter。 -
每一个特性都需要一层节点来包裹和描述。
关于层数,略做总结:
- 变量声明 (
var/let/const):是三层。因为要支持var a, b这种列表语法。 - 函数声明 (
function):是两层。因为不支持列表语法,是一对一的。 - 表达式 (
+ - \* /):是无限层。因为逻辑可以无限嵌套。 - 关键字 (
this,super):是一层。因为它是原子单位。
so, AST 的形状不是固定的, JS 语法长什么样,AST 就得长什么样。语法规则决定了树的形状。
在学习 AST 时,可以思考一下:“这句代码的语法结构,需要几个零件才能拼出来?”
- 需要“列表”吗? - 得加一层数组。
- 需要“嵌套”吗? - 得加一层递归。
- 是一对一吗? - 直接连接。
请注意
在标准 JSON中是不允许写注释 (
//) 的。 我们为了方便阅读和理解,在json中保留了注释,在真正书写时,大家记得不要在里面写注释。另外, 在babel中,AST的有些节点,会要求带上两个属性
"method": false, 表示不是方法
"computed": false 表示不是 obj[key] 这种动态属性
我们为了讲解时的简洁,省略了这些,只保留了比较核心的内容。
上面所有的 生成器角度 生成器视角 Generator 的描述, 都是指Ignition的字节码生成器,并非js中的生成器概念, 千万不要混淆了。
-
前面我们说了estree是前端事实上的ast标准,下面我们列出一份简明的estree核心内容。不需要记忆或北宋,只作为混个眼熟的用途, 看多了,自然就熟悉了。
ESTree 规范主要包括以下几个核心部分:
- 核心接口 (Base Node)
这是所有 AST 节点的“老祖”。AST有成百上千种类型的节点,为了能统一处理它们(例如遍历整棵树、定位源码位置、分析代码结构),需要保证每个节点都至少提供一些最基本的信息,所以它们都必须继承这个最基本的核心接口,拥有一些共同的属性。
-
type(string): 节点的类型名称(身份证)。比如"Identifier","BinaryExpression"。 -
loc(SourceLocation): 源码位置信息。包含start和end(行号、列号)。IDE使用loc来定位出错源码位置。 -
range(可选): [start_index, end_index],基于字符索引的位置。Babel 等工具常用range来快速定位和替换代码片段
-
根节点 (Root)
Program: 整棵树的根节点。-
body: [Statement],包含所有的顶层语句。 -
sourceType:"script"或"module"。这决定了是否允许使用 import/export 以及是否默认严格模式。
-
- 标识符与字面量 (Atoms / Leaf Nodes)
这是树的叶子节点,也是最基础的原子单位。
Identifier: 标识符。name: 变量名(如"a","myFunc")。
Literal: 字面量。-
value: 真实的值(如1,"hello",null)。 -
raw: 源码中的原始字符串(比如"1"或"'hello'")。 -
包含子类型:
RegExpLiteral(正则),BigIntLiteral等。
-
- 声明 (Declarations)
用于在作用域中定义新变量或函数的节点。
-
VariableDeclaration: 变量声明语句(var,let,const)。- 注意:它包含一个
declarations数组,因为 JS 允许var a, b, c;。
- 注意:它包含一个
-
VariableDeclarator: 单个变量的声明(a = 1)。id: 左边(名字,可能是模式)。init: 右边(初始值)。
-
FunctionDeclaration: 函数声明 (function foo() {})。 -
ClassDeclaration: 类声明 (class Foo {})。
- 语句 (Statements)
语句是执行某种操作的代码块,通常没有返回值(在表达式语境下)。
-
BlockStatement: 大括号包起来的代码块{ ... }。 -
ExpressionStatement: 表达式语句。比如a = 1;或foo();。这是把表达式变成语句的包装器。 -
控制流语句:
IfStatementSwitchStatement/SwitchCaseReturnStatementBreakStatement/ContinueStatementTryStatement/CatchClause/ThrowStatement
-
循环语句:
-
WhileStatement/DoWhileStatement -
ForStatement/ForInStatement/ForOfStatement
-
- 表达式 (Expressions)
表达式是可以计算并产生值的节点。这是 AST 中最复杂、嵌套最深的部分。
-
BinaryExpression: 二元运算 (+,-,*,/,===)。 -
AssignmentExpression: 赋值运算 (=,+=)。 -
LogicalExpression: 逻辑运算 (||,&&)。注意:这就是你提到的逻辑短路生成跳转指令的地方。 -
UnaryExpression: 一元运算 (!,typeof,-)。 -
UpdateExpression: 更新运算 (++,--)。 -
CallExpression: 函数调用 (foo())。 -
MemberExpression: 成员访问 (obj.prop或obj['prop'])。 -
FunctionExpression/ArrowFunctionExpression: 函数表达式和箭头函数。 -
ObjectExpression/ArrayExpression: 对象和数组的字面量构造 ({a: 1},[1, 2])。 -
ThisExpression:this关键字。
- 模式 (Patterns) - ES6+
主要用于解构赋值和函数参数。
-
ObjectPattern:{ a, b } = obj。 -
ArrayPattern:[ a, b ] = arr。 -
AssignmentPattern: 默认值(a = 1)。 -
RestElement: 剩余参数...args。
-
模块化 (Modules) - ES6
ImportDeclaration:import ...ExportNamedDeclaration:export const a = 1;ExportDefaultDeclaration: `export default ...
在前面,我们说过,ast是字节码生成的唯一来源,实际上,这个说法虽然没问题,但是却不是太精准。
在V8中,解析阶段是双树伴生,AST和作用域树 互相缠绕 同时生成,作用域和节点直接关联。
而在前端社区通用规范estree中,ast并不包含作用域信息,社区规范版本的ast,目标是精确、无歧义地描述代码的语法结构,而并不包括运行时的语义, 作用域信息,则是通过遍历ast 分析出来的,通常作为分析结果而存在。 所以 准确的说,estree的ast,如果需要作用域信息,需要多一个 遍历再分析 的过程。
在了解这两种区别以后, 我们在后续学习的时候, 会采用v8的AST模式, 即认为AST直接带有作用域。
-
后序遍历 后是什么后?为什么先搞左边?
在前面 ,我们讲了后序遍历,不少新手朋友肯定很疑惑,不都是先看左子树吗?哪个是后?怎么个后序法?
对前 / 中 / 后序 的深度优先遍历:核心是根节点的处理顺序,左、右子节点的相对顺序基本固定
对于像
1 + 2这种极简的 AST(根节点是运算符,两个叶子是数字),初学者往往会觉得前、中、后序遍历“没区别”。确实,无论你先访问哪个节点,最终都能拿到1、2、+这三个元素并算出3。但这种“没区别”是一种错觉,是因为我们只关注了计算结果,而忽略了数据流向和表达形式。一旦 AST 变得复杂(如嵌套运算),或者进入编译器生成指令的阶段,遍历顺序就决定了整个程序的处理逻辑。
以
1 + 2为例,三种遍历看似只是顺序不同,实际上对应了三种核心表示法:-
**前序 **:
+ 1 2—— 波兰表示法。特点是无需括号,适合函数式语言的构造。 -
中序:
1 + 2—— 中缀表示法。这是我们最习惯的阅读方式,也是源代码的样子。 -
后序:
1 2 +—— 逆波兰表示法。这是栈式虚拟机和大多数解释器的执行逻辑。常用的还有一个层序遍历,暂时用不到,就先不讲了。
当 AST 出现深层嵌套时(例如
1 + (2 * (3 + 4))),不同遍历顺序的差异会明显显现。这是前端逆向反混淆需要理解的核心概念,对于我们本系列V8入门的目的来说,作为可跳过内容即可。 -
JSON
{
"type": "BinaryExpression",
"operator": "+", // 根节点 A (最后执行)
"left": {
"type": "Literal",
"value": 1
},
"right": {
// 右子树:这是一个复杂的嵌套结构
"type": "BinaryExpression",
"operator": "*", // 中间层节点 B (先于 A 执行)
"left": {
"type": "Literal",
"value": 2
},
"right": {
// 最内层:括号里的内容
"type": "BinaryExpression",
"operator": "+", // 最底层节点 C (最早执行)
"left": { "type": "Literal", "value": 3 },
"right": { "type": "Literal", "value": 4 }
}
}
}
- 中序遍历:还原源代码
- 路径:
1→+→2→*→3→+→4 - 核心作用: 只有中序遍历能还原出符合人类直觉的
1 + 2 * (3 + 4)。 - 使用场景: 在需要解除混淆时,如果想把混淆后的 AST 打印回 JS 代码,必须使用中序遍历,并配合优先级判断来自动添加括号。
- 路径:
- **后序遍历 :代码执行与生成 **
- 路径: 1 → 2 → 3 → 4 → +(C) → *(B) → +(A)
- 核心作用: “先子后父”。必须先算出子节点的值,父节点才能进行运算。
- V8场景: 这正是 V8 字节码生成器 的核心逻辑。
- 先下沉到最底层的
3和4,生成加载指令; - 执行
+(C),得到结果7; - 加载
2,执行*(B),得到14; - 加载
1,执行+(A),得到15。
- 先下沉到最底层的
- 其他使用场景: 如果要写一个 AST 解释器,或者模拟执行一段加密算法,后序遍历是唯一正确的执行流。
- 前序遍历 :结构分析与拷贝
- 路径:
+(A) →1→*(B) →2→+(C) →3→4 - 核心作用: “先父后子”。先拿到“我们要干什么”(比如加法),再去准备“材料”。
- 使用场景:
-
树的深拷贝: 还没到叶子节点,先把根节点
new出来。 -
代码静态分析: 分析或逆向时,如果想统计“这段代码里总共有多少个加法运算”,或者“是否存在危险函数调用” 比如 eval()、new Function()、setTimeout('恶意代码') 等,前序遍历是最快的方式,因为它可以在进入子树之前就做出判断。
-
- 路径:
略微总结一下:
- 想看懂代码(还原): 用中序。
- 想执行代码(V8/模拟): 用后序。
- 想分析结构(统计/拷贝): 用前序。
Ignition 的字节码生成器 是典型的 后序遍历 它总是先递归处理完子表达式(生成加载指令),把结果放进寄存器或累加器,最后才生成父节点的运算指令。
- 前面我们几乎都是从单独的节点来学习的,现在我们把视线调高点。
-
容器:
BlockStatement这是 AST 里最基础但最重要的骨架。没有它,代码就是散沙。
JavaScript
{
var a = 1;
a = a + 1;
}
AST 结构: 核心特征是一个 数组 (Array)。 BlockStatement 就像一个容器,它的 body 属性里装着按顺序排列的语句列表。
JSON
{
"type": "BlockStatement",
"body": [
// 数组里的第 1 个元素
{
"type": "VariableDeclaration", // var a = 1
"kind": "var",
"declarations": [...]
},
// 数组里的第 2 个元素
{
"type": "ExpressionStatement", // a = a + 1
"expression": {
"type": "AssignmentExpression",
"operator": "="
// ...
}
}
]
}
生成器视角: Generator 看到 BlockStatement 时的逻辑非常简单粗暴:遍历数组。 for (stmt of body) { Visit(stmt); } 它不关心逻辑,它只负责按顺序把里面的代码挨个生成指令。这就是程序**“顺序执行”**的物理基础。
-
分流:
IfStatement这是 AST 从线性变成树状的关键点。
JavaScript
if (test) { consequent(); } else { alternate(); }AST 结构: 这是一棵标准的 三叉树。
JSON
{ "type": "IfStatement", // 1. 测试条件 "test": { "type": "Identifier", "name": "test" }, // 2. 成立时执行的路径 (Consequent) // 注意:这里通常包着一个 BlockStatement "consequent": { "type": "BlockStatement", "body": [ { "type": "ExpressionStatement", ... } ] }, // 3. 否则执行的路径(Alternate) // 如果没有 else,这个属性就是 null "alternate": { "type": "BlockStatement", "body": [ { "type": "ExpressionStatement", ... } ] } }生成器视角: Generator 看到这个树时,最头疼的不是生成代码,而是 “挖坑”。
- 生成
test的指令。 - 生成
JumpIfFalse指令(跳去哪?还不知道,先挖坑Label_Else)。 - 生成
consequent代码。 - 生成
Jump指令(跳过 else 部分,去Label_End)。 - 填坑: 标记
Label_Else的位置。 - 生成
alternate代码。 - 填坑: 标记
Label_End的位置。
AST 的结构决定了这里必须引入 非线性 的跳转逻辑。
这里我们可以从比较抽象的逻辑层面来理解,生成器遇到 如假则跳 指令,它现在并不知道要跳到哪里 跳到什么位置,因为相关的指令还没有生成。所以 生成器给它发了张 地址卡,说:兄弟 你啥都别管了 到时候要跳的时候 就按这张地址卡上的地方跳过去就行了。
然后,到了对应的地方,生成器会将地址卡和具体地址联系上。
从比较底层的角度来看,我们使用的是编译原理中标准的挖坑填坑回填的说法,如假则跳指令, 跳到哪里我还不知道 那我先挖个坑占个位置,等过一会知道了具体位置 ,我就回来把真实有效的地址填上,这个就是 回填 。
地址卡的说法 侧重于单向的逻辑流。程序继续往下走,不需要关心底层怎么修改,只觉得到时候“自然就对应上了”。
回填的说法 侧重于内存的真实读写。也就是指令生成器确确实实干了“留下占位符 - 记住位置 -过一会再回头 - 覆盖重写”的物理动作。
这两种说法 都可以用于理解,只是理解的角度和侧重点不同, v8都有使用。
- 生成
-
循环:
ForStatementfor循环是 AST 里结构最复杂的语句之一,因为它把 4 件毫不相干的事情组合在了一个节点里。JavaScript
for (var i = 0; i < 10; i++) { console.log(i); }AST 结构: 它有四个关键插槽,缺一不可。
JSON
{ "type": "ForStatement", // 1. 初始化 (Init) - 只执行一次 "init": { "type": "VariableDeclaration", "declarations": [ { "id": "i", "init": 0 } ] }, // 2. 检测条件 (Test) - 每次循环前执行 "test": { "type": "BinaryExpression", "left": "i", "op": "<", "right": 10 }, // 3. 更新动作 (Update) - 每次循环后执行 "update": { "type": "UpdateExpression", "operator": "++", "argument": "i" }, // 4. 循环体 (Body) "body": { "type": "BlockStatement", "body": [ ... ] } }这种结构让 V8 明白:
init在最前面,body执行完后必须跳回update,update完后再跳回test。 AST 的节点位置,锁死了循环的 生命周期。这里需要注意一下,json中,节点的书写顺序,并没有强制要求,按照通常的书写顺序就可以。上面所述的 节点位置 , 是指节点在树形结构中的从属关系 和 角色定位**,而不是 JSON 键的书写顺序。** AST 是一棵树,每个节点都有固定的子节点(比如
ForStatement固定有init、test、update、body四个子节点)。这种父子关系和角色标签(字段名)就已经锁定了循环的生命周期,无论这些字段在 JSON 对象中以什么顺序出现。 简单的理解 就是依靠子节点名字来安排for循环的执行顺序。for循环 , 我们在第一部分解析篇中 对它的解析过程 进行过详细的讲解,对于以 字节码生成器的角度来讲解, 难度比较大,尤其是 let的 for循环。所以我们把具体的讲解 放到后面的字节码生成章节进行专门详细的讲述。
-
小世界:
FunctionDeclaration现在我们要看“函数本身”。
JavaScript
function add(a, b) { return a + b; }AST 结构: 这是 AST 中最大的 “特权阶级”。
JSON
{ "type": "FunctionDeclaration", // 1. 名字 "id": { "type": "Identifier", "name": "add" }, // 2. 参数列表 (Params) - 这是一个数组 "params": [ { "type": "Identifier", "name": "a" }, { "type": "Identifier", "name": "b" } ], // 3. 函数体 (Body) - 必须是一个 BlockStatement "body": { "type": "BlockStatement", "body": [ { "type": "ReturnStatement", "argument": { "type": "BinaryExpression", ... } } ] }, //在estree中,只有单纯的节点描述,并木有作用域信息, //我们为了讲解 描述方便, //有时候会采用v8的ast方式,将作用域信息挂载上来。 // V8 夹带私货 Scope Info // 在 V8 内部,这个节点上会挂载一个 Scope 对象。 // 它告诉解释器:进入这个节点时,要开辟新的栈帧,要分配新的上下文。 }生成器视角: 当 Generator 遇到
FunctionDeclaration时,它通常不会立刻生成函数体内部的字节码(这是 V8 的 惰性编译 Lazy Compilation 策略)。 它只会生成一个“外壳”(Function Object),把函数体内的 AST 先存起来(或者只生成预解析信息),等真正调用add()的时候,再回来生成里面的a+b。 -
创世节点:
Program这就好比你画了一堆房间、走廊、家具,但没画 “房子” 本身。 任何 AST 都有一个唯一的入口,就是根节点。
JavaScript
var a = 1;AST 结构:
JSON
{ "type": "Program", // 创世节点,一切的起点 "sourceType": "script", // 或者 "module" "body": [ // 顶层代码列表 { "type": "VariableDeclaration", ... } ] }V8 视角
-
Script 即 Function: 在 V8 眼里,一段顶层的 JS 代码(Program)其实会被包裹成一个匿名的 顶层函数。
-
作用域起点:
Program节点对应着 Global Scope (全局作用域)。 -
Ignition 拿到 AST 时,第一眼看的就是这个节点,它决定了整个编译任务的性质(是脚本还是模块)。
-
-
函数的调用:
CallExpression前面讲了
FunctionDeclaration,但如果没有CallExpression, 齿轮永远不会转动。 这是生成字节码时涉及 **栈帧 ** 操作最核心的节点。代码:
add(1, 2)AST 结构:
JSON
{ "type": "CallExpression", // 1. 调用的谁? (Callee) // 这里可以很简单 (Identifier: add) // 也可以很复杂 (MemberExpression: a.b.c.add) "callee": { "type": "Identifier", "name": "add" }, // 2. 传了什么? (Arguments) "arguments": [ { "type": "Literal", "value": 1 }, { "type": "Literal", "value": 2 } ] }AST 必须区分 “我是要读这个函数” 还是 “我是要执行这个函数”。
-
add->Identifier(读变量) -
add()->CallExpression(执行) 生成器看到这个节点,才会生成Call或CallProperty指令,并处理参数压栈。关于call和callproperty,我们在字节码生成部分详细学习。
可能有朋友会有疑惑,为什么是 调用表达式 而不是 callfunction呢? 因为括号
()前面的那个东西,不一定非得是一个“函数名字”,它可以是任意一个能计算出函数实体的“表达式”,在 JS 语法中,这被称为 Left-Hand-Side (LHS) 表达式,callee槽位可以容纳任何合法的 LHS 表达式。add(1, 2),obj.getCallback()(1, 2),arr[0](1, 2),(function(){})(1, 2)等等。。。这就是为什么它叫CallExpression(调用表达式)。 它的callee(被调用者)属性非常宽容,它可以容纳任何形式的 AST 节点,只要这个节点在运行时能吐出一个 Function 对象就行。 -
-
数据的载体:
ObjectExpression前面讲了
var a = 1(简单数据),但没讲var a = { x: 1 }(复杂数据)。 在 AST 中,对象字面量被称为ObjectExpression。这是一个表达式,它在执行时会动态产生一个新的对象值(在堆上新分配的实例),因此可以被赋值或传递。代码:
JavaScript
var obj = { name: "v8", [key]: 123 // 计算属性 ES6+ };AST 结构: 在 AST 层面,必须清晰的区分 静态属性 与 动态计算属性。
JSON
{ "type": "ObjectExpression", "properties": [ // 1. 普通属性 (静态) { "type": "Property", "key": { "name": "name" }, "value": { "value": "v8" }, "computed": false // --- 重点:静态的,名字字面量已知 }, // 2. 计算属性 (动态) { "type": "Property", "key": { "name": "key" }, // 这是一个变量 "value": { "value": 123 }, "computed": true // --- 重点:动态的,需要在运行期计算 } ] }Ignition 生成器的视角:
当生成器看到这个 AST 节点时,
computed字段将直接决定底层的优化路径:- 如果是全静态的(如
{a:1, b:2}): V8 会启动 “样板对象 (Boilerplate Object) + 隐藏类 (Hidden Class/Map)” 优化。- 生成期/编译期: V8 预先创建好一个包含静态属性的样板,以及描述其形状的隐藏类,放入常量池。这个隐藏类和常量池的概念 ,我们后面再详细学习。
- 运行期: 通过类似
CreateObjectLiteral(快速克隆)的指令,直接一键克隆该样板。因为不需要逐个添加属性,速度极大提升。
- 如果包含动态属性(如
computed: true): V8 并不会完全放弃优化- 先吃保底优化: 生成器依然会先克隆只包含静态部分的“半成品样板”。
- 再补动态计算: 随后,按照源码的顺序,额外生成字节码去计算
key的值,并通过相应的 keyed-store 指令(如StaKeyedProperty等变体),把动态值手工挂载到对象上。 - 结论: 相比纯静态的“一键克隆”,这种“先克隆再手工挂载”的路径确实会慢一些,但依然最大化地利用了已知的静态信息。
so AST 中的布尔值
computed,不仅记录了语法特征,更在底层指挥着 V8 是走“极速克隆通道”还是“混合构建通道”。 - 如果是全静态的(如
-
结束的信号:
ReturnStatement有始(FunctionDeclaration)必须有终。 AST 必须明确告诉生成器:在哪里停下,把什么结果交还给调用者。
代码:
return a + b;AST 结构:
JSON
{ "type": "ReturnStatement", "argument": { // 返回的值,如果后面为空,这里就是 null "type": "BinaryExpression", "operator": "+", "left": ..., "right": ... } }V8 视角: 如果函数体结束了还没有遇到
ReturnStatement,V8 会默认补一个return undefined。这个隐式行为是 AST 分析阶段处理的。 生成器看到这个节点,会生成Return指令,这标志着 当前栈帧的销毁。 -
树的叶子:
Literal与Identifier(递归的终点)我们之前看到的
BinaryExpression、CallExpression,它们都是“中间商”,它们的操作数最终都要落实到 叶子节点 上。AST 遍历到最后,只有两种情况:要么是 死值,要么是 活名。
A.
Literal(字面量) —— 死值代码:
1,"hello",true,nullAST 结构:
JSON
{ "type": "Literal", "value": 1, "raw": "1" // 源码中的原始样子 }V8 视角: 这是最简单的节点。生成器看到它,直接把值放进 Constant Pool (常量池),然后生成
LdaConstant指令。它是静态的,编译期就能确定。B.
Identifier(标识符) —— 活名代码:
a,add,undefinedAST 结构:
JSON
{ "type": "Identifier", "name": "a" }V8 视角: 这是最复杂的叶子。生成器看到
a,它不知道a是什么。它必须:- 去当前 Scope (作用域) 查表。
- 如果没找到,去 Parent Scope 查。
- 一直查到 Global。
- 决定是生成
LdaGlobal(全局加载)还是LdaContextSlot(闭包加载)还是LdaNamedProperty(对象属性)。 - 这部分如何在作用域中查找的相关内容,在第一部分解析篇中,有详细的步骤解说。
总结: AST 的递归遍历,永远是终止于
Literal或Identifier。 -
轻装而行的箭头函数:
ArrowFunctionExpression
前面讲了 FunctionDeclaration(函数声明),但现代 JS 到处都是箭头函数。它在 AST 结构上有一个 特别重要的区别,V8 必须特殊处理。
代码: const add = (a, b) => a + b;
AST 结构: 注意看 body 部分,它不是 BlockStatement
JSON
{
"type": "ArrowFunctionExpression",
"params": [ ... ],
// 重点,直接就是表达式,没有大括号!
// 这叫 "Concise Body" (简洁体)
"body": {
"type": "BinaryExpression",
"operator": "+",
"left": ..., "right": ...
},
// 标记:这是一个表达式函数,不是声明
"expression": true
}
V8 视角:
-
没有
this: V8 解析到箭头函数时,会标记它“没有自己的this”。如果函数体内用了this,V8 必须直接去 外层作用域 找。 -
隐式 Return: 生成器看到
expression: true,会自动在生成字节码时,给a+b的结果前面补上Return指令。这和普通函数必须写return关键字完全不同。 -
拼接的模板字符串:
TemplateLiteral这是 ES6 引入的复杂的字面量。它打破了
Literal节点的原子性。代码:
Hello, ${name}!这看起来是一个字符串,但在 AST 里,它是一个 “拉链结构”。
AST 结构:
它被拆成了两部分:Quasi (准字面量/静态部分) 和 Expression (表达式/动态部分)。
JSON
{ "type": "TemplateLiteral", // 1. 静态的字符串片段 (Hello, !, 空字符串) "quasis": [ { "type": "TemplateElement", "value": { "raw": "Hello, " }, "tail": false }, { "type": "TemplateElement", "value": { "raw": "!" }, "tail": true } ], // 2. 动态的插值 (name) "expressions": [ { "type": "Identifier", "name": "name" } ] }V8 视角: V8 看到这个结构,处理逻辑是 拼接缝纫:
-
拿出第一个 quasi (
"Hello, ")。 -
算出第一个 expression (
name),转成字符串,拼上去。 -
拿出第二个 quasi (
"!"),拼上去。 -
最终合并结果。 这比普通的字符串拼接 (
+) 逻辑要复杂得多。
-
-
异常处理的依据:
TryStatement在
BytecodeArray结构中,有一个字段叫handler_table(异常处理表) ,这个表的来源,就是 AST 中的
TryStatement。代码:
JavaScript
try { doSomething(); } catch (e) { handleError(e); } finally { cleanup(); }AST 结构: 这是一个“三位一体”的复杂节点。
JSON
{ "type": "TryStatement", // 1. 尝试执行的代码块 (Block) "block": { "type": "BlockStatement", "body": [...] }, // 2. 捕获错误的部分 (CatchClause) // 注意:它是一个独立的节点类型 CatchClause "handler": { "type": "CatchClause", "param": { "type": "Identifier", "name": "e" }, // 捕获的变量名 "body": { "type": "BlockStatement", ... } }, // 3. 最终执行的部分 (Block) "finalizer": { "type": "BlockStatement", "body": [...] } }V8 视角: 生成器看到
TryStatement时,不会像普通If那样生成跳转指令,而是会记录:-
“从第 X 行指令到第 Y 行指令,如果出错了,请跳转到 Z 行(Catch 块)。”
-
这段信息会被单独提取出来,存入 Handler Table。
-
这是 AST 结构直接决定 运行时元数据 的直接体现。
-
-
有空洞的数组:
ArrayExpression我们讲了对象
{},但没讲数组[]。 数组看着简单,但它有一个 JS 特有的“大坑”,Holes (稀疏数组/空洞)。代码:
var arr = [1, , 2];注意中间有两个逗号AST 结构: V8 必须精确记录“哪里是空的”。
JSON
{ "type": "ArrayExpression", "elements": [ // 索引 0: 有值 { "type": "Literal", "value": 1 }, // 索引 1: 这是一个坑 (Hole) // 在 AST 中,它直接表现为 null null, // 索引 2: 有值 { "type": "Literal", "value": 2 } ] }V8 视角: 生成器看到
elements数组里有null时,会特别小心。-
全满数组:生成
CreateArrayLiteral,速度快。 -
有洞数组:V8 知道这不是一个连续的内存块,必须特殊处理这个“洞”(它不是 undefined,它是 the hole),这直接影响数组的存储模式,由满员变成了有洞。
-
这种含有
the hole的数组,在 V8 内部会被标记为 HOLEY_ELEMENTS 元素种类(Elements Kind),这会导致后续的数组遍历(如 map/forEach)引发原型链查找,降低性能。
-
-
class语法糖:
ClassDeclarationES6 引入了
class,但在 V8 看来,这只是一层厚厚的“糖衣”。AST 必须把这层糖衣剥开,以方便生成器看到里面的真实情况。代码:
JavaScript
class Person { constructor(name) { this.name = name; } say() {} }AST 结构: 结构非常深,包含了构造函数和方法定义。
JSON
{ "type": "ClassDeclaration", "id": { "name": "Person" }, "body": { "type": "ClassBody", "body": [ // 构造函数 { "type": "MethodDefinition", "kind": "constructor", "key": { "name": "constructor" }, "value": { "type": "FunctionExpression", ... } // 构造函数本质是函数 }, // 普通方法 { "type": "MethodDefinition", "kind": "method", "key": { "name": "say" }, "value": { "type": "FunctionExpression", ... } } ] } }V8 视角: 生成器看到这个 AST 时,实际上是在干 **“脱糖工作” **。 它不会生成一个叫
CreateClass的指令(早期 V8 没有),而是会生成一堆指令:-
创建一个普通函数(对应 constructor)。
-
设置这个函数的
prototype。 -
往
prototype上挂载say方法。 AST 清晰地展示了Class是如何由Function和Prototype拼凑而成的。 -
关于类的解析,如何生成AST的部分,可以看第一部分解析篇。
-
-
AST小结
我们花了不小的篇幅来讲解 AST,是因为它在编译流程的卡位:它既是前面**“语法解析”的最终成果,又是后面“字节码生成”**的唯一图纸。
对于前端工程通用的 ESTree 规范,其 AST 的数据结构本身其实并不复杂,真正的难点在于如何理解它的执行语义与遍历顺序。
接下来,我们将进入**作用域 ** 的学习。 有些朋友可能会有疑问:前面的第一部分解析篇里,不是已经讲过作用域了吗?
这是因为,两次的侧重点完全不同:
- 在 解析篇 中,我们关注的是“时机与动作”。 我们当时是站在解析器的视角,知道了“V8 在把源码变成 AST 的同时,顺手把 Scope 树也建了”。那时我们知道它做了这件事,但并没有详细的拆开 Scope 树去看看里面到底装了什么东东。
- 在这里,我们关注的是“结果于归属”。 我们现在要看的是解析之后的最终信息。Scope 分析的根本目的,是决定每一个变量的命运归宿,到底谁有资格留在高速的栈(寄存器)上,谁又被迫住进了堆内存的 Context 中?
- 终极目的:为字节码生成做铺垫。 Ignition 的字节码生成器根本不关心解析器分了几步把树建好。它只看结果:看AST 决定要干什么(加减乘除、调用),看 Scope 决定去哪拿数据(生成极速的
Ldar还是缓慢的LdaContextSlot)。
最后,为什么我们需要专门再讲解作用域的生成? 因为在这个 AST 专场中,为了方便理解,我们使用的是前端通用的 ESTree 规范。
- 在 ESTree 的世界里: AST 只是纯粹的语法骨架,不包含任何作用域信息。作用域必须通过后续的工具(如
eslint-scope)进行二次遍历才能生成,AST和作用域信息在物理上是分离的。 - 在 V8 的世界里: V8 的 AST 是内部私有格式,解析时 AST 和作用域信息双树伴生,AST 节点上直接挂载了作用域指针。
为了消除这两种视角的差异,在接下来的内容中,我们将以纯粹的 ESTree AST 为输入,把这份缺失的“作用域信息”生成出来。
3. AST与作用域
在后面的内容中,,为了直观地展示 V8 生成器眼中的世界,我们有可能会使用一种 “增强版的伪 JSON ”。
一定要注意:
- 标准的 JSON 是不允许写注释的。
- 严格的 ESTree 规范,包括生成的AST,并不包含带有
[[ ]]这种特殊标记的字段(如[[Scope]])。 - 真实的 V8 内部使用的 AST 在内存中是复杂的 C++ 对象和指针引用。
我们在标准的 ESTree 骨架上,添加进这些带有 [[ ]] 的虚拟属性,目的是为了讲解与学习。它可以极其直观的帮我们在脑中形象化“AST 节点是如何通过底层指针关联到作用域信息”的这一核心过程。把它看成一种学习的辅助可视化工具,而非标准的数据输出格式。这也是通用的一种讲解形式。
-
下面,我们就用一个非常简单的例子,来把静态的estree的ast,解析出它的作用域,这也是前端工具链中作用域分析器的工作,面对着刚刚解析出来的 ESTree,作用域分析器扮演了一个户籍调查员的角色,现在,他开启了户籍查询的旅程。
户籍查询目标代码:
JavaScript
var globalVar = 1; function foo() { var localVar = 2; return globalVar + localVar; }
第一步:面对纯粹的 AST 只有地图,没有户籍
解析器已经工作完毕,给出了一份标准的 ESTree JSON。在分析器眼里,这份 AST 大致是这样的结构(省略了非核心信息,比如 loc 位置等):
JSON
{ "type": "Program", // 根节点 "body": [ // 全局变量声明 { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "name": "globalVar" }, "init": { "value": 1 } } ] }, // 函数声明 { "type": "FunctionDeclaration", "id": { "name": "foo" }, "body": { "type": "BlockStatement", "body": [ // 局部变量声明 { "type": "VariableDeclaration", "declarations": [ { "type": "VariableDeclarator", "id": { "name": "localVar" }, "init": { "value": 2 } } ] }, // 返回语句 (包含加法运算) { "type": "ReturnStatement", "argument": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Identifier", "name": "globalVar" }, "right": { "type": "Identifier", "name": "localVar" } } } ] } } ] }此时,AST 里所有的
globalVar和localVar都只是单纯的字符串"name"。底下的ReturnStatement根本不知道它要加的这两个变量到底是谁。接下来,作为户籍调查员的分析器要开工了,开始两次遍历。
第二步: 建档 - 登记 -第一次遍历
分析器的第一遍目标很明确:只找声明(定义),不管使用(引用)。
它手里拿着一个作用域栈 (Scope Stack),开始从上往下走。
1. 站在
Program节点- 动作: 任何程序都有全局环境。分析器立刻创建一个
GlobalScope(全局作用域) 对象。 - 状态: 将
GlobalScope压入栈顶。当前户口本:GlobalScope。
2. 走到第一个
VariableDeclaration- 动作: 发现变量声明
id: { name: "globalVar" }。 - 登记: 分析器翻开当前栈顶的户口本(
GlobalScope),写下:“新增居民:globalVar”。
3. 走到
FunctionDeclaration- 动作 1(登记函数名): 函数
foo本身也是在全局定义的。分析器在GlobalScope里写下:“新增居民:foo”。 - 动作 2(开辟新地盘): 函数会创造新的作用域!分析器创建一个新的
FunctionScope对象,并将其内部的parent属性指向GlobalScope(确立父子关系)。 - 状态: 将
FunctionScope压入栈顶。当前户口本切换为:FunctionScope (foo)。
4. 进入
foo的BlockStatement,走到第二个VariableDeclaration- 动作: 发现变量声明
id: { name: "localVar" }。 - 登记: 分析器翻开当前栈顶的户口本(
FunctionScope),写下:“新增居民:localVar”。
5. 走到
ReturnStatement- 动作: 这里只有使用(引用),没有声明(没有 var/let/const/function)。
- 跳过: 第一遍遍历不关心引用,直接跳过。
6. 遍历结束,出栈
- 动作:
foo函数遍历完了,FunctionScope弹栈;整个程序遍历完了,GlobalScope弹栈。
第一次遍历调查的成果: 得到了两本建好的“户口本”(但此时还没人去查户口)。
第三步: 寻找亲人 - 第二次遍历
户口本建好了,现在分析器要进行第二遍遍历。这次的目标反过来了:不管声明,只找使用(引用 Identifier),并且给它们找到对应的户口。
分析器再次走到
ReturnStatement的那个加法表达式:globalVar + localVar。1. 解析右边的
localVar- 分析器看到了一个孤儿:
Identifier { name: "localVar" }。 - 在哪? 根据 AST 的层级,分析器知道自己现在正身处
foo的FunctionScope中。 - 查户口: 打开当前的
FunctionScope户口本。 - 匹配成功: “找到了!这里确实登记过一个叫
localVar的居民。” - 连线 (Resolved): 分析器在 AST 里的这个
Identifier节点,和FunctionScope里的localVar登记记录之间,建立了一条硬链接。
**2. 解析左边的
globalVar**- 分析器看到了另一个孤儿:
Identifier { name: "globalVar" }。 - 查当前户口: 打开当前的
FunctionScope户口本,找了一圈。 - 匹配失败: “哎呀,没找到
globalVar啊!难道是个黑户?” - 作用域链冒个泡: 分析器顺着
FunctionScope的parent指针,向上爬到了父级作用域 ——GlobalScope。 - 查父级户口: 打开
GlobalScope户口本。 - 跨层匹配成功: “找到了!原来它是全局居民。”
- 连线 (Resolved): 分析器将 AST 里的这个
Identifier,跨越层级,链接到了GlobalScope的globalVar记录上。
注意这里,如果在 GlobalScope 还找不到,分析器就会在它身上盖个戳:
Undeclared未定义。
第四步: 最终的户口本完工
经过这两次遍历,前端的分析工具(如
eslint-scope)会生成一份独立于 AST 的作用域树 (Scope Tree) JSON。它和 AST 是两套数据,但通过内存指针或节点引用互相链接:
JSON
// 这就是 Scope Tree { "type": "global", "variables": [ { "name": "globalVar", "defs": ["指向 AST 第2行的 var 节点"] }, { "name": "foo", "defs": ["指向 AST 第4行的 function 节点"] } ], "childScopes": [ { "type": "function", "name": "foo", // 登记在案的本地变量 "variables": [ { "name": "localVar", "defs": ["指向 AST 第5行的 var 节点"] } ], // 那些在 AST 里被使用的变量,最终都解析到了哪里? "references": [ { "identifier": "指向 AST 里 return 语句中的 localVar 节点", "resolved": "指向上面本地的 variables[0] (localVar)" }, { "identifier": "指向 AST 里 return 语句中的 globalVar 节点", "resolved": "跨层级,指向最外层 Global Scope 的 variables[0] (globalVar)" } ] } ] }稍微总结一下:
在标准的 ESTree 里,AST 是骨肉,Scope Tree 是神经系统。
神经系统虽然看不见,但它决定了哪块肌肉该怎么动。
- 对于前端工具链中的工具: 它查这套 Scope 树。如果发现有个变量在
variables里登记了,但在references里从没被用过,它就给你报警告:no-unused-vars。 - 对于 V8 的 Ignition: V8 的生成器在遍历到
return globalVar + localVar时,它不会再去查字符串名字了。它直接通过内部的指针问 Scope Tree:“我要加载这两个数据,去哪拿?”- Scope Tree 说:“
localVar在本地,生成Ldar寄存器指令!” - Scope Tree 说:“
globalVar在全局,生成LdaGlobal全局指令!”
- Scope Tree 说:“
通过这个例子,我们在纯正的 ESTree 标准下,讲了作用域是如何被生成出来的。
进阶内容:
我们在前面用“两次遍历(先建档,再寻亲)”来讲解,是简化过的抽象过了的过程,因为这样讲,最符合我们的直觉,也能直观了解js中的变量提升。
但是,在真实的 V8 引擎或
eslint-scope工具中,出于对性能的变态追求,分析器对 AST/或源码,只会进行一次完整的深度优先遍历。既然是一次遍历,就会遇到掉头发的问题:变量提升和提前引用。
比如
JavaScript
function foo() { console.log(a); // 引用 a var a = 1; // 声明 a }如果分析器只做一次自上而下的遍历,当它走到
console.log(a)时,它根本还没看到后面的var a = 1。此时如果这时候让它“寻亲”,它会误以为
a是个外部的全局变量。等走到下一行,才会发现:“哎呀,原来a是本地人!”。我记得类似的例子,在第一篇解析部分里写过吧,哎,时间隔太久,记不住了。为了在一次遍历中解决这个问题,真实的分析器采用了一种术语叫做 “延迟决议 (Deferred Resolution)” 的策略。我们可以称之为**“秋后算总账”**。
在遍历一个作用域(比如
foo函数)时,分析器会在手里拿着两个小册子:- 花名册 : 记录在本作用域内声明的变量(遇到
var a就记下来)。 - 悬案册: 记录在本作用域内被引用了,但还没找到主人的变量(遇到
console.log(a),就把引用的a记下来)。
一次遍历的真实过程如下:
- 一直往下走: 分析器遇到节点就记账。遇到声明,写进“花名册”;遇到引用,不管3721,先扔进“悬案册”。在遍历过程中,并不会立刻连线寻亲。
- 走到作用域尽头(遇到
}离开当前节点): 当分析器准备离开foo函数,弹栈之前,它会停下来秋后算总账。 - 内部结案: 它拿出“悬案册”里的每一个变量,去对比当前的“花名册”。
- 刚才
console.log(a)留下的悬案a,此时在这份花名册里找到了var a的记录!连线成功,悬案销毁。
- 刚才
- 悬案上交(冒泡): 如果对比完之后,“悬案册”里还有没找到主人的孤儿(比如
globalVar),怎么办?把剩下的悬案,打包塞给父级作用域的“悬案册”! - 最终结案: 等遍历回退到最顶层的 Global Scope 时,进行最后一次“内部结案”。如果全局花名册也结不了这些悬案,这些变量就会被正式宣判为
Undeclared(未定义)。
在略微总结一下:
-
我们学习时用的两次遍历: 是为了搞清楚,收集定义和处理引用是两个不同的逻辑阶段。
-
具体工程实现里的一次遍历: 是通过**“边走边记,离开时统一结算(匹配并向上传递)”**的算法,把两次遍历压缩在了一次遍历中完成。这省去了重复访问 AST 节点的超大性能开销。
- 动作: 任何程序都有全局环境。分析器立刻创建一个
4. 字节码的生成
在解析篇中,我们搭建起了一棵枝繁叶茂的 AST(抽象语法树),并查清了每一个变量的 Scope(作用域)户口。
现在我们要学习的,是如何将AST,变成Ignition可以使用的字节码。
在我们的想象中,可能会觉得 V8 会有条不紊的工作着:先让寄存器分配器画好所有图纸,再让生成器去生成指令,最后去检查优化各种指令和跳转。
但 V8 对极致执行性能有着近乎偏执的追求。真实的情况是,BytecodeGenerator 根本没有这些割裂的明显阶段。它采用的是 ASTVisitor 访问者模式,对 AST 执行后序遍历(先递归处理所有子节点,再处理当前节点)。顺着 AST 树往下摸:摸到已预分配槽位的显式变量就直接寻址,摸到加号就当场生成指令,摸到 if 就当场挖坑留地址卡,用到临时值就当场向场务申请或释放临时寄存器。所有的事情,在它顺着树游走的那一瞬间,同时发生,同时结束。
最后,一旦字节码生成完毕,那棵我们为之花了很多心血学习的 AST,它的主体会被 V8 在编译完成后立即整体释放。Zone 是 V8 编译期使用的线性内存池,解析、字节码生成全流程的 AST 节点均分配在该内存池内。编译完成后,整个 Zone 会被整体一次性释放,无需经过 JS 堆的垃圾回收流程,内存清理效率极高。不过 V8 并没有赶尽杀绝,那些函数运行时必不可少的元信息(比如作用域描述、源码位置映射),会被完好地保存在 SharedFunctionInfo 里,深藏功与名。
现在我们学习ignition是如何根据ast生成字节码的。
注意:
虚拟寄存器: 下文中出现的
r0, r1...都是 Ignition 解释器层面的 栈槽(Frame Slots / 虚拟寄存器),千万别把它们当成 CPU 里的物理寄存器。关于指令: V8 引擎迭代极快,真实的字节码指令名和操作数格式每个版本都可能变动。文中的
LdaGlobal、Star等指令均为表意清晰的 示意性代码,如需确定的指令,还需查阅最新的v8文档。
我们并不会采用抽象的 逻辑上的 “功能、阶段、目的。。。” 分段讲解,而是采用跟随的方式,用一镜到底的视角,跟随v8,看它是怎么生成字节码的,虽然有些烧脑,但这是v8的真实流程。
先介绍几个重要角色:
导演 —— BytecodeGenerator (字节码生成器):
- 绝技: ASTVisitor(访问者模式)。
- 人设: 掌控全局的片场老大。他亲自顺着 AST 树往下摸(后序遍历),走到哪拍到哪。他一声令下,全场运转。
场务 —— BytecodeRegisterAllocator (寄存器分配器):
- 绝技: 空间管理大师。
- 人设: 极其抠门、精打细算的后勤大管家。掌管着虚拟栈帧上的工作区。你向他借临时小板凳(临时寄存器),用完必须神速归还,他会立刻借给下一个人。
记录员 —— BytecodeArrayBuilder (字节码构建器):
-
绝技: 返聘的记录员,自带老编辑的职业病--窥孔优化(Peephole Optimization)。
-
人设: 手速如飞 ,患有极度强迫症的老编辑。他负责把导演喊出的指令转化为
uint8字节流写进内存。如果他发现导演喊了废话,就会触发职业病--窥孔优化,直接在脑子里把废话过滤掉,不搭理。
绝对的舞台中心: 聚光灯 —— 累加器 (Accumulator, Acc)。
为什么 Ignition 要采用这种“累加器 + 寄存器”的混合架构?因为绝大多数指令的运算结果都会默认写入聚光灯(累加器)下,这使得指令在编码时无需额外指定目标寄存器,从而大幅压缩了字节码的整体体积,并极大降低了底层解释器的实现复杂度。在ignition篇的上部分,我们已经讲过这些。这里再提一下。
我们开始了:
一。静态空间分配
JavaScript
// 假设在一个函数内,a 和 b 是已经存在的局部变量
let result = (a > 5) || (b + 10);
生成的AST是这样的:
JSON
{
"type": "VariableDeclaration",
"kind": "let",
// 场务提前关注:在当前 BlockScope 登记 result,状态为 TDZ,
// 预分配槽位:r2
"[[Scope_Action]]": "Register 'result' -> Allocate Local(r2)",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "result",
// 户口查明:目标明确,等会儿存入这里
"[[Resolved_Target]]": "Local Register r2"
},
"init": {
"type": "LogicalExpression",
"operator": "||",
// 导演关注:这里是控制流分水岭
// 必须挖坑 (JumpIfTrue),留地址卡 Label_End!
"[[Control_Flow_Mark]]": "Short-circuit Jump",
"left": {
"type": "BinaryExpression",
"operator": ">",
"left": {
"type": "Identifier",
"name": "a",
// 户口查明:不需要去堆里找,就在栈上
"[[Resolved_Source]]": "Local Register r0"
},
"right": {
"type": "Literal",
"value": 5
}
},
"right": {
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Identifier",
"name": "b",
// 户口查明:同样在栈上,直接拿
"[[Resolved_Source]]": "Local Register r1"
},
"right": {
"type": "Literal",
"value": 10
}
}
}
}
]
}
导演先拿着带有作用域信息的AST剧本,找到了场务。
“场务,这几个演员的位置定一下。”
场务翻开账本,这叫 显式局部变量 (Locals) 分配:
- 演员
a身家清白(没被闭包捕获),安排在栈槽r0。 - 演员
b身家清白,安排在栈槽r1。 - 新来的
result也是本地人,留个空椅子r2给它。
如果 Scope 户口本上写着
a被闭包捕获了怎么办?场务会果断拒绝给它分配栈上的
r0椅子。导演在后续喊指令时,绝不敢喊Ldar r0,而是必须喊出极其昂贵的LdaContextSlot,指路去堆内存(Heap)的 Context 豪华别墅里找人。这就是闭包拖慢速度的物理根源。另外:这里的显式局部变量,在v8的解析阶段,就已经确定好了位置,有确定的索引位置。
二。后序遍历的体现
导演看着剧本,根节点是赋值号 =。
“不行,等号右边的复杂表达式没算完,怎么赋值?”
导演发动 后序遍历(Post-order Traversal) 技能,一头扎进右子树,遇到了逻辑或 || 节点。
“短路逻辑?我也算不了,必须先看左边 (a > 5) 是真是假。”
导演继续下沉,来到了二元运算 > 节点。
“比大小?拿什么比?必须先拿到左右叶子!”
这就是后序遍历(先子后父)的物理必然性。不沉到最底层的叶子去拿数据,聚光灯下就空无一物,根本无法计算。
分镜头 :a > 5
导演终于摸到了最底层的叶子节点 a。
导演大喊:“开工!把 a 请到聚光灯下!记录员,写!”
记录员敲下:
Ldar r0(Load Accumulator from Register 0)
现在,聚光灯(Acc)下站着 a 的值。
但是,下一步要和 5 比大小,可是聚光灯的光圈极其狭小,只能站一个人。如果不把 a 挪开,下一个上场的 5 就会把 a 覆盖,
导演朝场务大喊:“场务,聚光灯塞不下,赶紧找个临时板凳,把 a 挪过去暂存!”
场务翻开小本本,开启 临时变量 (Temps) 贪心分配 模式:“临时区域 r3 空着,拿去!”
导演:“记录员,写!”
记录员敲下:
Star r3(Store Accumulator to Register 3)
此时,a 退到了阴影里的临时板凳 r3 上,聚光灯空出来了。
导演继续摸到下一个叶子节点 5。
记录员敲下:
LdaSmi [5](Load Small Integer 5 into Accumulator)
现在,左边在暗处的小板凳 r3 上,右边在明处的聚光灯 Acc 里。
导演:“万事俱备,执行 大于 操作!记录员,写!”
记录员敲下:
TestGreaterThan r3(拿r3的值去 > 聚光灯的值)
嗖的一下,一个布尔值(true 或 false)诞生了,它稳稳地停在了聚光灯(Acc)下,而原先acc里面的5,被无情的覆盖了。
也在这时候,场务猛扑过来,把 r3 那个临时板凳抽走了。
“算完了还想占着位置?临时寄存器用完即收,绝不浪费”,场务在账本上把 r3 重新标记为“可用”。
这就是为什么写了再长、再复杂的连加连乘公式,V8 的栈帧体积依然极其微小的原因:临时空间的极限贪心复用。
三。控制流的挖坑和回填
现在,导演带着聚光灯下的布尔值,浮出了水面,回到了逻辑或 || 节点。
在 JS 的法则里,如果 a > 5 算出来是 true,整个 || 表达式就直接为 true,右边的 (b + 10) 连看都不用看。
导演自言自语:如果聚光灯下是 true,立刻给我跳到大结局!
于是导演转头看向记录员:“写一条向前跳的指令!”
记录员有点迷惑:“导演,跳去哪儿啊?右边的代码都还不知道呢,也不知道大结局的内存偏移量是加上 5 个字节还是加上 15 个字节啊?”
导演轻蔑一笑,从口袋里掏出一张空白的地址卡(Label),拍在桌上:
“先挖坑! 写下跳转指令,目标地址留空,给我贴上这张叫 Label_End 的卡片。等会儿我们走到大结局的时候,你再回头把真实的地址填进去!”
记录员敲下:
JumpIfTrue [??? 坑位: Label_End]如真则跳, 前面我们讲过如假则跳。
这一刻,立体的 AST 分支,被强行拍扁成了带坑位的线性指令。 这就是在编译原理中被称为 **Backpatching(回填)**的术语。这里同时体现了前面我们说过的 地址卡 和 回填 两种方式。
四。右路推进
如果代码没有在上一句跳走(说明聚光灯下是 false),执行流就会推进碾压过来,进入右边的 b + 10。
导演再次下潜,这套动作已经熟练了:
记录员听着导演语录,疯狂输出:
Ldar r1(把b请到聚光灯下)
Star r3(重点 场务再次递上了刚才回收的r3临时板凳!空间被完美复用!)
LdaSmi [10](把 10 请到聚光灯下)
Add r3(执行加法,结果留在聚光灯下)
场务再次无情地抽走 r3 临时板凳。此时,聚光灯下acc里,闪烁着 b + 10 的最终计算结果。
五。填坑 赋值
导演终于回到了剧本的最顶层——根节点 result = ...。
此时的情况是:
-
如果第一条时间线短路了(
a > 5为真),刚才跳走时,聚光灯里留着的是true。 -
如果走了第二条时间线,算完了
b + 10,聚光灯里留着的是计算结果。无论走哪条线,最终需要赋给
result的那个正确的值,此刻都安安静静地躺在聚光灯(Acc)里!
导演:“大结局了!记录员,干两件事!”
第一,填坑! 看看你的笔现在停在物理内存的哪个偏移量上了?把 Label_End 对应的最终字节码偏移量,回填到之前预留的跳转指令操作数中!
记录员翻回上一页,把挖好的坑用真实的物理地址(比如 +0x0A)填满。
第二,杀青赋值! 把聚光灯里的结果,给我送回 result 的空椅子上去!
记录员敲下最后一句:
Star r2
六。片场速写
在记录员(BytecodeArrayBuilder) 每次记录下指令的瞬间,他的职业病时刻在准备发作——窥孔优化器 (Peephole Optimizer) 一直在默默运作。
他的视力不好,每次只能透过一个小孔(窗口)看相邻的两三条指令,专治各种“机械的愚蠢”。
假如导演看美女走神或者一时脑乱,喊出了这样一段内容:
LdaSmi [1] // 把 1 放进聚光灯
Star r0 // 存进 r0
Ldar r0 // 废话 又把 r0 读回聚光灯
记录员透过窥孔一看,很是烦躁:“第三步纯属多余,聚光灯里本来就是 1,不需要再读。”
他连笔都不动,直接在脑子里把 Ldar r0 抹杀掉,生成的真实字节码只有极度紧凑的前两句。这种在极小局部范围内“边写边优化”的实时拦截,保证了生成的指令没有明显的多余。
除了记录字节码,记录员还偷偷绘制了一张隐形地图。
如果将来运行时这行 b + 10 突然报错(比如 b 是个不可相加的奇怪对象),V8 怎么知道要把错误定位回源码的第 42 行?
记录员在生成字节码的同时,生成了一张 Source Position Table。它记录了“字节码偏移量 -> 源码行列号”的映射。为了省内存,这张表使用了v8中称之为 **差分编码(Delta Encoding)**的存储方式。平时它静静躺在内存角落里毫无声息,只有程序崩溃、抛出 Stack Trace 的那一瞬间,V8 才会紧急解压它,按图索骥定位位置。
七。收工
所有的图纸、动作、场务调度,最终在堆内存里凝结成了一个叫 BytecodeArray 的对象。
它本质上就是一串普普通通的 uint8 字节数组。
它的结构极其朴素:Opcode (1 byte 操作码) + Operands (变长操作数)。
如果遇到了场务分配的临时寄存器索引超过了 255 个(1 byte 无符号最大值)怎么办?1 byte 装不下了。 V8 会使用宽指令。它会在普通指令前塞入特殊的标记:Wide 前缀可将操作数扩展为 16 位,ExtraWide 可扩展为 32 位。这不仅用于海量的寄存器索引,还被广泛用于大整数常量和长距离的跳转偏移量等超出单字节范围的操作数。
现在再看上面的AST:
-
为什么导演敢直接喊
Ldar r0?因为他一潜入到最底层的
a,看到节点上挂着的[[Resolved_Source]]: "Local Register r0"。他根本不用再去查字符串 "a" 是谁,直接照着户口本上的地址找人 -
如果
a是个闭包变量,剧本长什么样?那
a节点上的标签就会变成[[Resolved_Source]]: "Context Slot [2]"。导演一看这标签,就会立马改口,让记录员写下:LdaContextSlot [2]。这就叫静态分析指导动态生成。 -
||节点的特殊对待在普通的 AST 里,
||只是个运算符。但在 V8 导演的剧本里,[[Control_Flow_Mark]],这就表示导演走到这里必须停下来发地址卡、挖坑,不能像普通的+号那样直接往下执行。
-
我们继续再多刷几个小例子
-
本地局部变量
- 代码:
let a = 1; return a; - 详情:
a是身家清白的本地人,没有被闭包等外界因素牵连。场务在函数开局建栈时,就给它分配了固定的椅子(比如r0,注意:反复说明过,这里指的是 Ignition 字节码层面的帧槽 Frame Slots 或虚拟寄存器,并不是物理 CPU 的通用寄存器)。 - 导演喊话:
Ldar r0(Load Accumulator Register:直接从r0抓取数据,扔进聚光灯 Acc 下) - 性能: 极速。 在物理层面上,这就是一个极其简单的栈内存(帧槽)偏移读取,没有任何多余动作,干净利落。
- 代码:
-
全局global变量
-
代码:
console.log(windowVar); -
详情: 导演查户口发现,它没在本地登记。顺着作用域链爬到顶,发现是全局变量。
对于以前较老的脚本(非模块)来说,用
var声明的顶层变量通常会直接变成全局对象(Global Object)的属性,但在 ES6 模块的片场里,顶层的let/const拥有独立的尊严,它们存放在模块的**顶层词法环境(Module Lexical Environment)**里,绝不会去给 Global Object 当小弟。 -
导演喊话:
LdaGlobal [name_index], [feedback_slot] -
细节: 无论它是哪种全局变量,导演都是会把
windowVar这个名字折叠进常量池,拿到一个对应的索引(name_index)。执行时,引擎拿着这个索引去全局环境里进行哈希查找或属性寻址。 -
性能:相对缓慢。 哪怕引擎的哈希表优化得再厉害,查全局字典/词法环境也比直接摸栈内存慢得多。所以,能在局部缓存的全局变量,尽量用
let/const缓存在局部。
-
-
闭包变量
-
代码:
return outerVar;(outerVar是外层函数的变量) -
详情: 导演翻开户口本,看到
outerVar被贴上了Context Allocation的标签。只有当变量确实被闭包捕获或需要跨帧访问时,编译器才会把它从栈槽**提升(Promote)**到堆内存的豪华别墅(Context 对象)里。没被捕获的局部变量,依然老老实实蹲在栈上。 -
导演喊话:
LdaContextSlot <context_reg>, <slot_index>, <depth> -
细节: 注意看这三个参数,导演要想越级拿到闭包变量,比较麻烦:
depth(深度): 导演得先看看自己离目标别墅隔了几层。如果是父函数的变量,depth就是 1;如果是爷爷函数,就是 2。- 顺藤摸瓜: 解释器在运行时,必须拿着当前栈帧里的 Context 指针,沿着堆内存里的链表,往上爬
depth次,才能摸到那个正确的别墅大门。 slot_index(槽位索引): 找到别墅后,直接去别墅里的第几个房间找人。
-
性能:沉重。 闭包之所以看起来慢且耗内存,就是因为这种访问常涉及“指针解引用 + 堆内存访问”。相对于极速的帧槽读取,消耗非常明显。不过现代引擎很聪明,在许多场景下会尽力延迟或避免不必要的堆分配,只有在规范确实需要保存跨帧状态时,才会狠下心做 Promotion提升。
-
-
对象属性访问
这可能是前端们写得最多的一句代码:
obj.name。在 V8 的片场里,这个并不是一个简单的取值。
-
代码:
console.log(obj.name); -
导演喊话:
Ldar r0(先把obj拿到聚光灯下)GetNamedProperty r0, [name_index], [feedback_slot] -
细节(情报小本):
重点全在那个不起眼的
[feedback_slot](反馈槽) 上由于 JS 是动态语言,导演在拍这段戏(生成字节码)时,根本不知道
obj长什么样子,它里面到底有没有name?name藏在内存的什么偏移量上?导演什么都不知道。所以,导演给未来的解释器(Ignition)发了一个空白的情报小本(Feedback Vector 反馈向量)。
爱面子的导演意图很明确,:“大兄弟,你等会儿跑起来的时候,第一次遇到这个
obj,肯定要花大力气去查它的隐藏类(Map)。查到之后,顺手把这个对象的形状和查找路线,记在这个小本的feedback_slot里!”当下一次再执行到这行代码时,解释器翻开小本一看:“呦,熟客啊,还是原来的形状没变化丫,
name就在内存偏移量+16的位置!”直接拿走,瞬间起飞。这就是传说中的 内联缓存(Inline Cache, IC) 的火种。AST 的每一次属性访问,都在收集运行时的类型情报,后续这些情报将直接驱动编译器在热路径上生成激进的机器码,从而把慢路径的“龟速查找”瞬间变成极速的“偏移量访问”。
-
-
函数调用
-
代码:
hero.attack(1, 2)函数调用,是片场最兴师动众的动作,它表示要临时搭建一个全新的分会场(新栈帧)。演员、道具、场地全得现成准备。
-
导演喊话流程:
- 找对象:
Ldar r0(先把hero拿到聚光灯下) - 找方法:
GetNamedProperty r0, [attack_index], [slot]->Star r1(把attack这个函数实体找出来,按在r1的椅子上备用) - 准备
this:Mov r0, r2(把hero作为隐形的this参数,塞进r2) - 准备参数:
LdaSmi [1]->Star r3,LdaSmi [2]->Star r4(把实参 1 和 2 依次在后面排好队) - 放大招:
CallProperty r1, r2, 2, [feedback_slot]
- 找对象:
-
细节:
导演在这句
CallProperty里,把格则定死了:r1是要执行的函数;r2是参数队伍的打头第一个(包含了隐形this,紧接着是r3, r4);2是参数队伍的真实长度。当这句指令开始执行时,引擎会立刻压入当前函数的界碑(Saved FP)和返回地址,SP 指针暴跌,一段全新的生命周期就此开启。
注意点:在运行时,这条指令背后还隐藏着不少的慢路径(Slow Paths)。比如
this的隐式装箱转换(严格模式与非严格模式的争斗)、遇到Proxy替身拦截、撞上Getter,或者处理剩余参数(Rest Parameters)。这些都会触发底层更复杂的 C++ 检查分支。但在常见的热路径上,反馈向量(Feedback Vector)和内联缓存(IC)依然能把大多数调用“快路径化”,让性能起飞。
-
-
对象字面量的创建
-
代码:
let hero = { name: '阿祖', skill: '收手吧' };在我们的想象中,很有可能是这样的:先
new Object(),再给它设name,再设skill。但是V8 导演又是轻蔑一笑:“图样图森破,你们太慢了!在我的片场,我们玩的就是高端局。”
-
导演喊话:
CreateObjectLiteral [boilerplate_index], [flags] -
细节(Boilerplate 样板):
导演在生成这段字节码的同时,已经在内存的 常量池(Constant Pool) 里,偷偷的做好了一个“阿祖半成品模型”。这个模型自带了分配好的内存空间、固定的隐藏类(Map),连
'阿祖'这几个字都提前填好了。当代码真正在运行、跑到这一行时,引擎根本不走繁琐的属性赋值逻辑,它直接去常量池,抓起那个半成品模型,嗖的一下,内存级别浅拷贝(Shallow Clone)。
速度极快,恐怖如斯。这就是为什么在 JS 里直接写对象字面量
{...},永远比new Object()再动态挂载属性要快得多的原因。记录员求知若渴的发问:
“导演,那如果字面量里有动态计算的属性怎么办?比如
{ [key]: 123 }?”导演皱眉道:“那没办法,克隆只能搞定静态的。遇到动态求值的初始化,引擎在做完浅拷贝后,依然需要在运行时追加记录额外的
StaKeyedProperty等指令,老老实实把动态算出来的值挂载上去。”
-
-
for(let)循环
在第一部分解析篇中,我们讲解了for循环的例子,分别对var 和 let 进行了详细的解析。
其中讲到为了应对闭包捕获每次迭代的状态,
for(let i=0...)会产生“影子变量”。但这只是一句逻辑概念。现在,我们要站在 V8 片场的监视器后面,亲眼看到这段代码 是如何生成的。说明:ECMAScript 规范仅要求语义上每次迭代要有独立的绑定(针对循环头的 let/const),但实现层面可以(并且通常会)通过逃逸分析、按需分配等优化手段,避免无意义的重度堆分配。
我们将以下面这段不怀好意的代码为例:
JavaScript
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 注意!闭包捕获了循环变量 i }在正式拍这场戏之前,导演看着手里的剧本,深吸了一口气,对全场喊道:“兄弟们,今天这场戏是硬仗。如果是
for(var),咱们在广场上挂一个叫i的大时钟,大家抬头看同一块表就行了。但今天是for(let),而且里面有闭包!”导演心里默默给自己打气:必须为每一次循环迭代,提供一个绝对独立的
i的绑定快照(Per-iteration Binding),否则,未来闭包执行时就会全部读到最终的那个值。”规范的 per-iteration 独立绑定语义,针对循环头部的
let/const声明,会为每次迭代创建独立的循环变量绑定快照;如果let/const声明在循环体内部,则为每次迭代独立的块级绑定。两种情况在语义上均保证了迭代间的空间隔离,仅在作用域的层级划分与底层实现逻辑上略有不同。1. 双层戏台
为了满足规范,V8 必须在图纸上画两层嵌套的作用域:
- 大本营(Loop Lexical Environment): 这是一个外层循环作用域,承担着控制循环进程的职责。
- 分会场(Per-iteration Environment): 每次进入循环体前,必须为本次迭代创建一个“临时别墅”。
现在,导演要拿着这两张图纸,把它们变成真实的指令。
再次注意:
文中出现的
CreateBlockContext等指令,均为表意清晰的 Ignition 示意性代码。请勿当作V8真实的指令名使用,真实 V8 版本的指令名和优化策略随时变化,如果需要确切的指令名及其他信息,请查阅最新的v8文档。2. 开拍
第一幕:建立外层环境与初始化
导演指挥场务:“按规范,先建立循环外层作用域!处理初始值
0,准备!”记录员敲下: 建立外层大本营,并将初始值分配进去。
第二幕:循环条件判定
导演看了一眼大本营里的
i,把它拿进聚光灯(Acc),准备和3比较。记录员敲下:
TestLessThan [3]->JumpIfFalse [Label_End](老规矩:先挖坑发地址卡!)第三幕:时空定格(建立独立绑定)
条件成立,准备进入循环体执行
setTimeout就在这时,导演突然发疯一般大喊一声:“stop!全体暂停!进入迭代环境生成协议!”
按规范,进入本次迭代前,必须为该迭代创建一个 per-iteration 绑定环境,并使用此时的控制值对本次迭代的绑定进行初始化。 (语义上等同于把当前值“拷贝”进新环境中。需要特别强调:在字节码生成阶段,只要 AST 上标记了迭代变量被循环内闭包捕获,生成器就会雷打不动地插入“创建迭代上下文”的指令。在解释器初期执行时,这笔昂贵的堆分配开销是 100% 会真实发生的;真正能把这笔开销抹除、避免不必要分配的,只有后续强势介入的优化编译器。)。
导演捂心含泪开始操作:“记录员!立刻给我写下新建专属临时别墅的指令,安排未来的解释器把大本营的当前值给我物理复印进去,使劲封住!”
记录员疯狂输出(极其昂贵的开销):
CreateBlockContext(申请堆内存,为第一轮循环分配独立环境记录)StaContextSlot <new_context>, [cloned_i](把值塞进新别墅里)第四幕:生产闭包,分发专属钥匙
新别墅建好了,里面的
cloned_i被定格在了0。导演挥手:“放
setTimeout进场!给我生成闭包!”记录员敲下:
CreateClosure [shared_function_info], [allocation_site]细节:
在这个闭包诞生的瞬间,导演塞给它的“上下文指针(Context Pointer)”,绝对不是外层大本营的指针,而是刚才那座锁死了
0的**“第一轮专属临时别墅”**的指针!第五幕:更新大本营,进入下一次轮回
循环体执行完毕。准备执行
i++。导演需要使用这轮迭代的值,或者外层控制的值,执行
++后,进入下一轮迭代。记录员敲下:
JumpLoop [Label_Start](向后跳跃!回到 第二幕 条件判定)3. V8 的抠门省钱黑科技
如果严格按照上面的流程,10000 次循环就会在堆内存里老老实实地砸出 10000 个 Context 别墅。
只要这些闭包还活着,在闭包存活期间,垃圾回收器(GC)就无法回收这些别墅,从而造成极大的堆分配开销和 GC 压力。
但 V8 绝不允许这种惨剧发生。
作为生成字节码的导演,其实是个非常死板的“规矩捍卫者”。 Parser 解析器进行静态词法分析时,只要在文本里发现闭包引用了
i,就会在剧本的 AST 树上给i盖上物理钢印:ContextAllocated。 导演看到这个钢印,就会毫不犹豫地喊出CreateBlockContext指令(必须分配在堆内存)。在画图纸(生成字节码)的阶段,导演会把这句昂贵的“建别墅”指令,死死地钉在循环体的开头。这就意味着,这张图纸已经注定了未来真正开始执行时,每一次循环都必须老老实实地去堆内存里砸出一座别墅。。那么,“抠门省钱”的黑科技是谁在搞?是后期特效师(TurboFan), 在 ECMAScript 规范只看结果的不良作风下,后期特效师会在代码跑热(Hot)之后强势介入,在生成最终的机器码前,施展真正的底层魔法:
逃逸分析与分配折叠(Escape Analysis & Allocation Folding) 特效师拥有上帝视角,他会进行极限的“逃逸分析”。如果在某些特殊场景下,他证明循环体内产生的闭包根本没有外泄(例如传给了内部不会保留引用的纯函数),他会在剪辑机器码时,直接把盖别墅的指令一刀剪掉(剥夺
i住进堆内存的权利),把它一脚踢回极速的栈槽(寄存器)里。没有任何堆分配,每次循环直接在寄存器里 ++ 覆盖。特别注意:有些“轻量级”优化(比如局部窥孔优化、反馈向量驱动的内联缓存)发生在字节码/解释器层面,但像上面讲的逃逸分析、分配折叠,这种跨流程的全局优化,通常由解释器(V8中是由Ignition)保证语义正确性,优化编译器(v8中是TurboFan)结合完整的运行时信息,才能靠谱的做出更激进的分配消除优化策略。解释器和优化编译器的分工边界会随V8版本迭代持续演进,并不是绝对固定。
总结:
这就是
for(let)的底层逻辑。 在语言规范和 AST 层面,它要求每一次迭代都像切片一样拥有独立的绑定(Per-iteration Binding),这完美解决了异步闭包的历史死结。但在引擎实现层面,这是一场**“守规矩的解释器(规范要求必定分配)”与“暴躁的优化编译器(千方百计消除分配)”**之间的疯狂博弈。只要解析器发现了闭包引用的文本痕迹,沉重的 Context 堆分配在初期就必然会发生;但随着代码的预热,优化编译器会用非常强悍的逃逸分析能力,将那些“被标记为捕获但实际未逃逸”的别墅全部拆除。
那么我们再看下面的一个例子:
假设剧本变成了这样(注意里面的 if):
JavaScript
for (let i = 0; i < 10000; i++) { // 只有在第 5000 次的时候,才产生闭包! if (i === 5000) { setTimeout(() => console.log(i)); } }如果只看表面,你可能会觉得:前 5000 次没有执行 setTimeout,所以根本不需要建别墅对吧? 错! 像前面说的 解析器是静态扫描文本的。他只要看到大括号里有
() => console.log(i)这行字,就会给i打上必须下放堆内存的钢印。导演看到钢印,就会在每一次循环的开头死板地生成建别墅的指令。当字节码交给解释器真正运行时,解释器就会像个傻子一样,哐哐哐地在堆里砸出 10000 座别墅!真正的奇迹,发生在后期特效师(TurboFan)介入之后。当这段循环执行了数千次,变得滚烫(Hot)时,特效师 TurboFan 通过栈上替换(OSR)登场了。他并不是未卜先知的神仙,而是一个极端依赖“历史情报”的超级赌徒。
前数千次的狂欢(推测性优化): 特效师翻看 Feedback Vector 的记录,发现前几千次循环根本没有进过 if 分支。于是他大胆下注:“我赌它以后永远不会进!” 他在生成的机器码中,把 CreateBlockContext 指令彻底抹除,让 i 就在极速的寄存器里原地覆盖,性能和 for(var) 一模一样。同时,为了防止意外,他在分支入口预埋了“守卫(Guard)”。
第 5000 次的大翻车(Deoptimization 去优化): 极速机器码一路狂飙,直到 i === 5000 时,if 条件突然成立,闭包诞生了!此时,特效师预埋的守卫被触发,检测到当前进入了之前从未执行过的闭包分支,不符合优化的推测前提。V8 主动触发去优化(Deoptimization),特效师生成的优化机器码会被标记为无效,后续不再执行。执行权被强行且平滑地交还给负责兜底的 Ignition 解释器。解释器烦躁地接手,按照原剧本,老老实实地在堆里砸出一座别墅,把 5000 封印进去,交给了闭包。
后 5000 次的重新定调: 经过这次翻车,负责运行的 Ignition 在情报小本 Feedback Vector 上记下了重重的一笔(类型反馈发生变化)。如果这段循环后续再次触发优化编译,特效师 TurboFan 就会学乖,基于更完整的执行信息,他在新的机器码中不再敢随意抹除别墅的分配了。
在这个真实的例子中,我们看到了 V8 现代编译流水线的分工艺术:为了极致的性能,V8 敢于基于历史经验进行激进的“推测性优化”,哪怕代价是偶尔的“翻车与去优化”。
- 下面我们来看一段真实的字节码
我们依然使用上面那个必定触发“独立绑定”的剧本:
JavaScript
function test() { for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i)); // 警戒,产生逃逸闭包! } } test();下面的字节码基于 Node.js v20.x 的真实打印结果简化而来。去除了极度冗余的环境代码,保留了核心的准确的流转逻辑。在我使用的环境中,偏移值都是精确的。
// 函数栈帧:r0 是循环大本营 Context 中 i 的【寄存器映射】;r1 存储当前的临时别墅环境。 // --- 【第一幕:大本营起跑,本体 i 初始化】 --- 0x00: LdaZero // 拿取数字 0 放进聚光灯(累加器 Acc) 0x01: Star r0 // 把 0 存入虚拟寄存器 r0(这是外层循环 i 的极速映射) // --- 【第二幕:循环条件判定 i < 3】 --- 0x03: Ldar r0 // 把 r0 的 i 拿进聚光灯 (Load Accumulator from Register) 0x05: TestLessThanSmi [3], [0] // 比较 i 和常量 3。[0] 是给情报小本(反馈向量)的索引 0x08: JumpIfFalse [0x2c] // i >= 3 时,直接跳转到大结局 0x2c 行 // --- 【第三幕:进入分会场!时空隔离与抄写】 --- // 这里是严格的“新建 -> 暂存 -> 压栈”三部曲 0x0a: CreateBlockContext [0] // 导演指令:按 [0] 号图纸新建临时别墅,放到聚光灯(Acc)里 0x0c: Star r1 // 把聚光灯里的“新别墅”暂存到 r1 0x0d: PushContext r1 // 导演:压栈!全体环境切入 r1 临时别墅! 0x0f: Ldar r0 // 把大本营的 i(在 r0)拿过来 0x11: StaCurrentContextSlot [2] // 抄写进临时别墅的 2 号房间(闭包马上要捕获它) // --- 【第四幕:放闭包进场,拿走临时别墅的钥匙】 --- 0x13: CreateClosure [1], [0] // 生成闭包,物理嵌入当前的临时 Context 指针! // 闭包不能直接上场,必须先去板凳上排队! 0x15: Star r2 // 场务:赶紧把聚光灯里的闭包暂存到空闲寄存器 r2 备用 0x17: LdaGlobal [2], [0] // 加载全局的 setTimeout 到聚光灯 0x19: CallUndefinedReceiver1 r2, [0] // 执行 setTimeout!把 r2 里的闭包作为参数喂进去! // --- 【第五幕:防范“内部篡改”与撤出分会场】 --- // 为什么要先读回来?因为闭包可能在内部写了 i = 100! 0x1d: LdaCurrentContextSlot [2] // 还没撤出临时别墅!赶紧把 2 号房间最新的 i 读进聚光灯! 0x1f: Star r0 // 强行覆盖大本营的映射寄存器 r0!保证内部修改能同步到外层! 0x21: PopContext r1 // 读完收工!导演大喊:撤出临时别墅,恢复大本营环境! // --- 【第六幕:大本营里的 i++】 --- 0x23: Inc // i++,聚光灯里的值自增 0x24: Star r0 // 自增后的值写回 r0 0x26: JumpLoop [0x03], [0] // 引擎轰鸣,向后跳回 0x03 行条件判断![0] 是循环深度标记 // --- 【大结局:跳出循环,杀青】 --- 0x2c: LdaUndefined // 默认返回 undefined 0x2d: Return // 函数彻底结束通过阅读上面的精确到偏移值的字节码,我们需要掌握下面的要点:
要点一:累加器(Acc)流转和传参规律
在真实的 V8 物理世界里,聚光灯(累加器 Acc)是唯一的中央枢纽。
指令
CreateBlockContext只能把建好的别墅放在聚光灯下。必须补上一句极其关键的Star r1,把别墅搬到r1寄存器暂存,才能执行后续的PushContext r1。同样在
0x13到0x19行生成闭包并传参的过程:闭包诞生在 Acc 里,它绝不能直接被Call指令吃掉。场务必须用Star r2把闭包挪到独立的寄存器里暂存,然后再把setTimeout请进 Acc,最后才能把r2作为参数传进去。如果不这么干,内存指针将直接错乱。要点二:“本体 i”的双重身份和 Ldar 的身份
为了极致的性能,大本营里的
i虽然一旦被闭包逃逸就会在堆内存(外层 Loop Context)中安家,但在执行高频的循环判断(i < 3)和自增(i++)时,V8 会在栈帧上为它分配一个r0寄存器作为极速映射(Shadow)。Ignition 的虚拟寄存器本质就是函数栈帧上的内存槽位 Frame Slots,另外关于槽位/寄存器 这些称呼上的异同,可以看前一篇 ignition上 中的内容。同时,我们还要知道,
Ldar它的真身是 Load Accumulator from Register(从虚拟寄存器加载到聚光灯下),它是极速的寄存器(栈)读取,而真正把数据写进堆内存别墅的,是那句StaCurrentContextSlot。要点三:“图纸”与“2 号房间”的秘密
在
CreateBlockContext [0]里有个神秘的[0]。这其实是常量池里
ScopeInfo(图纸)的索引。未来的解释器是严格按照这张图纸来盖别墅的。而为什么
i总是放在StaCurrentContextSlot [2](示例中的2号房间)?在 V8 的 BlockContext 内存布局中,0 号房间永远预留给ScopeInfo图纸本身,1 号房间留给指向上一层作用域的指针(Previous Context),真正的业务变量,只能老老实实从 2 号房间开始住。并且,在
CreateClosure诞生的瞬间,引擎底层会把当前的执行上下文指针,像打钢印一样嵌入到闭包对象的内部内存中。这就是闭包“拿走钥匙”的真实过程。关于常量池,已经提过好几次了,后面会详细学习。
要点四:“反向读回”机制
假设在循环体里,某个演员突然脑子一抽,写了一句
i = 100。根据 ECMAScript 规范,下一次循环的
i必须受这次修改的影响,从101开始!如果本体i只存在大本营的r0里,外层怎么知道里面的演员搞了破坏?看
0x1d和0x1f这两行神级指令,在撤出临时别墅之前,这套指令会强制要求解释器立刻把别墅里最新的i读出来,强行同步覆盖掉大本营的映射寄存器r0,哪怕里面把天捅破了,大本营也能瞬间同步,然后再执行0x23的Inc(i++)。当然,如果 V8 发现你的循环体里老老实实,根本没有去修改
i。那么在后续的 TurboFan 机器码优化阶段,这个极其严谨的“反向读回”指令会被优化器判定为“废戏”,直接一刀剪掉。关于TurboFan的内容,在后面将会详细学习。要点五:消失的
CloneContext和 GC 的滞后拆迁在早期 V8(5.9 版本之前的 Crankshaft 编译器时代),底层的环境切换确实又笨又慢,每次都用极其昂贵的
CloneContext去暴力克隆旧环境。这也是很多旧教程里说for(let)是“每次克隆上下文”的来源。在这段真实的现代 V8 字节码中,没有出现所谓的“克隆(Clone)”指令。
在如今的新时代,V8 的字节码生成策略与执行机制进化了。正如我们在字节码里看到的,他不再用笨重的克隆,而是改成了**“新建临时别墅 -> 抄写初始值 -> 用完反向读回 -> 撤出别墅”**的极其丝滑的流水线。
最后还要注意一点:
0x21行的PopContext只是导演喊了“撤出”,关上了别墅的门,并不是当场炸毁别墅。只要闭包还捏着嵌入的上下文指针,这座别墅就会静静地躺在堆内存里。直到这个闭包彻底消亡,这座曾经的临时别墅才会被垃圾回收器(GC)无情碾碎。-
作为字节码生成部分的最后一个例子,我们依旧使用上面用过的那个例子,进行一次导演的深度漫游。
我们最后一次强调注意:
社区通用的ESTree 规范定义了标准的语法结构 AST,其最终的形成东东,是不包含作用域信息的纯语法结构AST。 如果需要作用域信息, 需通过第三方工具,在其基础上进行静态分析额外生成,而这些作用域信息的表示形式/格式,由第三方工具或者是特定需求目的来决定,并不包括在规范之内。
V8 使用私有内部 AST,其解析过程会直接构建作用域相关信息,但该内部格式高度耦合于编译器实现,且不对外公开。我们可以将这些内部ast的数据 提取 抽象出来,形成一份我们在了解学习中可以使用的近似的示意性的结构,在这里我们使用json格式来表示。
在通常学习时,不管是estree规范的ast 还是v8私有的内部ast,我们都是用简化的伪 AST(JSON 格式),并通过
[[...]]等标记将作用域信息挂载到对应节点上,以便清晰理解语法与作用域的关系,这是通常的做法,大家以后野可以这样使用。
JSON
{ "type": "ForStatement", "[[Scope]]": { "type": "LoopLexicalEnvironment", "description": "第一层户口本:外层大本营", "[[Bindings]]": { "i": { "is_captured": false, "allocation": "Register (r0)" // 导演笔记:大本营的 i 没被直接捕获,分配极速寄存器 r0! } } }, "init": { "type": "VariableDeclaration", "kind": "let", "declarations": [ { "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "i" }, "init": { "type": "Literal", "value": 0 } } ] }, "test": { "type": "BinaryExpression", "operator": "<", "left": { "type": "Identifier", "name": "i", "[[VariableProxy]]": "ResolvedTo(LoopLexicalEnvironment.i) -> r0" }, "right": { "type": "Literal", "value": 3 } }, "body": { "type": "BlockStatement", "[[Scope]]": { "type": "IterationLexicalEnvironment", "description": "第二层户口本:每次迭代的临时分会场", "[[HasClosureEscape]]": true, // 红色警报:内部有闭包逃逸! "[[Bindings]]": { "i": { "is_captured": true, "allocation": "ContextSlot (2)" // 导演笔记:分会场的 i 被逃逸闭包盯上了,必须下放堆内存 2 号房间! } } }, "body": [ { "type": "ExpressionStatement", "expression": { "type": "CallExpression", "callee": { "type": "Identifier", "name": "setTimeout" }, "arguments": [ { "type": "ArrowFunctionExpression", "[[Environment]]": "Pending... (等待运行时打入物理钢印,指向当前的 IterationLexicalEnvironment)", "body": { "type": "CallExpression", "callee": { /* console.log */ }, "arguments": [ { "type": "Identifier", "name": "i", "[[VariableProxy]]": "ResolvedTo(IterationLexicalEnvironment.i) -> ContextSlot(2)" } ] } } ] } } ] }, "update": { "type": "UpdateExpression", "operator": "++", "argument": { "type": "Identifier", "name": "i", "[[VariableProxy]]": "ResolvedTo(LoopLexicalEnvironment.i) -> r0" } } }现在,启动
BytecodeGenerator.VisitForStatement(node)方法。我们跟着导演的脚步,一步步开始吧。步骤 1: 遍历
init节点-
到达点:
ForStatement.init。 -
导演决策: 这是一个
let i = 0。导演查阅了外层[[Scope]],发现大本营的i被分配在寄存器r0。数字0是一个Literal(字面量)。 -
对应动作: 把字面量读入聚光灯,存入对应寄存器。
-
导演喊叫(字节码):
Plaintext
0x00: LdaZero // 处理 Literal(0) 0x01: Star r0 // 处理赋值,写入 LoopLexicalEnvironment.i (r0)
步骤 2: 遍历
test节点-
到达点:
ForStatement.test(i < 3)。 -
导演决策: 这是一个二元表达式
<。- 查左儿子的
[[VariableProxy]],指向大本营的r0。 - 查右儿子的字面量,是
3。
- 查左儿子的
-
对应动作: 生成比较指令。如果条件为假,就要跳过整个循环(挖坑留跳转地址)。
-
导演喊叫(字节码):
Plaintext
0x03: Ldar r0 // 处理左儿子 i 0x05: TestLessThanSmi [3], [0] // 带着右儿子 3 执行比较 0x08: JumpIfFalse [0x2c] // 不成立则跳转(坑位地址后期回填)
步骤 3:准备进入
body节点-
到达点: 准备深入
ForStatement.body,但被大括号拦截! -
导演决策 关键点: 导演刚摸到
BlockStatement,报警器狂响!他盯着[[Scope]]里的两个要他老命的属性:[[HasClosureEscape]]: true(有闭包逃逸)[[Bindings]].i.allocation: ContextSlot (2)(需要堆内存)
-
导演脑内
if逻辑触发: “既然规范要求独立绑定,且发生了逃逸,我必须在这里拦截,强行插入一段上下文切换与抄写的代码!” -
对应动作 前置拦截: 新建堆内存别墅,压栈,并从大本营把值抄进来。
-
导演喊叫(字节码):
Plaintext
0x0a: CreateBlockContext [0] // 基于 ScopeInfo[0] 图纸建别墅 0x0c: Star r1 // 暂存别墅到 r1 0x0d: PushContext r1 // 全体环境强行切入别墅! 0x0f: Ldar r0 // 拿出大本营的 r0 0x11: StaCurrentContextSlot [2] // 抄写进临时别墅的 2 号房间!
步骤 4:遍历
ArrowFunctionExpression-
到达点:
body内部的setTimeout参数节点。 -
导演决策: 遇到函数声明/表达式。导演看到它身上带着虚线的
[[Environment]]内部槽,等待实体化。 -
对应动作: 生成闭包对象,并强制要求传参前暂存。
-
导演喊叫(字节码):
Plaintext
0x13: CreateClosure [1], [0] // 生成怪物,物理嵌入当前的 Context 指针 0x15: Star r2 // 老实的去板凳 r2 排队 0x17: LdaGlobal [2], [0] // 加载 setTimeout 0x19: CallUndefinedReceiver1 r2, [0] // 喂入闭包并执行
步骤 5: 退出
body节点-
到达点:
body的大括号右侧}。子节点全部遍历完毕,准备向上返回。 -
导演决策 非常关键: 导演准备退出,但他再次看了一眼
BlockStatement上的[[Scope]]。 -
导演脑内
if逻辑触发: “刚才是因为你亮了红灯,我才压了栈(PushContext)。现在我要走了,根据 C++ 栈平衡原则,我必须弹栈(PopContext)。并且,为了防止里面的坏人修改了i,规范要求我必须把 2 号房间的值同步回大本营的r0!” -
对应动作 后置拦截: 读回最新值,同步寄存器,最后弹栈。
-
导演喊叫(字节码):
Plaintext
0x1d: LdaCurrentContextSlot [2] // 把刚才别墅 2 号房间的 i 读出来 0x1f: Star r0 // 强行覆盖大本营的映射寄存器 r0! 0x21: PopContext r1 // 完美收工,撤出别墅!
步骤 6: 遍历
update节点-
到达点:
ForStatement.update(i++)。此时环境已经回到了大本营。 -
导演决策: 这是一个
UpdateExpression(++)。查[[VariableProxy]],指向r0。 -
对应动作: 寄存器自增,并强行跳转回
test节点。 -
导演喊叫(字节码):
Plaintext
0x23: Inc // 聚光灯里的值自增 0x24: Star r0 // 写回 r0 0x26: JumpLoop [0x03], [0] // 跳回 0x03 行继续下一轮轮回
这就是完整的、毫无删减的“一镜到底”。从上面内容我们可以深刻理解,之前我们所说的,(通常情况下) AST是字节码生成的唯一来源。
如果有疑问:“V8 是怎么知道要把
i存进 Context 的?是怎么知道要反向读回的?”就可以看这份 AST ,里面的
[[Scope]]和[[HasClosureEscape]]耀眼生光:“字节码生成器(BytecodeGenerator)就是一个极其死板的执行机器。是 AST 树上挂载的这两层
[[Scope]]物理钢印,在它的递归函数里触发了那几个if拦截器,才有了这一整套时空隔离、内存抄写、反向同步的底层魔法”。 -
5. 小结
指令的补充说明
- 比较指令:为了节省操作码空间,现代 V8 并未为所有的二元比较操作都提供 Smi(立即数)快捷版本。它仅对高频操作(如 TestLessThanSmi)做了指令扩展。不同 V8 版本的指令集存在差异,在无法优化的通用场景下,依然以标准的寄存器比较指令为主。
- Call 系列指令:在前面讲解中导演喊话用了 CallProperty,而在真实的字节码中变成了 CallUndefinedReceiver1。这是 Call 系列指令的极速快捷版本,专门针对 this 为 undefined(没有显式调用者)的场景。末尾的数字 1 代表它只接收 1 个真实参数,这类带参数个数后缀的指令,是 V8 为了减少运行时参数检查开销做的高频场景快路径优化。
划定阶段界限:编译期 和 运行期
在 V8 的世界里,生成字节码和执行字节码是完全割裂的两个阶段。V8 采用懒编译(Lazy Compilation)机制,以避免页面启动时全量编译所有函数造成的性能损耗。
编译期
发生在函数第一次被调用、V8 触发全量解析编译的时候。这个阶段 JS 业务代码的逻辑绝对不会执行,仅会生成编译相关的静态内存对象,业务层面的变量、堆对象均未初始化。
- 导演(BytecodeGenerator):负责遍历 AST 树,掌控宏观的指令流向。
- 场务(BytecodeRegisterAllocator):负责静态的空间规划。他只在生成字节码的时候存在,负责精打细算地分配虚拟寄存器。当他看到 AST 树上带有逃逸闭包的节点时,他只是在图纸上规划并留下标记,并不会去物理堆内存里申请空间。
- 记录员(BytecodeArrayBuilder):精准记录指令与偏移量。发现导演抽风会触发窥孔优化,消除相邻指令的冗余操作。
- 阶段成果:核心产出物是三个静态对象:写满指令的 BytecodeArray(字节码序列)、预留了槽位但内容空白的 Feedback Vector(反馈向量 / 情报小本),以及存储了字符串、ScopeInfo、对象样板等元信息的常量池(Constant Pool)。
运行期
当函数真正开始执行时,这个阶段的主角是执行引擎。
- Ignition 解释器:它逐行读取字节码,当读到 CreateBlockContext 这类指令时,它会向底层内存管理器申请真实的堆内存。真正在物理内存里“盖别墅”的,是运行期的解释器或优化的机器码。
核心纽带:反馈向量 (Feedback Vector)
反馈向量里收集的类型信息,是后续 TurboFan 生成激进机器码的核心依据。
- 编译期占坑:导演生成字节码时,只在指令里留下一个槽位索引号(如 [0]),告诉解释器预留空白页。
- 运行期收集:当解释器在运行期摸到真实的对象时,查出其隐藏类(Map),然后在对应的空白页郑重记录下对象的形状与偏移量情报。
Deoptimization(去优化)的复盘
我们用带有逃逸闭包的循环,最后一次复盘 V8 的阶段分工:
- 编译期:场务老实画图纸,导演死板地在字节码里写下 CreateBlockContext 指令。
- 优化狂飙:循环执行数千次变热后,TurboFan 通过栈上替换(OSR)接管。他发现之前从未进入过闭包分支,于是激进下注,抹除建别墅的动作,直接在寄存器里跑,并预埋了守卫(Guard)。
- 翻车瞬间:条件突然成立,闭包诞生。机器码中的守卫检测到异常,主动触发去优化(Deoptimization)。
- 踢回解释器:优化机器码被标记为无效,执行权被强行交还给负责兜底的 Ignition 解释器。
- 兜底执行:解释器临危受命,翻开原始剧本,老老实实向内存管理器申请空间,盖出别墅交给闭包。同时,新的分支执行信息被记录进反馈向量,确保下一次优化编译更加安全。
这部分内容,从春节前写到春节后,码字是个体力活。
下一篇: Ignition解释器(下) 码字中。。。
发表于: 掘金社区
发表于: csdn
发表于: 博客园
码字不易 知识脉络的梳理更是不易 ,但是知识的传播更重要,
欢迎转载,请保持全文完整。
谢绝片段摘录。