找回密码
 立即注册
首页 业界区 业界 V8引擎 精品漫游指南--Ignition篇(中) AST详解 字节码的 ...

V8引擎 精品漫游指南--Ignition篇(中) AST详解 字节码的生成

瞧厨 4 天前
目录

  • 二. Ignition解释器(中)

    • 1. 再说AST
    • 2. AST学习专场
    • 3. AST与作用域
    • 4. 字节码的生成



        • 1. 双层戏台
        • 2. 开拍
        • 3. V8 的抠门省钱黑科技
        • 步骤 1: 遍历 init 节点
        • 步骤 2:  遍历 test 节点
        • 步骤 3:准备进入 body 节点
        • 步骤 4:遍历 ArrowFunctionExpression
        • 步骤 5: 退出 body 节点
        • 步骤 6: 遍历 update 节点


    • 5. 小结


二. 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,它们都是一个节点。
  1.   {
  2.     "type": "Identifier",       // 我是谁:节点的类型
  3.     "start": 0, "end": 1,       // 我在哪:字符索引范围(用于高亮)
  4.     "loc": {                    // 我的精准定位:二维坐标
  5.       "start": { "line": 1, "column": 0 },
  6.       "end": { "line": 1, "column": 1 }
  7.     },
  8.     "range": [0, 1]             // 另一种位置表示法
  9.   }
  10.   
  11.   其中的 loc 是位置的定位,也是比较重要的,比如,当 V8 抛出 Uncaught ReferenceError: a is not defined at line 1 时,靠的就是这里保留的坐标信息。
  12.   
复制代码

  • 变量声明:VariableDeclaration
    代码: var a = 1;
    我们可能认为的 AST样子: 一个节点,名字叫 a,值是 1。
    而实际的 AST: 三层嵌套
    JSON
  1.   {
  2.     "type": "VariableDeclaration",      // 第一层:声明语句
  3.     "kind": "var",                      // 也可能是 let/const
  4.     "declarations": [                   // 第二层:数组
  5.       {
  6.         "type": "VariableDeclarator",   // 第三层:声明符
  7.         "id": { "type": "Identifier", "name": "a" },
  8.         "init": { "type": "Literal", "value": 1 }
  9.       }
  10.     ]
  11.   }
复制代码
为什么要这么复杂? 因为 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
  1. var a = 1;
复制代码
这就相当于:“一列绿皮车 (var),只挂了 1 节车厢,车厢里坐着 a,带着货物 1。”
  1. {
  2.   // 第一层:火车头 (决定是 var)
  3.   "type": "VariableDeclaration",
  4.   "kind": "var",
  5.   "declarations": [
  6.     // 第二层:车厢 (数组中只有 1 节)
  7.     {
  8.       "type": "VariableDeclarator",
  9.       // 第三层:货物 (Id 和 Init)
  10.       "id": {
  11.         "type": "Identifier",
  12.         "name": "a"
  13.       },
  14.       "init": {
  15.         "type": "Literal",
  16.         "value": 1
  17.       }
  18.     }
  19.   ]
  20. }
复制代码
为什么要三层? 虽然只有一节车厢,但它依然是一列“火车”。你不能因为只有一节车厢,就把“火车头”和“车厢”焊死在一起。万一下一站要挂新车厢呢? 这就是 AST 设计的 通用性 , 哪怕只有一个变量,也要按列表的格式来存。
例二:超长火车
代码:
JavaScript
  1. let a = 1, b = 2, c = 3;
复制代码
  1. {
  2.   // 第一层:火车头 (决定大家都是 let)
  3.   "type": "VariableDeclaration",
  4.   "kind": "let",
  5.   "declarations": [
  6.     // 第二层:车厢列表 (数组里有 3 个对象)
  7.    
  8.     // 车厢 A
  9.     {
  10.       "type": "VariableDeclarator",
  11.       "id": { "type": "Identifier", "name": "a" },
  12.       "init": { "type": "Literal", "value": 1 }
  13.     },
  14.     // 车厢 B
  15.     {
  16.       "type": "VariableDeclarator",
  17.       "id": { "type": "Identifier", "name": "b" },
  18.       "init": { "type": "Literal", "value": 2 }
  19.     },
  20.     // 车厢 C
  21.     {
  22.       "type": "VariableDeclarator",
  23.       "id": { "type": "Identifier", "name": "c" },
  24.       "init": { "type": "Literal", "value": 3 }
  25.     }
  26.   ]
  27. }
复制代码
AST 三层结构

  • 第一层 (火车头): VariableDeclaration { kind: "let" }

    • 后面挂的所有车厢,全部按 let 的规则办事(不能重复声明,有块级作用域)

  • 第二层 (车厢列表): declarations: [ 车厢A, 车厢B, 车厢C ]

    • 这里是一个数组。

  • 第三层 (各自的货物):

    • 车厢A: 我叫 a,我有值 1。
    • 车厢B: 我叫 b,我有值 2。
    • 车厢C: 我叫 c,我有值 3。

例三:半空半满的火车
代码:
JavaScript
  1. let x, y = 10;
复制代码
  1. {
  2.   "type": "VariableDeclaration",
  3.   "kind": "let",
  4.   "declarations": [
  5.     // 车厢 1:x (没装货)
  6.     {
  7.       "type": "VariableDeclarator",
  8.       "id": {
  9.         "type": "Identifier",
  10.         "name": "x"
  11.       },
  12.       "init": null  // 这里要注意,木有初始值,就是 null
  13.     },
  14.     // 车厢 2:y (装了 10)
  15.     {
  16.       "type": "VariableDeclarator",
  17.       "id": {
  18.         "type": "Identifier",
  19.         "name": "y"
  20.       },
  21.       "init": {
  22.         "type": "Literal",
  23.         "value": 10
  24.       }
  25.     }
  26.   ]
  27. }
复制代码
这行代码最能体现 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
  1. const { name, age } = person;
复制代码
  1. {
  2.   "type": "VariableDeclaration",
  3.   "kind": "const",
  4.   "declarations": [
  5.     {
  6.       "type": "VariableDeclarator",
  7.       
  8.       // 第三层左边 (id):这是一个 ObjectPattern (对象模式)
  9.       "id": {
  10.         "type": "ObjectPattern",
  11.         "properties": [
  12.           // 解构里的 name
  13.           {
  14.             "type": "Property",
  15.             "key": { "type": "Identifier", "name": "name" },
  16.             "value": { "type": "Identifier", "name": "name" },
  17.             "shorthand": true, // 因为是简写 { name }
  18.             "kind": "init"
  19.           },
  20.           // 解构里的 age
  21.           {
  22.             "type": "Property",
  23.             "key": { "type": "Identifier", "name": "age" },
  24.             "value": { "type": "Identifier", "name": "age" },
  25.             "shorthand": true,
  26.             "kind": "init"
  27.           }
  28.         ]
  29.       },
  30.       
  31.       // 第三层右边 (init):就是一个普通的变量引用
  32.       "init": {
  33.         "type": "Identifier",
  34.         "name": "person"
  35.       }
  36.     }
  37.   ]
  38. }
复制代码
这是es6中重点,解构赋值,这里没有简单的变量名,左边是一个 模式 (Pattern)
AST 三层结构是如何工作的?

  • 第一层 (火车头): const。

    • 所有声明的变量都不能修改

  • 第二层 (车厢): 只有 1 节车厢。
  • 第三层 (货物  重点在这里):

    • 左边 (id): 这次坐的不是一个人,是一个 结构体

      • 类型是 ObjectPattern (对象模式)。
      • 里面包含属性 name 和 age。

    • 右边 (init): 变量 person。

如果没有 Declarator 这一层来承载左边的 id,我们是无法描述 { name, age } 这种复杂的解构语法的, AST 的灵活性就在于:第三层的 id 位置,不仅可以放简单的 Identifier (a),还可以放复杂的 ObjectPattern ({a,b})。
例五:火车被打包了
代码:
JavaScript
  1. export var a = 1;
复制代码
这个也是常见常用的形式,var a = 1 既是一个声明,又是模块导出的内容。
  1. {
  2.   // 最外层大箱子:导出声明
  3.   "type": "ExportNamedDeclaration",
  4.   "specifiers": [],
  5.   "source": null,
  6.   
  7.   // 核心内容:把刚才的整列“火车”塞进 declaration 属性里
  8.   "declaration": {
  9.     "type": "VariableDeclaration", // 火车头
  10.     "kind": "var",
  11.     "declarations": [
  12.       {
  13.         "type": "VariableDeclarator", // 车厢
  14.         "id": {
  15.           "type": "Identifier",
  16.           "name": "a"
  17.         },
  18.         "init": {
  19.           "type": "Literal",
  20.           "value": 1
  21.         }
  22.       }
  23.     ]
  24.   }
  25. }
复制代码
正因为 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。在这深一层里,它构建出了一个完整的 乘法节点
  1.     {
  2.                            "type": "BinaryExpression",
  3.                            "operator": "*",
  4.                            "left": { "value": 2 },
  5.                            "right": { "value": 3 }
  6.     }
  7.    
  8.                             这个就不用说了吧,很简单的描述。
复制代码
等这个乘法节点构建完毕,解析器函数 返回 (Return)
返回给谁呢?返回给上一层那个还在苦苦等待“右手”的 + 号。

  • 完工了: 于是,那个乘法节点,作为一个完整的整体,被塞进了加法节点的 right 属性里。
  1.      {
  2.        "type": "BinaryExpression",
  3.        "operator": "+",  // 根节点,加法,最后执行
  4.        "left": {
  5.          "type": "Literal",
  6.          "value": 1
  7.        },
  8.        "right": {
  9.          // 这是重点:right 属性不是一个简单的数字,而是一个完整的“对象”
  10.          // 这就是“嵌套”。解释器必须先把这个对象“解开”算出结果,才能配合左边的 1            做加法。
  11.          "type": "BinaryExpression",
  12.          "operator": "*",  // 子节点,乘法,被包裹在里面,深一度,先执行
  13.          "left": { "type": "Literal", "value": 2 },
  14.          "right": { "type": "Literal", "value": 3 }
  15.        }
  16.      }
复制代码
在这个嵌套结构中:

  • 外层(加法)依赖内层(乘法):
    根节点 + 的 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.        {
  2.          // 1. 根节点 BinaryExpression (*)
  3.          // 为什么根节点是乘法?
  4.          // 因为从逻辑上讲,最后一步操作是“某数乘以3”。
  5.          // 只有把左边的 (1+2) 算完了,才能执行这最后一步。
  6.          // 所以 * 站在了金字塔的顶端,最后被执行。
  7.          "type": "BinaryExpression",
  8.          "operator": "*",
  9.       
  10.          // 2. 左子树 left
  11.          // 这里是重点:这里的 left 不是一个简单的数字,而是一个庞大的“对象”。
  12.          // 这就是括号的作用,它把 "1+2" 打包成了一个整体,扔给了乘法的左边。
  13.          "left": {
  14.            // 内层节点 加法 (+)
  15.            // 此时,加法被“降级”了。它不再是根,它是乘法的一个“零件”。
  16.            // 根据“越深越先执行”的规则,这个节点必须优先计算。
  17.            "type": "BinaryExpression",
  18.            "operator": "+",
  19.       
  20.            // 内层左叶子 1
  21.            "left": {
  22.              "type": "Literal",
  23.              "value": 1
  24.            },
  25.       
  26.            // 内层右叶子 2
  27.            "right": {
  28.              "type": "Literal",
  29.              "value": 2
  30.            }
  31.          },
  32.       
  33.          // 3. 右子树 right
  34.          // 乘法的右边很简单,就是数字 3。
  35.          "right": {
  36.            "type": "Literal",
  37.            "value": 3
  38.          }
  39.        }
复制代码

  • 上面是解析以后 生成了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

  1.   {
  2.     "type": "LogicalExpression", // 注意类型:逻辑表达式
  3.     "operator": "||",            // 根节点 逻辑或 (最后执行)
  4.     "left": {
  5.       "type": "Identifier",
  6.       "name": "a"
  7.     },
  8.     "right": {
  9.       // 右子树 逻辑与
  10.       // 因为 && 优先级高,所以它被打包成了一个整体,作为 || 的右操作数
  11.       // 这体现了 AST 的 深度优先 原则
  12.       "type": "LogicalExpression",
  13.       "operator": "&&",          // 子节点 逻辑与 (先结合)
  14.       "left": { "type": "Identifier", "name": "b" },
  15.       "right": { "type": "Identifier", "name": "c" }
  16.     }
  17.   }
复制代码
这个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
  1.   {
  2.     // 根节点 第二个加号 (+)
  3.     // 它的右手是 c。左手是谁?是前面算完的结果。
  4.     "type": "BinaryExpression",
  5.     "operator": "+",
  6.    
  7.     "left": {
  8.       // 左子树 第一个加号 (+)
  9.       // 被埋在了下面,深一度,先执行。
  10.       "type": "BinaryExpression",
  11.       "operator": "+",
  12.       "left": { "type": "Identifier", "name": "a" },
  13.       "right": { "type": "Identifier", "name": "b" }
  14.     },
  15.    
  16.     "right": {
  17.       "type": "Identifier",
  18.       "name": "c"
  19.     }
  20.   }
复制代码
我们依旧简单预习一下,看看生成器视角:

  • 站在根节点(第二个 +)。
  • 先去左边:下沉到内层,计算 a + b。
  • 拿到结果后,回到根节点,再和 c 相加。
    左结合 = 树向左歪 = 先算左边。
例五:右结合  a=b=c
a = b = c 这个就不一样了。把 c 赋值给 b,再把结果赋值给 a。等价于 a = (b = c)。
AST 长什么样? 它是一棵 “向右倾斜” 的树。
JSON
  1.   {
  2.     // 根节点 第一个等号 (=)
  3.     // 左手是 a。右手是谁?是后面那一坨赋值的结果。
  4.     "type": "AssignmentExpression",
  5.     "operator": "=",
  6.    
  7.     "left": {
  8.       "type": "Identifier",
  9.       "name": "a"
  10.     },
  11.    
  12.     "right": {
  13.       // 右子树 第二个等号 (=)
  14.       // 被埋在了右边下面,深一度,先执行。
  15.       "type": "AssignmentExpression",
  16.       "operator": "=",
  17.       "left": { "type": "Identifier", "name": "b" },
  18.       "right": { "type": "Identifier", "name": "c" }
  19.     }
  20.   }
复制代码
生成器角度来看:

  • 站在根节点(第一个 =)。
  • 先处理右边(赋值语句的特殊性,右边是值):下沉到内层,计算 b = c。
  • 拿到结果(即 c 的值),回到根节点,赋给 a。
    右结合 = 树向右歪 = 先算右边。
例六:连续赋值 a=b=1
代码: a = b = 1
js中的赋值运算 (=) 是 右结合 运算。 它的意思是:先把 1 赋给 b,算出个结果来,再把这个结果赋给 a。
AST 结构: 这是一棵 “向右倾斜” 的树。根节点是 第一个等号
JSON
  1.   {
  2.     "type": "AssignmentExpression",
  3.     "operator": "=",  // 根节点 第一个等号
  4.     "left": {
  5.       "type": "Identifier",
  6.       "name": "a"
  7.     },
  8.     "right": {
  9.       // 右子树 重点:右边是一个完整的赋值表达式
  10.       // 必须先把右边这一坨算出来,才能给 a 赋值
  11.       "type": "AssignmentExpression",
  12.       "operator": "=",  // 子节点 第二个等号 (先执行)
  13.       "left": {
  14.         "type": "Identifier",
  15.         "name": "b"
  16.       },
  17.       "right": {
  18.         "type": "Literal",
  19.         "value": 1
  20.       }
  21.     }
  22.   }
复制代码

  • 第一篇学过的解析器视角: 解析器读到 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
  1.   {
  2.     "type": "MemberExpression", // 根节点 求 .c
  3.     "property": {
  4.       "type": "Identifier",
  5.       "name": "c"
  6.     },
  7.     "object": {
  8.       // 左子树 对象本身又是一个 MemberExpression
  9.       // 必须先算出 a.b 是个啥,才能去访问它的 .c
  10.       "type": "MemberExpression", // 子节点 求 .b (先执行)
  11.       "object": {
  12.         "type": "Identifier",
  13.         "name": "a"
  14.       },
  15.       "property": {
  16.         "type": "Identifier",
  17.         "name": "b"
  18.       }
  19.     }
  20.   }
复制代码

  • 重点是: 代码是 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
  1.   {
  2.     "type": "LogicalExpression",
  3.     "operator": "&&",           // 根节点 最后算
  4.     "left": {
  5.       // 左子树 一元运算
  6.       // ! 抢先执行,把 a 取反
  7.       "type": "UnaryExpression",
  8.       "operator": "!",
  9.       "argument": { "type": "Identifier", "name": "a" },
  10.       "prefix": true
  11.     },
  12.     "right": {
  13.       "type": "Identifier",
  14.       "name": "b"
  15.     }
  16.   }
复制代码
生成器的角度来看:

  • 站在根节点 &&。
  • 必须先算左边(!a)。
  • 于是下沉到 UnaryExpression,生成 ToBoolean + LogicalNot 指令。
  • 拿着这个结果,再回来决定是否要短路,或者继续算右边的 b。
例九:三元运算符的右结合  a ? b : c ? d : e
代码: a ? b : c ? d : e
这是除赋值以外,JS 里唯一的 右结合 运算符。
AST 结构: 这是一棵 “向右下方” 无限延伸的树。
JSON
  1.   {
  2.     "type": "ConditionalExpression", // 根节点 第一个问号
  3.     "test": { "name": "a" },
  4.     "consequent": { "name": "b" },   // 如果 a 为真,取 b
  5.     "alternate": {
  6.       // 右子树 重点:else 部分是一个新的三元表达式
  7.       // 这就是右结合:后面的问号被打包成了前面问号的 "否则" (else) 部分
  8.       "type": "ConditionalExpression",
  9.       "test": { "name": "c" },
  10.       "consequent": { "name": "d" },
  11.       "alternate": { "name": "e" }
  12.     }
  13.   }
复制代码
三元运算的优先级很重要 如果三元运算符是左结合的,这行代码就会变成 (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
  1. {
  2.   "type": "BinaryExpression",
  3.   "operator": "+",            // 根节点  加法
  4.   "left": {
  5.     // 左子树 await
  6.     // await 紧紧抱住了 x,先执行
  7.     "type": "AwaitExpression",
  8.     "argument": {
  9.       "type": "Identifier",
  10.       "name": "x"
  11.     }
  12.   },
  13.   "right": {
  14.     "type": "Literal",
  15.     "value": 1
  16.   }
  17. }
复制代码
我们依旧使用生成器的视角来瞄一眼:

  • 站在根节点 +。
  • 先处理左边 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(极高,和 . 还有 () 平起平坐)。
    • 特点: 括号是它的保镖,一旦带了括号,它就变得极其强势,必须先执行。

  • 形态二(无参数): new Foo

    • 优先级: 17(稍低)。
    • 特点: 如果后面跟了点号 .,它会认怂服软。

对于我们的例子 new Date().getTime():

  • 解析器读到 new。
  • 紧接着读到了 Date 和 ()。
  • 判定: 触发“形态一(带参)”。
  • 结果: new Date() 被瞬间锁死,打包成一个 NewExpression 节点。
  • 后续: 后面的 .getTime 只能乖乖地挂在这个节点上面。
AST 结构是层层递进: 这是一棵 “底座很深” 的树。
JSON
  1. {
  2.   "type": "CallExpression",      // 根节点 最后的调用 ()
  3.   
  4.   "callee": {
  5.     "type": "MemberExpression",  // 中间层 访问 .getTime 属性
  6.     "property": { "type": "Identifier", "name": "getTime" },
  7.    
  8.     "object": {
  9.       // 最底层 创建对象 (NewExpression)
  10.       // 因为带了括号,new Date() 优先级极高,作为整体成为了 Member 的底座
  11.       "type": "NewExpression",   
  12.       "callee": { "type": "Identifier", "name": "Date" },
  13.       "arguments": []
  14.     }
  15.   },
  16.   "arguments": []
  17. }
复制代码
对比 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结构:
  1.   JSON
  2.   
  3.   {
  4.     "type": "FunctionDeclaration",    // 第一层:火车头
  5.     "id": {                           // 第二层:直接就是名字,木有中间的车厢列表
  6.       "type": "Identifier",
  7.       "name": "foo"
  8.     },
  9.     "body": { ... }
  10.   }
复制代码
为什么函数声明只有两层结构呢?
因为 JS 语法规定:一个 function 关键字只能声明一个函数。 你写 function foo(), bar() {} 是 语法错误。 既然是一对一的关系,就不需要中间那个“数组列表”了。火车头直接焊死在车厢上,不可分割。
一层结构:原子节点
代码: this
在函数里用到 this 时,它在 AST 里就是一个光杆司令。
AST 结构:
JSON
  1. {
  2.   "type": "ThisExpression"
  3. }
复制代码
它既没有 name,也没有 value,也没有子节点。它自己就是全部。它就像一个 孤单滑板,没有车头也没有车厢,踩上去就走。
无限层结构:二元运算 (BinaryExpression)
代码: 1 + 2 + 3 + 4 + ...
代码  a + b + c
这是最能体现 AST “树” 特征的地方。层数理论上是 无限 的。
AST 结构是左结合 如果代码写成 a + b + c + d + e...,这棵树就会像 俄罗斯套娃 一样,一直往深处长。因为数学运算是可以无限嵌套的。AST 必须忠实地记录这种嵌套关系,才能保证计算顺序不出错。
左结合已经讲了很多了,例子就不举了。
爆炸层结构:类声明 (ClassDeclaration)
JavaScript
  1. class Person {
  2.   getName() {}
  3. }
复制代码
这在 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();。这是把表达式变成语句的包装器。
    • 控制流语句:

      • IfStatement
      • SwitchStatement / SwitchCase
      • ReturnStatement
      • BreakStatement / ContinueStatement
      • TryStatement / 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 ...

  1. 在前面,我们说过,ast是字节码生成的唯一来源,实际上,这个说法虽然没问题,但是却不是太精准。
  2. 在V8中,解析阶段是双树伴生,AST和作用域树 互相缠绕 同时生成,作用域和节点直接关联。
  3. 而在前端社区通用规范estree中,ast并不包含作用域信息,社区规范版本的ast,目标是精确、无歧义地描述代码的语法结构,而并不包括运行时的语义, 作用域信息,则是通过遍历ast 分析出来的,通常作为分析结果而存在。 所以  准确的说,estree的ast,如果需要作用域信息,需要多一个 遍历再分析 的过程。
  4. 在了解这两种区别以后, 我们在后续学习的时候, 会采用v8的AST模式, 即认为AST直接带有作用域。
复制代码

  • 后序遍历 后是什么后?为什么先搞左边?
    在前面 ,我们讲了后序遍历,不少新手朋友肯定很疑惑,不都是先看左子树吗?哪个是后?怎么个后序法?
    前 / 中 / 后序 的深度优先遍历:核心是根节点的处理顺序,左、右子节点的相对顺序基本固定
    对于像 1 + 2 这种极简的 AST(根节点是运算符,两个叶子是数字),初学者往往会觉得前、中、后序遍历“没区别”。确实,无论你先访问哪个节点,最终都能拿到 1、2、+ 这三个元素并算出 3。
    但这种“没区别”是一种错觉,是因为我们只关注了计算结果,而忽略了数据流向表达形式。一旦 AST 变得复杂(如嵌套运算),或者进入编译器生成指令的阶段,遍历顺序就决定了整个程序的处理逻辑。
    以 1 + 2 为例,三种遍历看似只是顺序不同,实际上对应了三种核心表示法:

    • **前序 **:+ 1 2 —— 波兰表示法。特点是无需括号,适合函数式语言的构造。
    • 中序:1 + 2 —— 中缀表示法。这是我们最习惯的阅读方式,也是源代码的样子。
    • 后序:1 2 + —— 逆波兰表示法。这是栈式虚拟机和大多数解释器的执行逻辑。
      常用的还有一个层序遍历,暂时用不到,就先不讲了。
    当 AST 出现深层嵌套时(例如 1 + (2 * (3 + 4))),不同遍历顺序的差异会明显显现。这是前端逆向反混淆需要理解的核心概念,对于我们本系列V8入门的目的来说,作为可跳过内容即可。

  1.   JSON
  2.   {
  3.     "type": "BinaryExpression",
  4.     "operator": "+",            // 根节点 A (最后执行)
  5.     "left": {
  6.       "type": "Literal",
  7.       "value": 1
  8.     },
  9.     "right": {
  10.       // 右子树:这是一个复杂的嵌套结构
  11.       "type": "BinaryExpression",
  12.       "operator": "*",          // 中间层节点 B (先于 A 执行)
  13.       "left": {
  14.         "type": "Literal",
  15.         "value": 2
  16.       },
  17.       "right": {
  18.         // 最内层:括号里的内容
  19.         "type": "BinaryExpression",
  20.         "operator": "+",        // 最底层节点 C (最早执行)
  21.         "left": { "type": "Literal", "value": 3 },
  22.         "right": { "type": "Literal", "value": 4 }
  23.       }
  24.     }
  25.   }
复制代码

  • 中序遍历:还原源代码

    • 路径: 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
  1.      {
  2.        var a = 1;
  3.        a = a + 1;
  4.      }
复制代码
AST 结构: 核心特征是一个 数组 (Array)。 BlockStatement 就像一个容器,它的 body 属性里装着按顺序排列的语句列表。
  1. JSON
复制代码
  1.      {
  2.        "type": "BlockStatement",
  3.        "body": [
  4.          // 数组里的第 1 个元素
  5.          {
  6.            "type": "VariableDeclaration", // var a = 1
  7.            "kind": "var",
  8.            "declarations": [...]
  9.          },
  10.          // 数组里的第 2 个元素
  11.          {
  12.            "type": "ExpressionStatement", // a = a + 1
  13.            "expression": {
  14.              "type": "AssignmentExpression",
  15.              "operator": "="
  16.              // ...
  17.            }
  18.          }
  19.        ]
  20.      }
复制代码
生成器视角: Generator 看到 BlockStatement 时的逻辑非常简单粗暴:遍历数组。 for (stmt of body) { Visit(stmt); } 它不关心逻辑,它只负责按顺序把里面的代码挨个生成指令。这就是程序“顺序执行”的物理基础。
<ol start="2">分流:IfStatement
这是 AST 从线性变成树状的关键点。
JavaScript
  1. if (test) {
  2.   consequent();
  3. } else {
  4.   alternate();
  5. }
复制代码
AST 结构: 这是一棵标准的 三叉树
JSON
  1. {
  2.   "type": "IfStatement",
  3.   // 1. 测试条件
  4.   "test": { "type": "Identifier", "name": "test" },
  5.   
  6.   // 2. 成立时执行的路径 (Consequent)
  7.   // 注意:这里通常包着一个 BlockStatement
  8.   "consequent": {
  9.     "type": "BlockStatement",
  10.     "body": [ { "type": "ExpressionStatement", ... } ]
  11.   },
  12.   
  13.   // 3. 否则执行的路径(Alternate)
  14.   // 如果没有 else,这个属性就是 null
  15.   "alternate": {
  16.     "type": "BlockStatement",
  17.     "body": [ { "type": "ExpressionStatement", ... } ]
  18.   }
  19. }
复制代码
生成器视角: Generator 看到这个树时,最头疼的不是生成代码,而是 “挖坑”

  • 生成 test 的指令。
  • 生成 JumpIfFalse 指令(跳去哪?还不知道,先挖坑 Label_Else)。
  • 生成 consequent 代码。
  • 生成 Jump 指令(跳过 else 部分,去 Label_End)。
  • 填坑: 标记 Label_Else 的位置。
  • 生成 alternate 代码。
  • 填坑: 标记 Label_End 的位置。
AST 的结构决定了这里必须引入 非线性 的跳转逻辑。
这里我们可以从比较抽象的逻辑层面来理解,生成器遇到  如假则跳 指令,它现在并不知道要跳到哪里 跳到什么位置,因为相关的指令还没有生成。所以 生成器给它发了张 地址卡,说:兄弟 你啥都别管了  到时候要跳的时候  就按这张地址卡上的地方跳过去就行了。
然后,到了对应的地方,生成器会将地址卡和具体地址联系上。
从比较底层的角度来看,我们使用的是编译原理中标准的挖坑填坑回填的说法,如假则跳指令, 跳到哪里我还不知道  那我先挖个坑占个位置,等过一会知道了具体位置  ,我就回来把真实有效的地址填上,这个就是 回填 。
地址卡的说法 侧重于单向的逻辑流。程序继续往下走,不需要关心底层怎么修改,只觉得到时候“自然就对应上了”。
回填的说法  侧重于内存的真实读写。也就是指令生成器确确实实干了“留下占位符 - 记住位置 -过一会再回头 -  覆盖重写”的物理动作。
这两种说法 都可以用于理解,只是理解的角度和侧重点不同, v8都有使用。
循环:ForStatement
for 循环是 AST 里结构最复杂的语句之一,因为它把 4 件毫不相干的事情组合在了一个节点里。
JavaScript
  1. for (var i = 0; i < 10; i++) {
  2.   console.log(i);
  3. }
复制代码
AST 结构: 它有四个关键插槽,缺一不可。
JSON
  1. {
  2.   "type": "ForStatement",
  3.   
  4.   // 1. 初始化 (Init) - 只执行一次
  5.   "init": {
  6.     "type": "VariableDeclaration",
  7.     "declarations": [ { "id": "i", "init": 0 } ]
  8.   },
  9.   
  10.   // 2. 检测条件 (Test) - 每次循环前执行
  11.   "test": {
  12.     "type": "BinaryExpression",
  13.     "left": "i", "op": "<", "right": 10
  14.   },
  15.   
  16.   // 3. 更新动作 (Update) - 每次循环后执行
  17.   "update": {
  18.     "type": "UpdateExpression",
  19.     "operator": "++",
  20.     "argument": "i"
  21.   },
  22.   
  23.   // 4. 循环体 (Body)
  24.   "body": {
  25.     "type": "BlockStatement",
  26.     "body": [ ... ]
  27.   }
  28. }
复制代码
导演先拿着带有作用域信息的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) 一直在默默运作。
他的视力不好,每次只能透过一个小孔(窗口)看相邻的两三条指令,专治各种“机械的愚蠢”。
假如导演看美女走神或者一时脑乱,喊出了这样一段内容:
  1. function add(a, b) {
  2.   return a + b;
  3. }
复制代码
记录员透过窥孔一看,很是烦躁:“第三步纯属多余,聚光灯里本来就是 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]],这就表示导演走到这里必须停下来发地址卡、挖坑,不能像普通的 + 号那样直接往下执行。
<ul>我们继续再多刷几个小例子

  • 本地局部变量

    • 代码: 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 , ,
    • 细节: 注意看这三个参数,导演要想越级拿到闭包变量,比较麻烦:

      • 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
  1. {
  2.   "type": "FunctionDeclaration",
  3.   
  4.   // 1. 名字
  5.   "id": { "type": "Identifier", "name": "add" },
  6.   
  7.   // 2. 参数列表 (Params) - 这是一个数组
  8.   "params": [
  9.     { "type": "Identifier", "name": "a" },
  10.     { "type": "Identifier", "name": "b" }
  11.   ],
  12.   
  13.   // 3. 函数体 (Body) - 必须是一个 BlockStatement
  14.   "body": {
  15.     "type": "BlockStatement",
  16.     "body": [
  17.       {
  18.         "type": "ReturnStatement",
  19.         "argument": { "type": "BinaryExpression", ... }
  20.       }
  21.     ]
  22.   },
  23.   
  24.   //在estree中,只有单纯的节点描述,并木有作用域信息,
  25.   //我们为了讲解 描述方便,
  26.   //有时候会采用v8的ast方式,将作用域信息挂载上来。
  27.   
  28.   // V8 夹带私货 Scope Info
  29.   // 在 V8 内部,这个节点上会挂载一个 Scope 对象。
  30.   // 它告诉解释器:进入这个节点时,要开辟新的栈帧,要分配新的上下文。
  31. }
复制代码
在正式拍这场戏之前,导演看着手里的剧本,深吸了一口气,对全场喊道:“兄弟们,今天这场戏是硬仗。如果是 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 , [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
  1. var a = 1;
复制代码
如果只看表面,你可能会觉得:前 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
  1. {
  2.   "type": "Program",         // 创世节点,一切的起点
  3.   "sourceType": "script",    // 或者 "module"
  4.   "body": [                  // 顶层代码列表
  5.     { "type": "VariableDeclaration", ... }
  6.   ]
  7. }
复制代码
下面的字节码基于 Node.js v20.x 的真实打印结果简化而来。去除了极度冗余的环境代码,保留了核心的准确的流转逻辑。在我使用的环境中,偏移值都是精确的。
  1. {
  2.   "type": "CallExpression",
  3.   
  4.   // 1. 调用的谁? (Callee)
  5.   // 这里可以很简单 (Identifier: add)
  6.   // 也可以很复杂 (MemberExpression: a.b.c.add)
  7.   "callee": {
  8.     "type": "Identifier",
  9.     "name": "add"
  10.   },
  11.   
  12.   // 2. 传了什么? (Arguments)
  13.   "arguments": [
  14.     { "type": "Literal", "value": 1 },
  15.     { "type": "Literal", "value": 2 }
  16.   ]
  17. }
复制代码
通过阅读上面的精确到偏移值的字节码,我们需要掌握下面的要点:
要点一:累加器(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
  1. var obj = {
  2.   name: "v8",
  3.   [key]: 123  // 计算属性 ES6+
  4. };
复制代码
现在,启动 BytecodeGenerator.VisitForStatement(node) 方法。我们跟着导演的脚步,一步步开始吧。
步骤 1: 遍历 init 节点


  • 到达点: ForStatement.init。
  • 导演决策: 这是一个 let i = 0。导演查阅了外层 [[Scope]],发现大本营的 i 被分配在寄存器 r0。数字 0 是一个 Literal(字面量)。
  • 对应动作: 把字面量读入聚光灯,存入对应寄存器。
  • 导演喊叫(字节码):
    Plaintext
    1. {
    2.   "type": "ObjectExpression",
    3.   "properties": [
    4.     // 1. 普通属性 (静态)
    5.     {
    6.       "type": "Property",
    7.       "key": { "name": "name" },
    8.       "value": { "value": "v8" },
    9.       "computed": false    // --- 重点:静态的,名字字面量已知
    10.     },
    11.     // 2. 计算属性 (动态)
    12.     {
    13.       "type": "Property",
    14.       "key": { "name": "key" }, // 这是一个变量
    15.       "value": { "value": 123 },
    16.       "computed": true     // --- 重点:动态的,需要在运行期计算
    17.     }
    18.   ]
    19. }
    复制代码
步骤 2:  遍历 test 节点

<ul>到达点: ForStatement.test (i < 3)。

导演决策: 这是一个二元表达式

相关推荐

您需要登录后才可以回帖 登录 | 立即注册