前言
这篇文章,本意是作为V8入门 前端进阶的朋友阅读的,但是在写AST部分的时候,突然发现,相对于前端开发来说,似乎在web分析 JS逆向的方面,这篇文章的用处更大。从写文章本身的角度来说,也是希望能有更多的人来阅读。尽管这样的文章,注定是读者稀少。
基本上都是基础 但是很重要的知识。整篇文章作为一个整体,并不是零散的知识点的罗列,有助于完整系统的了解V8及JS的内容,形成自己的知识脉络思考模型。
如果阅读时遇到暂时不明白不懂的内容,可以浏览性的通读过去。解析部分的具体步骤可以跳过,但是穿插的完整知识点都是需要学习掌握的。
文章持续更新中。。。
下面是文章作为V8入门前端进阶的前言:
这是完整的一篇超长文章,内容为javascript V8引擎的 词法分析 语法分析 编译 执行 优化 等完整的一个链条,内容详略得当 可以按需要部分阅读 也可以通篇仔细观看。
依旧是无图无码,网文风格。我觉得,能用文字把逻辑或者概念表述清楚,一是对作者本身的能力提升有好处,二是对读者来说 思考文字表达的内容 有助于多使用抽象思维和逻辑思维能力,构建自己的思考模式,用现在流行的说法 就是心智模型。你自己什么都可以脑补,那不是厉害大了嘛。
上面的话不要相信,其实我就是为自己懒找的借口。
这部分内容,能学习了解,当然最好,对平时的前端开发,也有好处,不了解,也不影响日常的工作。但是总体来说,很多开发中的问题,在这部分内容中 都可以找到根源。有些细节做了省略 有些边界情况做了简化表述。不过 , 准确性还是相当不错的。依旧是力求高准确性,符合规范,贴合实现。
篇幅比较长,可以按需要阅读,内容链条如下:
1识别-2流式处理-3切分-4预解析和全量解析-5解析概述-6解析具体过程.表达式的解析-7声明的解析-8函数的解析-9变量的解析-10类的解析-11语句的解析- 持续更新中。。。
其中包含单个完整的知识点分散在各部分:闭包 作用域 作用域链/树 暂时性死区。。。可搜索关键字查找。
版权声明呢。。。码字不易,纯脑力狂暴输出更不易
欢迎以传播知识为目的全文转载,谢绝片段摘录。 谢绝搞私域流量的转载。
一.词法分析和语法分析
当浏览器从网络下载了js文件,比如app.js,浏览器引擎拿到的最初形态是一串字节流 。
-
识别:浏览器根据 HTTP 响应头,通常是 Content-Type: text/javascript; charset=utf-8 将下载的字节流解码为字符流并交给 V8。V8 在内存中存储字符串时采用动态编码策略:在可行的情况下优先使用单字节(Latin-1)格式存储,只有当字符串中出现 Latin-1 范围外的字符(如中文、Emoji)时,才会转为双字节(UTF-16)格式。
-
流式快速处理: 引擎并不是等整个文件下载完才开始干活的。只要网络传过来一段数据,V8 的扫描器就开始工作了。 这样可以加快启动速度。此时的状态就是毫无意义的字符 c, o, n, s, t, `,a, ,=, ,1,;` ...
-
然后的这一步叫 Tokenization 词语切分。 负责这一步的组件就是上面提到的叫 Scanner(扫描器)。它的工作就像是一个切菜工,把滔滔不绝连绵不断的字符串切成一个个有语法意义的最小单位,叫做 Token(记号)。看到这个词 ,大家是不是惊觉心一缩,没错,就是它,它们就是以它为单位来收咱钱的。
scanner 内部是一个状态机。它逐个读取字符:
- 读到
c 可能是 const,也可能是变量名,继续。
- 读到
o, n, s, t 凑齐了5个娃,且下一个字符不是字母(比如是空格),确认这是一个关键字 const。”(防止误判 constant 这种变量名)
- 读到
空格 忽略,跳过去。
- 读到
1 这是一个数字。
这样就由原来的字节流变成了 Token 流。这是一种扁平的列表结构。
- 源码:
const a = 1;
- Token 流:
CONST (关键字)
IDENTIFIER (值为 "a")
ASSIGN (符号 "=")
SMI (小整数 "1")
SEMICOLON (符号 ";")
这一步,注释和多余的空格和换行符会被抛弃。
-
现在就是解析阶段了
其实解析是一个总称,它分为 全量解析 和 预解析 两种形式。
这就是v8的懒解析机制。看到这个懒字,也差不多能明白了吧。
对于那些不是立即执行的函数(比如点击按钮才触发的回调),V8 会先用预解析快速扫一遍。
检查基本的语法错误(比如有没有少写括号),确认这是一个函数。并不会生成复杂的 AST 结构,也不建立具体的变量绑定,只进行最基础的闭包引用检查。御姐喜的结果是这个函数在内存里只是一个很小的占位符,跳过内部细节。
而只有那些立即执行函数或者顶层代码,才会进入真正的全量解析,进行完整的 AST 构建。
那么,问题就来了,v8怎么判断到底是使用预解析还是使用全量解析呢?
它的原则就是 懒惰为主 全量为辅
就是v8默认你写的函数暂时不会执行,除非是已经显式的通过语法告诉它,这段这行代码 马上就要跑 你赶快全量解析。
下面 我们稍微详细的说一下
function clickHandler() {
console.log("要不要解析我");
}
// 引擎认为 这是一个函数声明 看起来还没人调勇它
// 先不浪费时间了,只检查一下括号匹配吧,
// 把它标记为 'uncompiled',然后跳过。"
-
那么 如何才能符合它进行全量解析的条件呢
-
顶层代码
写在最外层 不在任何函数内 的代码,加载完必须立即执行。
判断依据: 只要不在 function 块里的代码,全是顶层代码,必须全量解析。
-
立即执行函数
那么这里有个问题,就是V8 如何在还没运行代码时,就知道这个函数是立即调用执行函数呢?
答案就是 看括号()
当解析器扫描到一个函数关键字 function 时,它会看一眼这个 function 之前有没有左括号 (
-
没括号
function foo() { ... }
// 没看到左括号,那你先靠边吧, 对它预解析。
-
有括号
(function() { ... })();
// 扫描器扫到了这个左括号
// 欸,这有个左括号包着 function
// 根据万年经验,这是个立即执行函数,马上就要执行。
// 直接上大菜,全量解析,生成 AST
-
其他的立即执行的迹象:除了括号,!、+、- 等一元运算符放在 function 前面,也会触发全量解析
!function() { ... }(); // 全量解析
-
除了这些以外, v8还有一些启发式的规则来触发全量解析。比如 如果是体积很小的函数,V8 有时也会直接全量解析,因为预解析再全量解析的开销可能比直接解析还大。。。等等。
-
如果有嵌套函数咋办呢
嵌套函数默认是预解析,即使外部函数进行的是全量解析,它内部定义的子函数,默认依然是预解析。只有当子函数真的被调用时,V8 才会暂停执行,去把子函数的全量解析做完 把 AST 补齐
//顶层代码全量解析
(function outer() {
var a = 1;
// 内部函数 inner:
// 虽然 outer 正在执行,但 inner 还没被调用
// 引擎也不确定 inner 会不会被调用。
// 所以inner 默认预解析。
function inner() {
var b = 2;
}
inner(); // 直到执行到这一行,引擎才会回头去对 inner 进行全量解析
})();
-
那么 引擎根据自己的判断 进行全量解析或者预解析,会出错吗
当然会,
如果是本该预解析的 结果判断错了 进行了全量解析 浪费了时间和内存生成了 AST 和字节码,结果这代码根本没跑。
如果是本该全量解析的又巨又大又重的函数 结果判断错了 进行了预解析,然后马上下一行代码就调用了,结果就是 白白预解析了一遍,浪费了时间,发现马上被调用,又马上回头全量解析一边 又花了时间,两次的花费。
-
在上面只是讲了解析阶段的预解析和全量解析的不同,现在我们讲解析阶段的过程
V8 使用的是递归下降分析法。它根据js 的语法规则来匹配 Token。
它的规则类似于:当我们遇到 const,根据语法规则,后面必须跟一个变量名,然后是一个赋值号,然后是一个表达式。
过程示例:
看到 const 创建一个变量声明节点。
看到 a 把它作为声明的标识符。
看到 = 知道后面是初始值。
看到 1 创建一个字面量节点,挂在 = 的右边。
而在这个阶段的同时,作用域分析也在同步进行,因为在构建 AST 的过程中,解析器必须要搞清楚变量在哪里。
它会盘算 这个 a 是全局变量,还是函数内的局部变量?
如果当前函数内部引用了外层的变量,解析器会在这个阶段打上标记:“要小心,这个变量被逮住了,将来可能需要上下文来分配”。
这个作用域分析比较重要,我们用稍微大点的篇幅来讲讲。
首先 强烈建议 不要再去用以前的 活动对象AO vo 等等的说法来思考问题。应该使用现在的词法作用域 环境记录 等等思考模型。
词法作用域 (Lexical Scoping)” 的定义:作用域是由代码书写的位置决定的,而不是由调用位置决定的。
这说明,引擎在还没开始执行代码,仅仅通过“扫描”源代码生成 AST 的阶段,就已经把“谁能访问谁”、“谁被谁逮住”这笔账算得清清楚楚了。
一旦AST被生成,那么至少意味着下面的情况
作用域层级被确定
AST 本身的树状结构,就是作用域层级的物理体现。
- AST 节点: 当解析器遇到一个
function 关键字,它会在 AST 上生成一个 FunctionLiteral 节点。
- Scope 对象: 在 V8 内部,随着 AST 的生成,解析器会同时维护一棵 “作用域树”。
- 每进入一个函数,V8 就会创建一个新的
Scope 对象。
- 这个
Scope 对象会有一个指针指向它的 Outer Scope父作用域。
- 结果: 这种“父子关系”是静态锁定的。无论你将来在哪里调用这个函数,它的“父级”永远是定义时的那个作用域。
变量引用关系被识别
这是解析器最忙碌的工作之一,叫做 变量解析。
- 声明: 当解析器遇到
let a = 1,它会在当前 Scope 记录:“我有了一个叫 a 的变量”。
- 引用: 当解析器遇到
console.log(a) 时,它会生成一个 变量代理。
- 链接过程: 解析器会尝试“连接”这个代理和声明:
- 先在当前 Scope 找
a。
- 找不到?沿着 Scope Tree 往上找父作用域。
- 找到了?建立绑定。
- 一直到了全局还没找到?标记为全局变量(或者报错)。
这里要注意: 这个“找”的过程是在编译阶段完成的逻辑推导。
闭包的蓝图被预判
这一步是 V8 性能优化的关键,也就是作用域分析。
-
现在 我们来复一下盘 重点学习解析过程
字节流---被切成有语法意义的最小单元token---成为token流---解析阶段(进行预解析或者全量解析)---得到AST和作用域树和变量引用关系 这就是我们第一部分所讲的词法分析和语法分析的内容。
因为这部分比较重要,所以我们将继续深入的学习一下。。。反正学都学了 要学还不趁机多学点,所以 前面的内容 只是开胃菜 惊不惊喜 意不意外 .
其实,是因为在整个链条中,从开始到AST生成,是一个较为完整的独立的小阶段。此时,仅仅是静态分析过程完成。
从整个流程来看, AST生成,表示物理层级确定 作用域链构建完成,闭包蓝图依托作用域链 变量路径引用依托作用域链,甚至连栈和context中的位置分配都有了蓝图。 所以 重点了解这部分内容,也是获得感满满了。
下面 我们来重点学习解析的过程。
上面讲了解析的过程叫 递归下降分析法 听起来是不是很高大上,其实 它还有个小名,叫“层层甩锅工作法”。
-
解析器有两大神技,这两大神技,是它的最大倚仗
它处理当前token时,总是喜欢盯着下一个( 甚至下几个),比如 当它手里拿着const了,然后它提前偷看后面的 欸 是个 a, 那就没错 这把稳了,是个变量声明。
这个神技,有个比较正规的名字 叫前瞻 lookahead。
当解析器在解析某句或某段代码时,是解析器中的某一个解析函数在工作,很有可能是被上面层层甩锅甩下来的,轮到这个解析函数时,很大的可能,是这句或这段代码的解析,就属于它的本职工作,它按照自己的解析流程判断逻辑,来使用前瞻技能,预判下一个token是否符合它的工作逻辑需求。
- 消费 consume 当确认这个当前的 Token 没问题,就把它“吃掉”,consume 即消费掉,同时指针移动,指向下一个token,准备处理下一个。
比如 当前指针指着const,它偷看后面的,是个a,它就确定 符合它变量声明的岗位的判断逻辑,于是,它就吃掉 消费掉当前指针指着的const,然后指针移动到a,重复它的偷看和消费的步骤。
-
简单来说,解析过程就是:用 前瞻 提前偷看 lookahead 决策,用 消费 consume 前进,一层层把工作交给合适的解析函数,直到整段代码被解析完成。
-
前面说 懒惰为主 全量为辅,意思是从解析结果 从解析数量上来看, 很大很大部分都是做的懒惰解析 预解析,是占主要的部分。 而全量解析做的很少。
那么 从解析流程的决策层面来看,从“指挥权”来看,全量解析为主。
- 全量解析负责开场,它负责做决定,它负责把控全局。没有它,预解析根本不知道什么时候进场工作。即 全量解析是主导流程的
- 这里要特别注意,我们把 主解析器和全量解析 作为一个整体来讲的,在v8中,主解析器和全量解析器 基本上可以划上等号,所以 说全量解析为主导流程 ,就是说主解析器主导流程。 主解析器/全量解析 推进流程, 遇到非立即执行的代码,就呼唤预解析器来工作。
// 全量解析 即主解析器正在干活 构建全局AST
var a = 1;
// 突然遇到了一个函数声明!
function lazy() {
var b = 2;
console.log(b);
}
// 全量解析:"哎呀,是个函数声明,估计没人调用它,我不进去了,太费劲。"
// 于是指挥 预解析去干活
// 切换到了 预解析
// 预解析快速扫描 lazy 内部:
// 1. 检查有没有语法错误?(没有)
// 2. 检查有没有引用外部变量?(没有)
// 3. 检查结果:"里面安全,是个普通函数。"
// 4. 于是生成一个"占位符节点",预解析器收工。
// 切换回到了 全量解析/主解析器
// 全量解析继续往下
var c = 3;
// 遇到了 立即执行函数
// 全量解析一看:"哎,这后面有个括号 (),马上要跑"
// 不能喊外包了,得自己来干这一票。
// 全量解析进入函数内部构建 AST
(function urgent() {
var d = 4;
})();
-
我们说预解析 虽然不生成AST节点,只是生成占位符节点,但是也需要快速扫描内部。
// 对外部函数进行全量解析,对内部函数进行预解析
function father() {
let dad = '爸爸';
//全量解析中,遇到内部函数,额太累,呼叫外包 预解析
// 预解析进来 开始快速扫描 son 的内部文本...
function son() {
console.log(dad);
}
}
预解析 :
它看到 console.log,不生成 AST 节点。
它看到 dad 这个标识符
判断:son 内部声明过 dad 吗?(没有)。
判断:这是一个未解析的引用 (Unresolved Reference)。
结果: 预解析 扫描完 son 后,虽然把中间的信息扔了(不存 AST),但会给 father 的作用域留下一条极其重要的情报:
该子函数内部引用了你的 dad 变量
father函数的反应 (Context 分配)
收到预解析的情报后,father 函数此时已经在忙碌中了,它就会做出反应:
本来 dad 是准备分配在 栈 (Stack) 上的。
因为收到了预解析提供的闭包引用信息,所以
father 的作用域分析结果中,dad 被标记为 需要 Context 分配。
结果: dad 被移入堆内存的 Context 中,确保 father 死后 dad 还能活。
这里要特别注意,这是蓝图 蓝图, 此时是静态解析阶段,所说的都是蓝图 都是画的大饼。
关于怎么描述 被移入堆内存的上下文中,后面会详细讲。
那么 这个占位符里是什么内容呢?
对于预解析的函数 son,
AST 树上只有一个“占位符节点”(UncompiledFunctionEntry),在 V8 中,这个占位表示会与一个 SharedFunctionInfo 关联,用来保存函数的元信息(如参数、作用域、是否为闭包等),供后面真正全量解析和编译时使用,
元信息中大致有如下内容:
没有 AST节点:也就是没有具体的代码逻辑结构。
有作用域信息 (ScopeInfo):
它知道自己内部引用了哪些外部变量。
它知道自己是不是闭包。
关于作用域,后面会详细讲。上面是先讲占位符里是有这些信息的,否则无法保证闭包蓝图的完整性和准确性。
-
经过上面的铺垫,我们现在开始AST的解析了。这部分内容是否有必要展开, 我纠结了起码两盏热茶的时间,因为从了解的角度来说 ,上面的内容,已经足够了,甚至在中级高级前端开发的岗位面试中,也足够了。 但是,我又觉得具体的解析也有必要讲讲,毕竟都学到这块内容了,稍微再往深处瞄那么几眼,也可以的。
我们以v8为例。
为了说明白,现在开始就不得不使用具体的函数名了,不过基本上这些函数名都有规律,看名字就差不多知道含义了。
ParseStatementList(语句列表解析)是真正的循环驱动者。如果不严格区分顶层入口的话,我们可以把它看作解析流程的主引擎。它的工作非常单纯枯燥:就是开启一个 while 循环,只要没到文件结尾,就驱动 项/Item 这一级别的解析。
而在循环内部,它会把每一次的处理任务甩锅给 ParseStatementListItem(项级入口)。
可能有朋友会疑问:什么是“项(item / 条目)”这一级别?可以这样理解:从语法上讲,语句加上声明,就构成了 项/item/条目, 但是语句和声明 他们有很大的不同。既要区分他们,又要在一个大循环里统一处理他们,所以有了 项 这个称呼。
有些 声明、模块的 import / export、在允许位置上需要提升并且登记到作用域的函数声明、需要做早期错误检测的地方等等,就要求优先的处理 比如提前登记名称和作用域信息、报早期错误,或者做预解析并留下占位符。
ParseStatementListItem() 负责做项级的分流,如果检测到是 import/export、可提升的函数声明或其他项级必须优先处理的内容,就在此处定向甩锅,通常是直接甩给对应的具体解析函数,如果检测到不是需要优先处理的声明定义,而是普通的语句,它会把该条甩锅给 ParseStatement(),就是普通的语句级解析,由语句级负责普通语句(控制流、块、表达式语句等)的详细解析。在解析器层面上的这两种分流保证了 提升、模块 规则和语句语义既能正确又便于优化实现。
ParseStatementList:负责整体推动循环,偷看一眼,现在只要不是eof结束标记,不管其他是什么内容,统统一股脑的甩锅。
ParseStatementListItem:负责在 项级 这一层面分流, 综合以下判断:
当前 token + 当前的语境 + 语法规则 + 可能有的预判
分流为声明级解析和普通语句级解析,
如果是声明级 import、function、class、let 等,就优先处理,提前定向甩锅,以实现提升或登记作用域。
通过以上内容,我们知道了,ParseStatementListItem 具有解耦的用途,它区分了声明和语句,但是它又不具体干活,依旧是把它拦截的声明项派发。
下面我们来看 ParseStatement ,通过上面的语句和声明的分流,语句项来到了这个地方,这里又是一个甩锅处。ParseStatement 先使用神技 前瞻lookahead偷看token,使用类似于 if 或 switch case 的形式,尝试匹配所有具有确定起始关键字或符号的语句形式(如 if、for、return、{ 等)。匹配上以后 对准那个匹配成功的解析函数,甩锅下去。其他尚未识别的 则甩给表达式解析,这是因为表达式的形式有很多,而且无法根据关键字来识别,所以 可以说表达式解析是个兜底。 如果是被甩锅到表达式解析,首先由表达式的赋值解析接手, 解析流程统一从 ParseAssignmentExpression 这一最低优先级规则开始。
因为对于表达式解析,它和其他的解析不同,其他的可以依靠关键字来甩锅,但是表达式必须依靠优先级来甩锅。赋值解析作为低优先级的一层,它无法预知当前代码的含义,因此它必须先无条件地将解析任务甩锅给更高优先级的下层解析器(如三元、二元、调用等)。
等下层解析器返回了一个表达式节点后,赋值解析器再偷看后续 token。只有当后续 token 是 = 时,它才将其组装成赋值表达式,否则,它就直接将刚才下层解析器返回的结果,原封不动地向上返回。
我们以一个表达式的例子来说明解析过程:
解析 m=1+3
ParseStatement通过前瞻,匹配不到语句,甩锅到表达式 ParseExpression(),这个也是直接转交给ParseAssignmentExpression, 此时有5个token
- 前面说过 这个赋值解析优先级非常低,它无法预知当前token的含义,必须先甩锅给别人,先搞出来一个东西看看。
这里肯定有朋友会问了,赋值解析拿到m,偷看后面的 是个 = 号,不就知道了吗?
但是,假如不是m,而是m[0] ,是m.b 甚至是m(888) (函数调用,虽然这在赋值中是非法的,但解析器得先把它解析出来,然后偷看到=号,才会知道非法)呢? 而且,解析函数的设计,是需要统一性 通用性 的,所以 它必须先甩锅,必须得到一个确定的表达式节点,才能做决定。
- 所以 赋值解析直接派发给了三元解析ParseConditionalExpression
三元解析说 看不懂 不归它管 依旧往下甩锅。
两元解析 依旧甩锅
一元解析 依旧甩锅
- ParseLeftHandSideExpression
LHS 处理new,(),.,[] 的解析, 依旧甩锅
到了原子层,这里是专门处理m,1,(expr),this 的地方。
这层一看 欸 是我的活呀, 然后吃掉 token m。
生成 VariableProxy(m) 节点。 交回上层。
- 返回到ParseLeftHandSideExpression
这层的解析拿到m节点,偷看后面 是个 = 号,嗯 没我的事,快走吧。继续往上交
这层拿到m,偷看 是个=号,和我的工作没关系,快走吧
这层拿到m,偷看 是个=号 ,我是搞两元的,和我没关系 ,快走吧
- 返回到ParseConditionalExpression
这层拿到m,偷看 是个=号,我是搞三元的,和我没关系,快走吧
- 返回到ParseAssignmentExpression
这层拿到m,偷看 是个=号,哎呀呀,我就是搞赋值的,就是我的活,
然后接收m节点,吃掉=号 并且保存=号, 关键点来了: 此时它需要解析等号右边的内容。虽然我们看到的是 1+3,但解析器并不知道右边是不是还藏着另一个赋值(比如 m = n = 1+3)。 为了保证赋值的右结合性(即连等赋值),它必须递归调用自己(ParseAssignmentExpression) 来解析右边。
第2次进入 ParseAssignmentExpression 新的一层赋值解析器启动了。它依然遵循老规矩,先看不懂,甩锅
- ParseConditionalExpression
三元解析拿到1,啥东西呀,甩锅
-
。。。一直甩到原子层
-
ParsePrimaryExpression
拿到1,哎呀,又是我的活,咔嚓 消费掉token 1,生成 Literal(1) 节点,往上交
- 返回到ParseLeftHandSideExpression
拿到Literal(1)节点,偷看 是个 + 号,快走吧
拿到Literal(1)节点,偷看 是个+号,和我的工作没关系,快走吧
拿到Literal(1)节点,偷看 是个+号,天呐 我就是搞两元的,我的活,
然后 接收Literal(1)节点 消费掉+号 并且保存+号,
这个时候 它要解析后面的token 3,前面讲过,解析函数的设计,要兼顾到统一性和通用性,虽然本例是1+3,但是二元解析中,+号后面 依旧可能是个二院解析式,比如 3+5*9 等等,所以,本例虽然可以直接甩锅到下面的一元解析lhs解析到原子解析,但是,从统一和通用性的角度,v8设计成了递归调用。
就是对于+号后面的解析,依旧是调用ParseBinaryExpression,只不过,必须要加上优先级, 比如 + 号的优先级是12, 乘法*的优先级是13, 这个优先级传递很简单 就是通过函数的参数传的。
再次调用以后,本例是3,再次甩锅,甩到原子层,得到节点3,返回到这里,
这第2次调用 得到3节点,它偷看一眼 后面没了,嗯 嗯嗯 这个表达式就是一个节点3,连优先级判断都没用到。 它就返回上交,退出第2次调用, 回到了当前, 此时,它左手有1节点 右手有3节点,脑子里还记得一个+号, 于是 它召唤出factory工厂方法NewBinaryOperation(op, left, right),生成了大的新的节点,这个节点 上面是+号节点 左孩子是节点1,右孩子是节点3。
后面什么都没了,往上交活了。
- 返回到ParseConditionalExpression
三元解析一看 这是个1+3的小AST树,偷看后面 没有token了, 快走吧
- 返回到ParseAssignmentExpression
赋值解析拿到这棵 1+3 的小 AST 树,偷看一眼 后面没了, 于是第2次的调用返回,
现在,自己左手是个 m,右手是个 1+3,脑子里还记得个 =,全妥了。 于是它就召唤 factory 工厂方法 NewAssignment(ASSIGN, m, right_node)。
随着一道金光,一个 Assignment赋值节点 诞生了 这行代码 m=1+3 的语法分析彻底完成,最终返回给最顶层的 ParseStatement。
- 上面我们以一个简单的赋值表达式m=1+3的例子 详细讲解了AST的生成过程。并通过赋值解析的递归调用 能了解连等赋值的右结合是怎么实现的,二元运算解析中的递归调用,我们也能知道通过参数传递运算符的优先级。
解析 m = 1 + 2 * 3
- 赋值层启动:赋值解析拿到
m,消费掉 = 号,并记住 =。
- 开始第一次递归调用(赋值表达式解析):为了解析右值。
- 甩锅环节:拿到
1,不认识,甩甩甩...
1 节点被返回,返回到 二元解析(Level 0) 这里。
- 二元解析(Level 0):
- 状态:接收
1 节点。
- 偷看:
+ 号(优先级 12)。
- 判断:当前门槛 0,12 > 0,消费
+ 号,记忆 + 号。
- 递归调用:调用二元解析,门槛设为 12。
- 第一次递归二元解析(Level 1)开始:
- 甩锅环节:
2 不认识,甩甩甩... 返回 2 节点。
- 状态:接收
2 节点。
- 偷看:
* 号(优先级 13)。
- 判断:当前门槛 12,13 > 12,可以吃! 消费
* 号,记忆 * 号。
- 递归调用:调用二元解析,门槛设为 13。
- 第二次递归二元解析(Level 2)开始:
- 甩锅环节:
3 不认识,甩甩甩... 返回 3 节点。
- 状态:接收
3 节点。
- 偷看:没了(或者分号)。
- 判断:优先级不够。
- 返回:直接返回
3 节点。
- 回到第一次递归(Level 1):
- 组装:接收到
3 节点。左手是 2,右手是 3,记忆是 *。
- 动作:组合成
2 * 3 节点。
- 返回:把
2 * 3 节点往上交。第一次递归结束。
- 回到二元解析(Level 0):
- 组装:接收到
2 * 3 节点。左手是 1,右手是 2 * 3,记忆是 +。
- 动作:组合成
1 + (2 * 3) 节点。
- 返回:往上交。直到赋值表达式。
- 回到赋值表达式(第一次递归调用处):
- 状态:接收
1 + 2 * 3 节点。
- 偷看:没了。
- 返回:第一次赋值解析递归调用返回。
- 回到最顶层赋值解析:
- 组装:当前左手
m,右手 1 + 2 * 3,记忆 =。
- 动作:组合成
m = 1 + 2 * 3。解析完成。
上面我们又以 m=1+2*3 的例子,详细解说了赋值解析中的递归调用,二元解析中的多次递归调用,并且在递归的时候,加入了优先级套餐,相信能看到这里的朋友,对于解析的套路,已经有那么一点点的感觉了吧。
现在来看 m = 1 * 2 + 3 这个例子 是个优先级高的在前
节点 1 返上来,被二元解析拦截。偷看 是* 号 优先级13,当前0,吃掉。
记住*号, 然后开始递归,调用 ParseBinaryExpression(13)
第一次递归,拿到2,不认识 甩甩甩, 节点2返上来,接收节点2, 偷看 + ,优先级12,而当前优先级13,太弱了 不搭理,带着节点2返回,结束本次递归。
此时,左手节点1,右手是刚返回得节点2,记住的是*号,
组装节点 1*2 . 然后继续, 偷看后面 + 号, 当前优先级0,+号优先级12,
吃掉消费掉+号,记住+号, 开始第二次递归ParseBinaryExpression(12)
拿到3 不认识 甩甩甩, 节点3返上来 接收节点3,偷看 后面没了。带着节点3返回,第二次递归结束。 此时 左手是 1*2 节点, 右手是刚返回来的3节点,脑子记着的是+号,
金光一闪, 1*2+3 完成。
简单描述了一下优先级高的在前的例子。
成员访问 obj.data.list
还是从赋值解析开始,看到 obj,不认识,甩甩甩,一路下去,直到原子层。 原子层生成 VariableProxy(obj) 节点,返回。刚返回一层,到了 ParseLeftHandSideExpression。
被拦截: 手里拿着 obj 节点,偷看后面是个 . 符号,是我的活!接收 obj 节点,消费掉 . 符号。
这里它不需要像处理 [] 那样,去调用那个沉重复杂的表达式解析器(因为 [] 里甚至可以写 1+1),而是自己解析 data。 因为点号后面,只允许跟一个“名字”。所以它直接自己上手,快速扫描这个名字。哪怕你写的是 obj.if 或者 obj.class,在这里也被当作普通的名字处理。解析完名字,立马打包。这种自力更生的处理方式,比把 data 甩锅给原子层更快速。
现在,左手是 obj 节点,右手是刚解析的 data,脑子记着点号,咔嚓一下,组装成 obj.data 节点。
注意,这里是个循环: 组装完后它不走,偷看后面,哎,还是个 . 点号! 于是消费掉第二个 . 号,继续自己解析 list。 此时,它的左手变成了刚才组装好的 (obj.data) 节点,右手是新拿到的 list,再次组装,生成 (obj.data).list。
再偷看,后面没了,交上去。
三元表达式 ok ? 1 : 0
从赋值解析开始,看到ok 不认识,甩甩甩,从原子层返回ok节点,返回到三元解析层,
拿着ok 偷看 ?号啊, 那是我的活了,接收ok,吃掉?,注意,现在就不需要记住?了,因为三元表达式是固定的语法结构,在这一函数解析的 都是固定的格式,不需再记?号。
调用ParseAssignmentExpression() ,得到条件为真时的节点,此例为节点1. 此时,左手ok 右手节点1,偷看 是:号,妥了,吃掉:号,必须是冒号,如果不是,直接报错 SyntaxError: Unexpected token
再次调用ParseAssignmentExpression(),得到条件为假时的节点,此例为节点0,
此时,左手ok 右手节点1 加上刚刚返回的节点0, 全齐了, 召唤
factory 工厂函数, NewConditional(condition, then_expr, else_expr)
生成一个 Conditional(ok, 1, 0)(三叉树,这里要注意,并不是左手右手的二叉了,而是有三个子节点的三叉了,即一个Conditional节点,带3个子节点)节点,返回到赋值解析层。
a || b 这个解析时和加法差不多 只是操作符不同。
m = (1+2) * 8 这个表达式带括号,实际也很简单,接收m 偷看= 消费掉=,递归调用赋值解析,( 一路到了原子层,原子层吃掉 ( ,然后调用最高级的 ParseExpression(注意:是重新从头调用表达式解析,相当于开启了一个新的独立副本)。 然后接收1+2节点,偷看 ),欣慰,刚才吃了个(,现在成对了, 于是吃掉 ), 把1+2 节点上交。。。 后面就更简单了。省略。
add(1, 2, 3)
依旧是从原子层返回add节点,返回到ParseLeftHandSideExpression层,偷看 是 (
,接收add节点, 吃掉( , 调用ParseArguments,收集参数,依次调用ParseAssignmentExpression 收集参数,直到碰到 ),吃掉 ),返回,此时ParseLeftHandSideExpression左手add节点 右手刚才拿到的参数列表,组装,完工。
m[2]
这是带有计算属性的成员访问形式。 LHS 层在处理时,会把解析点号 . 和中括号 [ 的任务,统一甩给 ParseMemberExpression 来处理(new 操作符也归它管),而 LHS 自己负责函数调用和模板字符串的解析。
简要流程:
-
先找头: 先解析出 m。
-
进入循环: ParseMemberExpression 启动 while 循环,偷看后面。
-
处理中括号: 发现是 [,吃掉它。
这里会调用 ParseExpression(true)。这个 true 表示允许包含逗号,表示中括号里可以写完整的表达式(比如 1+1或者更复杂的表达式)。
-
组装: ParseExpression 返回节点 2,吃掉 ],将 m 和 2 组装起来。
-
继续循环: 如果后面还有 [ 或 .(比如二维数组或链式调用),就继续解析、继续包在外面组装;如果没有,就返回。
下面我们进入思考模式
我们说 在赋值解析的时候 要使用递归调用,这是没有任何问题的,因为递归调用本身就可以得到右结合的目的,和连等赋值的定义是相符合的。
在二元解析的时候,我们也说使用递归调用,但是这就有些问题,因为递归调用会产生右结合,而通过使用优先级 和遇到同级操作符 则退出递归 由上级处理左结合以后 再次递归,这样也可以达到左结合的目的。 这种方式本身也没问题,从嵌套深度上来讲,极限情况下 也不过是十多个递归嵌套,并不会栈溢出。 但是从横向上来看,比如 有多个同级操作符的时候 就比较繁琐,极其频繁的函数调用,开销比较大。
so, v8在具体实现二元解析的时候 采用的是 循环为主 递归为辅 的方式。用循环处理同级左结合,用递归下降处理更高优先级的子表达式
主要思路就是在while循环里处理同优先级,高优先级的 则进到递归里处理, 一个while循环里处理同一级,高优先级的 进到递归里 继续在递归里的那个while里处理那个高优先级的同级。如此循环,所以,实际上,跟我们之前例子里学的,全部递归的方式,在递归层次上相同,极限情况下 也不过是十多个嵌套递归, 但是,横向的同级,则被压扁成在一个while循环里处理。这种通过循环吞噬同级运算符,通过递归处理高优先级运算符的方法,在编译原理中被称为‘优先级爬升算法’.
伪代码
// 入口:解析二元表达式,传入当前允许的最小优先级
function ParseBinaryExpression(min_precedence) {
// [初始左值] 先搞定左边的原子 (例如: 1)
let x = ParseUnaryExpression();
// 开启大循环
// 只要后面还有能吃的符号,就一直在这个循环里转
while (true) {
let op = Peek(); // 偷看下一个符号
// 遇到这两种情况 1是符号没了,到头了 2是下个符号太弱了,该上层递归要管的事情,
// 这时,就带着手里积攒的 x 赶紧返回
if (!op || op.precedence <= min_precedence) {
return x;
}
// [消费] 优先级够格,吃掉符号 (比如 +)
Consume(op);
// [递归获取右值]
// 让递归函数去拿右边的数。
// 关键点:把当前 op 的优先级传下去
// 这样如果右边是同级运算(如 1+2+3),递归函数会发现优先级不够,只拿一个数就立马 // 返回。
// 如果右边是高级运算(如 1+2*3),递归函数会深入处理。
let y = ParseBinaryExpression(op.precedence);
// [原地累加 像滚雪球]
// 把左边(x)、符号(op)、右边(y) 组装成新节点。
// 核心动作:把新节点赋值回 x
// 现在的 x 从 "1" 变成了 "(1+2)"。
x = NewBinaryNode(op, x, y);
// [循环继续]
// 代码运行到这里,会回到大循环开始处。
// 此时手里拿着新的 x, (1+2),去偷看下一个符号(比如 +3 的那个 +)。
// 如果下一个符号优先级还够,就继续吃;不够就由if语句退出。
}
}
上面是使用循环为主 递归为辅 实现二元解析 左结合的伪代码。
理解伪代码 理解思路以后,会感觉 甚至比原先的纯递归更容易。具体的例子就不举了。
注意 这里要说明 金光是如何一闪的
之前我们说 召唤工厂方法,金光一闪,节点诞生, v8中的AST节点的创建,有自己的内存分配方法,它采用的是一种叫 Zone Allocation的分配方式。类似于提前圈地模式。
解析前,V8 直接向系统“圈”了一大片连续的内存,取名为 Zone。
当工厂函数 factory() --- NewAssignment(...) 被调用时,它只是在自己圈好的这块地里,把指针往后挪一挪,划出一小块地给这个节点住。
这个动作快到不可思议,仅仅是简单的指针加法操作。
而当需要销毁时,V8 不需要一个个节点去拆除,它只需要把 Zone 整个推平。一键清空,瞬间满血。
所以,AST 节点的创建,是极速的指针跳动。这保证了哪怕代码量再大,解析器的内存分配速度也快如闪电。
在表达式解析的家族里,还有一个不得不提的重磅人物,那就是 ES6 引入的 箭头函数 () => {}。
你可能会问:“它不是函数吗?为什么要在表达式这里讲?” 这是因为在 V8 眼里,箭头函数首先是一个表达式。它通常出现在赋值号右边(let a = () => {})或者作为参数(func(() => {}))。它不能像 function 关键字那样独立成行(除非你没写名字且不赋值,虽然合法但没意义)。
但它让解析器非常头疼,因为它喜欢 伪装。
看这行代码:
let x = (a, b ...
当解析器读到这里时,它有些糊涂了。
- 如果是
let x = (a, b); —— 这是一个 分组表达式,里面是个逗号运算。
- 如果是
let x = (a, b) => a + b; —— 这是一个 箭头函数。
在读到 => 这个关键 Token 之前,解析器根本不知道前面的 (a, b) 到底是个什么。
这就是解析器面临的 歧义 。
如果 V8 只有读到 => 才知道前面是参数,那难道要先存着 Token 不解析,等看到了箭头再回头解析吗?
不,V8 通常不愿意回头。 它采用了一种 “将错就错,后期修正” 的策略,术语叫 Cover Grammar(覆盖语法)。
我们以 (a, b) = a + b 为例,看看解析器是怎么被骗,又是怎么反应过来的。
阶段一:按表达式解析
1. 入口与误判
解析器在扫描到左括号 ( 时,它此时处于 ParsePrimaryExpression(基础表达式解析)的上下文中。 此时,解析器心里只有一种想法:“这肯定是个 分组表达式 (Parenthesized Expression),里面包着一些运算逻辑。”
2. 表达式解析模式启动
解析器开始调用 ParseExpression 来处理括号里的内容:
- 读到
a:
- 解析器认为这是在使用变量
a。
- 产物:生成一个
VariableProxy 节点(变量代理,表示“我要引用 a”)。
- 读到
,:
- 解析器认为这是 逗号运算符 (Comma Operator)。
- 它的作用是连接两个表达式,并返回后者。
- 读到
b:
- 生成
VariableProxy 节点(表示“我要引用 b”)。
3. 阶段性产物 当解析器吃掉右括号 ) 时,它手里捧着一个 多元运算 或者叫 逗号表达式。 在解析器眼里,(a, b) 目前的含义是:“先执行 a,扔掉结果;再执行 b,返回 b。” 这显然不是我们想要的结果,但在读到 => 之前,这是唯一合法的解释。
阶段二:坏了 发现箭头
解析器刚吃完 ),立刻启动 前瞻 (Lookahead/Peek) 技能,偷看下一个 Token。
- 如果后面是
+:那前面就是个逗号表达式,继续做加法。
- 但这次,它看到了
=>!
解析器:
“哎呀!撞上箭头了! 前面那个括号里的根本不是什么逗号运算,那是 箭头函数的参数列表 (Formal Parameters)! 手里捧着的这些 VariableProxy(变量引用),全都是废纸,它们应该是 参数声明 才对!”
此时,解析器必须启动紧急预案:重解释 (Reinterpretation)。
阶段三: AST 进行原地修正变身
V8 通常不会回退指针重新解析一遍(那太慢了)。它选择直接对内存里已有的 AST 节点修改。
1. 合法性检查 解析器遍历刚才那个 CommaExpression 里的每一个子节点,:
- 检查
a:你是个 VariableProxy 吗?是。你的名字是合法的参数名吗?是。 -通过。
- 检查
b:你是个 VariableProxy 吗?是。 -通过。
- 假如:假如你写的是
(a + 1) => ...。
- 解析器会发现列表里有个
BinaryOperation(加法节点)。
- 问:“
a+1 能当参数名吗?”
- 回答:不能。 -直接报错
SyntaxError。
- 在这里还要进行其他的必须检查,以保证它们作为参数的合法性。
2. 节点转化 (Transformation) 这是最重要的一步。解析器不销毁节点,而是修改节点的 性质。
- 它把
a 和 b 的 VariableProxy 节点,原地转化 为 参数声明。
- 关键动作:
- 之前,
a 指向的是外层作用域(试图引用)。
- 现在,解析器把
a 从外层作用域的引用列表中摘除。
- 然后,把
a 作为 新声明,登记到即将创建的 FunctionScope 里。
从此,a 和 b 从“消费者”(引用)变成了“生产者”(声明)。
阶段四:解析函数体
参数搞定了,现在处理 => 后面的 a + b。
1. 创建作用域 V8 调用 NewFunctionScope,创建一个新的函数作用域。
- 注意:因为是箭头函数,所以这个 Scope 被标记为
is_arrow_scope,所以它不会声明 this,也不会声明 arguments。
2. 偷看与判定 解析器偷看箭头后面的 Token:
- 是
{ 吗?不是。
- 那这是一个 Concise Body (简写体)。
3. 自动包装 (Desugaring) 对于 a + b 这种简写体,解析器并不是直接把它当表达式扔在那。 它会由工厂方法生成一个 ReturnStatement 节点,把 a + b 包在里面。
最终产物: 虽然你写的是 (a, b) => a + b,但在 V8 的 AST 里,它长得和下面这段代码几乎一模一样:
function (a, b) {
return a + b;
}
这就是覆盖语法 Cover Grammar :先按通用的表达式解析,一旦发现特征(箭头),立刻把已有的 AST 结构重组为特定语法结构。
面试官必被吊打题:为什么箭头函数没有 this?
很多教程说:“箭头函数的 this 指向外层。”
这句话是对的,但在 V8 的实现里,更准确的说法是:箭头函数根本就不在这个作用域里定义 this。
我们来看看 Scope 分析 阶段发生了什么:
普通函数 (function) 的 Scope:
- V8 创建
FunctionScope。
- V8 会在这个 Scope 里专门声明一个隐藏变量:
this。
- 当你访问
this 时,找到的就是这个专门声明的变量(由调用方式决定值)。
箭头函数 (=>) 的 Scope:
- V8 创建
FunctionScope。
- 关键点:V8 给这个 Scope 打上一个标记 ——
is_arrow_scope。
- 后果:V8 不会 在这个 Scope 里声明
this 变量。
查找过程:
当你在箭头函数里写 console.log(this):
-
解析器在当前 Scope 找 this。
-
找不到!(因为根本没声明)。
-
往上找:沿着 outer_scope 指针去父级作用域找。
-
结果:它自然而然地就用了外层的 this。
这不是什么特殊的“绑定机制”,这单纯就是“变量查找机制”的自然结果。
因为它自己没有,所以只能用老爸的。这就是 词法作用域 (Lexical Scoping) 的本质。
从解析器的角度看,箭头函数是一个 “三无” 产品,这正是它轻量的原因:
-
无 this:Scope 里不声明 this,直接透传外层。
-
无 arguments:Scope 里不声明 arguments 对象,也是透传。
-
无 construct:生成的 FunctionLiteral 节点会被标记为“不可构造”。如果你想 new 它,现在炸不了你,过一会肯定炸飞你。
通过箭头函数的学习,说明俩问题。
-
解析层面的歧义(为什么解析器要回溯、重解释)。
-
作用域层面的 this 本质(不是绑定,而是查找)。
上面 我们已经基本上将表达式解析的比较常见的形式 从超级详细的撕扯到简略的梳理,讲了几个,如果能耐心的看完,相信自己也可以分析了,即使还有没遇到的表达式形式,根据惯用的套路,也能自己搞定。
在学习这些内容时,要联系到在js层面编码时,表现出的特点。这样不仅js能掌握的牢, 底层也记得住。 比如obj.data.list的解析,主要是在LHS层里的while大循环里解析点后面的内容,内容是字符串的形式, 是固定的, 而m[2],解析的时候,Lhs看到是中括号里的内容,是调用了顶层的表达式解析函数来干活的,表达式解析可以解析的东西那可多了,而且还可能有递归,所以在js的编码时,要知道这两种的区别和性能上的差异。虽然说 现在电脑性能快到飞起,都得用石头压住,而且浏览器本身的优化也很厉害,一丢丢丢丢的性能差异完全不用担心,但是,万一你换工作去面试,正巧问到你这两种的区别。。。嘿嘿嘿,你就真的可以像那些八股文里说的那样 吊打面试官了。想想都刺激。
-
在前面,我们了解了,在 项 级的解析中,它实际是个分流处,把声明的项拦截后直接甩锅, 把语句的项甩锅给语句解析。而上面我们花了大篇幅讲的表达式解析,是语句解析中,负责兜底的表达式解析。 所以 我们还剩下可用关键字匹配的语句解析 和 在项 级就被直接派发的声明的解析。现在我们开始了解声明的解析。
声明的解析
声明的解析不多,总结起来,就是:一类四函两变量。
class C {} // 类
function f() {} //四种形式的function
function* g() {}
async function f() {}
async function* g() {}
let //变量
const
可能有朋友会问了:var哪儿去了? 在js规范中, var属于 语句,不属于声明,即 var属于VariableStatement 。 但是 从var的效果和语义上来说,它确实是声明变量。
所以 从规范的角度来说, 声明 只有这一类四函两变量, 没有var, 但可是, 在v8的具体实现中,let const var 这三个却是被分到一起 作为变量定义 派发到了ParseVariableDeclarations中解析,只是在里面解析的时候 他们有不同的处理分支。
在进行下一步学习之前 ,我们再次的总结一下:
开始解析之后,来到 项级 被分流成两种, 一个是声明 包括(一类四函两变量)4种函数被发到ParseHoistableDeclaration,类被发到ParseClassDeclaration,变量声明(这里要注意,js规范var不属于声明,但是v8中 var也在这里被分发了) 被发到ParseVariableDeclarations,
还有一个是语句,语句统一被甩锅到ParseStatement进行解析,在解析时 先按关键字派发,无关键字匹配的甩给表达式兜底。
我们首先以一个简单的函数声明的解析为例。
function add(x, y) {
let result = x + y;
return result;
}
初始情况:
- 当前作用域: Global Scope(全局作用域)。
- 扫描器状态: 指针停在
function 这个 token 上。
第一阶段:项级分流
1. ParseStatementListItem (项级入口)
-
动作: 解析器被上层循环调用,要求解析下一项。
-
偷看 (Lookahead): 当前 Token 是 function。
-
判断: 这是一个函数声明。它属于 Declaration (声明),且属于 HoistableDeclaration (可提升声明)。
-
甩锅: 这活儿不能当普通语句处理,得走“提升通道”。
在前面反复多次提到,项级分流主要分两种:一是语句,一是声明。声明则由项级分流自己派发 按照“一类四函两变量”。此处是普通函数声明,被项级精准派发到 ParseHoistableDeclaration。
-
调用: ParseHoistableDeclaration。
2. ParseHoistableDeclaration (可提升声明解析)
- 动作: 确认是
function。
- 偷看: 后面不是
* (Generator),没有 async。
- 决定: 这是一个标准的函数声明。
- 甩锅: 调用
ParseFunctionDeclaration。
3. ParseFunctionDeclaration (函数声明解析)
- 消费: 吃掉
function 关键字。
- 解析标识符: 读到
add。
- 关键动作(登记名字): 解析器立刻转头告诉当前的 Global Scope:“老全头,我要在你这里预订一个叫
add 的名字。”
- Global Scope 记录:
add ---- 登记为函数声明。
- 注意: 虽然解析器现在只读到了名字,但因为它记录的是“函数声明”,V8 会在后续的编译/实例化阶段,确保在任何代码执行前,这个名字就已经指向了完整的函数体。这就实现了我们常说的“函数整体提升”。
- 所以,虽然此时只是在小本本上记了个名字(占位),真正的函数对象创建和绑定要等到后续阶段。但对解析器来说,名字有了,就可以继续往下走了。
- 准备进入实体: 名字搞定后,剩下的
(x, y) { ... } 属于函数字面量部分。
- 甩锅: 调用
ParseFunctionLiteral。
- 这个函数是个解析函数字面量的主力。不止声明这里可以调用,其他地方也经常调用它去干苦力活。
第二阶段:函数体解析
这里是重点,是最关键的一步,我们从外部跨入了内部。
4. ParseFunctionLiteral (函数字面量解析)
- 初始化上下文:
- 创建新作用域: V8 创建一个新的 FunctionScope。这里,函数作用域被创建了。
- 父指针连接: 新 Scope 的
outer_scope 指向 Global Scope。这里,作用域的外部连接指针被创建了,指向父作用域。(这一步形成了作用域链,为以后的变量查找铺好了路)。
- 当前状态: 解析器现在的“当前作用域”切换为这个新的
FunctionScope,现在已经全部进入函数内部开始干活了。
- 消费: 吃掉
(。
5. ParseFormalParameters (解析参数)
- 循环读取函数参数:
- 读到
x:在 FunctionScope 登记参数 x。
- 读到
,:跳过。
- 读到
y:在 FunctionScope 登记参数 y。
- 消费: 吃掉
)。
- AST 节点: 此时,参数列表的 AST 节点已完成。
6. ParseFunctionBody (解析函数体)
- 消费: 吃掉
{。
- 动作: 现在进入了函数体内部。这里本质上是一个语句列表 (Statement List)。
- 开始循环: 调用
ParseStatementList。
第三阶段:体内的循环
现在,我们在 add 函数的内部,开始循环处理每一行代码。
====== 第一行代码:let result = x + y; ======
7. ParseStatementListItem (再次回到项级入口)
-
ParseStatementList 开启以后,甩锅给项级入口,进行分流。
-
偷看: Token 是 let。
-
判断: 这是个 LexicalDeclaration (词法声明)。
-
甩锅: 调用 ParseVariableStatement。
项级分流,一是语句,二是声明。let 是变量声明,在此处被项级直接派发到 ParseVariableStatement。嗯嗯嗯,反复的重复,加深脑内印象。
8. ParseVariableStatement (变量声明解析)
- 消费: 吃掉
let。
- 解析标识符: 读到
result。
- 作用域操作:
- 问自己:当前 FunctionScope 有
result 吗?(没有)。
- 动作: 在 FunctionScope 中登记
result。
- 标记: 暂时标记为 “栈局部候选人 (Stack Local Candidate)”。
- 为什么是候选?因为现在还不知道有没有闭包这个老登在后面等着捕获它。先按“住栈”处理,等最后算总账时再决定。
- 偷看: 后面是
=,这表示有初始值,需要解析赋值表达式。
9. ParseAssignmentExpression (赋值解析)
- 眼熟吧,俺表达式解析又回来了。熟悉的情节也回来了。
- 左手: 拿到
result 的变量代理节点。
- 消费: 吃掉
=。
- 右手(递归): 解析
x + y。
- ParseBinaryExpression (+号):
- 读到
x Resolve:在当前 Scope 找到参数 x,生成引用节点。
- 吃掉
+。
- 读到
y Resolve:在当前 Scope 找到参数 y,生成引用节点。
- 组装: 生成
BinaryOperation(+, x, y) 节点。
- 这里的读到变量的时候,首先在当前的作用域找,找不到就通过指向父作用域的指针,到上层作用域里找。
- 终极组装:
- 生成
Assignment 节点:result = (x + y)。
- AST 挂载: 这个 Assignment 节点被 push 到函数体的 statements 列表中。
- 消费: 吃掉
;。
====== 第二行代码:return result; ======
10. ParseStatementListItem
11. ParseReturnStatement (返回语句解析)
- 消费: 吃掉
return。
- 偷看: 后面不是
;,说明有返回值。
- 甩锅表达式: 调用
ParseExpression 解析 result。
- 变量的解决:
- 读到
result。
- 查找: 在当前 FunctionScope 找到了刚刚登记的
result。
- 生成:
VariableProxy(result) 节点。
- 组装: 生成
ReturnStatement(result) 节点。
- AST 挂载: 挂到函数体列表中。
- 消费: 吃掉
;。
第四阶段:收工阶段
12. ParseStatementListItem (循环继续)
- 偷看: Token 是
}。
- 判断: 列表结束了。
- 返回: 退出
ParseStatementList。
13. 退出函数体与作用域计算 (Scope Finalization)
14. 此时的产物与最终包装
- 返回:
ParseFunctionLiteral 任务完成,手里捧着刚出炉的 FunctionLiteral 节点(含代码体 + 作用域),返回给上一层的 ParseFunctionDeclaration。
- 关键打包 (The Packaging):
ParseFunctionDeclaration 接过这个 Literal 节点,把它和之前解析好的名字 add (VariableProxy) 绑在一起。
- 召唤工厂: 调用工厂方法,生成一个更大的 FunctionDeclaration 节点。
- 左手:名字
add。
- 右手:实体
FunctionLiteral。
- 最终挂载: 这个
FunctionDeclaration 节点(而不是裸露的 Literal),被 push 到 Global AST 的 body 列表中。
在前前前前面,我们提到过变量代理的说法,前面我们又提到了变量代理节点。 那么这个变量代理到底是个什么东东呢?这个概念比较重要,需要稍微讲一下。
声明 (Declaration): var a = 1; 这是在造变量。引擎在作用域里实打实地登记了一个叫 a 的东西。
代理 (Proxy): console.log(a); 这是在用变量。
解析器读到这里的 a 时,它心里是没底气的:“我要用一个叫 a 的东东,但我现在手头没有它的详细档案(不知道它是在栈上、堆上,还是全局里)。不管了,我先开一张‘我要找 a’的小票放在这儿。”
这张“小票”,在 AST 里就是 VariableProxy。
那么有朋友就会说了,读到 a 的时候,直接去查一下不就行了吗?为什么还要这么麻烦搞个代理?
原因主要有两个:
- 是因为 JS 允许在变量定义前使用它:比如函数提升、
var 提升。当它读到一个不确定的变量时,不能报错也不能立刻绑定,所以它只能先生成一个 VariableProxy(a) 放在 AST 里面,表明这里有个 a 的坑,等全部解析完了,我得过来填坑。
- 是因为解析的顺序限制:解析器是从上往下读的。举个最简单的例子:
console.log(a); var a = 1;。当解析器读到第一行 console.log(a) 时,如果你非要它立刻、马上就把 a 找出来,它去哪里找?它可能会去外层找,结果找错了人。因为它还没读到第二行,根本不知道你在后面偷偷藏了个局部变量 a。所以,解析器必须先忍一手。它必须先把当前函数里的代码全都扫完,把该登记的变量都登记在册(Scope构建完成),然后回头算总账时,才能准确地知道:哦,原来这个 a 指的是第二行声明的那个兄弟,而不是外面的隔壁老王。
所以,因为上面这两个原因,就先生成代理,等 AST 造好了,或者进入作用域分析的阶段,再统一处理这些代理的坑。
我们用一个小例子来演示:
JavaScript
function order() {
return dish; // A: 使用 dish
}
var dish = '周黑鸭'; // B: 定义 dish
第一步:生成代理 解析器解析 order 函数内部:
- 读到
return。
- 读到
dish。 “这是个变量名。但我现在只负责造树,不知道 dish 是谁。”
- 动作:创建一个
VariableProxy 节点。
- 名字: "dish"
- 状态: Unresolved (未解决/未找到)
- 把这个节点挂在
ReturnStatement 下面。
此时 AST 的状态: ReturnStatement - VariableProxy("dish") (手里拿这个只有名字的小票,不知道去哪领菜)
第二步:变量解决 (Variable Resolution) —— 兑换 这一步通常发生在前面讲解例子的时候的第13步, Scope Finalization(作用域收尾/算总账) 阶段,也有可能是后续的编译阶段。
V8 开始拿着这张小票(Proxy)去兑换:
- 问当前作用域 (FunctionScope):“你这里有
dish 的声明吗?”
- 问父作用域 (Script/Global Scope):“你这里有
dish 的声明吗?”
链接 (Bind): V8 就会把这个 VariableProxy 节点,和一个具体的 VariableDeclaration(或者具体的档案信息)连上红线。
此时的状态: VariableProxy 不再是一张空头小票,它变成了一个指针,明确指向了外部作用域的那个 dish。
“代理”这个词的意思是 “代表某人行事”。 在 AST 中,这个节点暂时代表了那个真实的变量。在真正的连接建立之前,它就是那个变量的魔鬼代言人。一旦连接建立,操作这个 Proxy,实际上就是在操作那个真实的变量档案(或者说逻辑地址),因为此时还在静态解析阶段。
嗯嗯嗯。。。肯定又有朋友会问了,那链接绑定以后,是什么样子的?
样子就是,从此以后,V8 就不会再关心它叫什么名字(名字只是给人看的),只关心它住在哪里。它会被标记为以下三种“住址”之一:
- 住址 A:栈 (Stack / Local)
- 含义:这是个普通局部变量,没被闭包捕获。
- 结果:Proxy 拿到一个 寄存器索引 (Register Index)。
- 表示:“这小子就在隔壁房间(寄存器 r0, r1...),伸手就能拿,速度最快!”
- 住址 B:上下文 (Context / Heap)
- 含义:这是个被闭包捕获的变量,或者
with (with已经被强烈建议不要使用了)里的变量。
- 结果:Proxy 拿到一个 上下文槽位索引 (Context Slot Index)。
- 表示:“这小子搬家了,住在堆内存的 Context 豪华大别野里。访问它得先拿到 Context 指针,再根据偏移量(比如第 3 个格子)去找。”
- 住址 C:全局 (Global)
- 含义:这是个全局对象(window/global)上的属性。
- 结果:Proxy 被标记为 全局访问。
- 表示:“这是大老板,得去查全局字典。”
上面插个队讲了一下变量代理的概念,在我们继续学习声明的解析之前,我们再插个队,讲一下 作用域。
能看到这里的朋友,估计对作用域都了解。但是,不讲作用域光讲声明,就像吃饺子不蘸醋,浑身不得劲。
前面讲变量代理的时候,那张寻找变量 a 的“小票” (Proxy),现在要拿着它去兑换了。去哪里兑换呢?就是去 作用域。
有些教程上说“作用域是变量的可访问范围”,这话是没错,但这仅仅是从变量的角度来说,并没有从作用域本身的视角来讲。
作用域是一套语法规则,它就是“地盘”。它不光规定了谁在地盘里,还规定了这是谁的地盘。
词法作用域 (Lexical Scope):
这句话翻译过来就是:“出身决定命运”。 一个变量的作用域,在你写代码的那一刻,就由它在源代码里的物理位置决定了。 它的特点就是 静态:写了就决定了,写完就锁死。以后不管怎么调用、在哪儿调用、怎么调用,作用域永远不变。
作用域就是一张在编译阶段就画好的静态地图。
能圈地盘的,有哪些大佬呢?
- 全局 (Global):最大的地主,普天之下莫非王土。
- 模块 (Module):每个文件一个独立地盘,自带防盗门,互不干扰。
- 函数 (Function):这是最老牌的地主。每写一个
function,就圈了一块地。函数里的 var、let、参数,都归它管。
- 块 (Block):这是 ES6 新晋的小地主。凡是
{ ... } 包起来的(比如 if、for 或者直接写的大括号),在语法上都算作“块”。
但是,V8 在块级作用域这里是非常现实的。
如果大括号里没有 let 或 const,V8 觉得专门为你建一个 Scope 对象太浪费内存了,根本懒得搭理你。此时,它在 V8 眼里实际上并不构成独立作用域,变量查找直接走外层。
只有当大括号里出现了 let 或 const 这种新贵小王子时,V8 才会真的给它发“房产证”,专门创建一个由大括号为标志的块级作用域 BlockScope。
注意 var:至于 var,它比较特殊。它看不上块级这种小地盘,这种大括号根本关不住它。它会直接穿墙出去,去找外面的函数地主或者全局地主。
那么,变量有没有作用域呢?
准确地说:变量本身并不能拥有作用域,但是变量属于某个作用域。
我们说 a 的作用域是函数 f,实际是在说,变量 a 处在函数 f 的作用域里。
在 V8 内部,每个作用域都有一个清单,上面详细记录了:
“我这块地盘上,住了张三、李四、还有老王...”
如果解析器在这一层没找到人,说明这个人不住这儿,就会沿 作用域链 去往上找。
那么 问题来了,
作用域链是怎么形成的呢?
当一个新的作用域被创建出来的时候,新的作用域里都有一个 outer 指针,拴在父级作用域上。
子函数的作用域里,也有个 outer 指针拴着外部函数的作用域;
外部函数的作用域里,也有个 outer 指针拴着全局的作用域,这就形成了一根链条。
肯定有朋友会有疑问了:
“什么作用域链?不就是子函数指向父函数吗?平时咱写代码,函数嵌套个两三层也就顶天了,这么短一点,也好意思叫‘链’?
这里有两点:
第一,这是由数据的组织形式决定的。 只要是通过指针一个连一个的数据结构,都叫 链表。这跟它长短没关系,只要是这种结构,5厘米是链表,25厘米也是链表,特指它这种“顺藤摸瓜”的连接方式。它不是数组,不能通过下标直接访问;也不是树或图。哪怕它只有两层,只要是靠指针指过去的,它就是链表结构。
第二,它是内存里实实在在的物理链条。 一定要分清解析和执行。现在我们是在解析阶段,这根链条在图纸上,是蓝图。等到后续代码真正执行的时候,在堆内存里,真的会创建出一串串的 Context 对象,它们之间真的是通过物理指针连接起来的。 所以,它不光是逻辑上的链,更是物理上的链。
想象一下查找过程: 当要查找一个变量时:
- 先看自己家:当前作用域有吗?木有。
- 顺着绳子找爸爸:父级作用域有吗?木有。
- 一层层往上:直到找到全局作用域。
- 找到了:皆大欢喜。
- 到顶了还没找到:
- 如果是赋值
a=1 且不是严格模式:那就在全局给你造一个。
- 如果是取值
b=a:哎呀,找到全局都没有,你歇着吧,直接报错 ReferenceError。
一定要注意: 我们现在所说的,都是在 解析阶段。 这一切都是 蓝图。作用域和作用域链,在解析阶段就锁定了。遇到变量该怎么找、该去哪里找,在这一刻都已经有了蓝图。
在讲完作用域链以后,要停下来,揪出一个披着狼皮的羊,这就是对象。
对象 Object ,它没有作用域。
var obj = {
name: '阿祖',
say: '我是' + name // 报错!或者是拿到全局的 name
};
为什么,同样是大括号,函数那里是作用域,对象这里却只是一个框框,只表示一个数据结构?
可以从以下几个方面来说:
-
语法
作用域的大括号,它里面装的是语句, 是动词 是命令 比如 a=1,这里=是赋值运算,表示一个动作,他的意思是 在这个作用域里面,开一个槽位,把1放进去。
对象的大括号,它里面装的是属性定义,属性是描述,是名词。比如 name:’阿祖‘ ,这里要用冒号, 不能用=号,如果手抖用了=号,马上出错 SyntaxError: Unexpected token '=' 。 在对象里,没有变量的说法 只有 键 和 值 的映射关系,只可以用冒号。
-
时序
函数是有提升的, 而对象没有,
var obj = {
a: 1,
b: a // 想引用上面的 a
};
当引擎解析时,
读到 var obj =:好,准备创建一个变量 obj。
读到 {:好,开始准备构建一个对象。
读到 a: 1:记录属性 a 值为 1。
读到 b: a:
- 这里冒号右边的
a,是一个表达式。
- 解析器需要求出这个表达式的值,作为属性
b 的值。
- 关键点:解析器此时会向 当前作用域 发出查找请求:“谁是 a?”
当前作用域是谁?
是 obj 所在的作用域(比如全局作用域),而绝不是 obj 内部!
因为此时此刻,obj 这个对象还没生出来呢!
究极原因,是因为 对象的初始化 是一个不可分割的原子过程,要么 就是没有 ,要么 就是已经构建完成,绝不会出现在构建当中可以使用的情况,除非这个原子过程已经完成了,否则 这个obj是不存在的。
所以,对象初始化是一个原子过程。在大括号闭合 } 之前,这个对象在逻辑上是“不存在”的,自然无法构建起所谓的“内部引用环境”,
-
结构
在v8的世界里,作用域和对象是完全不同的。
作用域 对应着 context
- 它是一个环境
- 就像一个栈帧或者上下文的列表
- 里面的变量是使用索引,比如 let a 是第0号槽位,let b 是第1号槽位。
- 作用域是为了代码执行服务的。
对象 对应着 映射 隐藏类
- 它是一个字典, 对象的定义是什么?它的定义就很清楚的说明 属性的无序集合。就是一个字典。
- 它是一堆键和值的无序的集合。
- 里面的属性查找,是使用哈希计算或者偏移量描述符的,还有这个隐藏类,后面我们会讲到。
- 它是为了存储数据服务的。
对象和作用域,v8分的特别清楚,找变量,走作用域,查栈帧 查context 速度快到起飞。
找属性,走原型链,查map 隐藏类,稍微慢点。
肯定有朋友说,你就是个骗子, 你看,class现在都能在里面写 = 号了。
class Obj {
name = '阿祖'; // 这里写了等号
say = () => { console.log(this.name) }; // 这里也用了变量
}
class是构造函数的语法糖,在es6以后,确实可以写=号。
但是 可以写=号,也是一个语法糖。引擎并不会把类里的=号 当成变量声明,而是把它放到constructor构造函数里面, 改成
// 引擎偷摸的操作
function Obj() {
this.name = '阿祖'; // 变成了属性赋值
this.say = ...
}
引擎悄悄的使用 this.name=。。。 进行了属性赋值,而不是 var name=。。。,它使用的依旧是对象的规则,不是作用域的规则。
你在 class 里面写 name,如果不加 this,依然访问不到这个属性,还得去外层作用域找。
总结对象:
- 对象没有墙:它只是数据的容器,不是变量的隔离区。
- 对象的大括号是骗子:不要因为长得像块级作用域,就以为它是作用域。
- 冒号不是等号:
: 是画地图(定义结构),= 是发指令(执行赋值)。
- 目的不同:作用域是为了执行代码,对象是为了存储数据。V8 从底层就把它们分到了不同的“部门”。
话音未落,又有朋友大声说 骗子 现在类里面不止=号,什么都能写,还有作用域。
class Database {
static data = [];
// 静态初始化块
static {
try {
const content = loadFromFile(); // 可以写逻辑呀
this.data = content;
} catch {
this.data = []; // 可以写 try-catch呀
}
}
}
它并不是 对象属性, 而是披着大括号外衣的函数。
虽然static写在class里面,但是 static{...} 并不是定义一个叫 static 的属性(不像 name: '阿祖')。在 V8 眼中,看到 static 关键字后面紧跟一个 {,解析器会立马切换模式:
“注意,这不是在列清单定义属性,这是要执行代码!给我开辟一个新的 类作用域 (Class Scope)”
所以,static { ... } 内部,实打实地拥有一个块级作用域。
你在static{...}里面 let a = 1,这个 a 就死在这个大括号里,外面谁也看不见。这完全符合作用域的定义。
本质上,这个静态块相当于一个绑定了 this 的立即执行函数 ,this值为这个class构造函数本身。
// 我们的代码
class C {
static { ...code... }
}
// V8 眼中的代码
class C { ... }
// 马上执行的立即执行函数
(() => {
// ...code...
// 这里的 this 指向 C
}).call(C);
正因为它本质上是代码执行,而不是数据描述,所以它里面当然可以有作用域,当然可以写语句。
这并不是对象大括号变成了作用域,
而是 ES2022 专门在 Class 定义里挖了一个代码执行区。
- 普通的对象字面量
{ a: 1 }:依然是数据清单,没有作用域,不能写语句。
- 类的静态块
static { a = 1 }:是逻辑代码块,是作用域,是 一个 VIP 执行通道。
能写语句的地方,才可以叫作用域,只能写键值对的地方, 叫字典 叫对象。
顺带着,还有个暂时性死区的概念,这也是很多八股文里要吊打面试官的地方。
在v8中, 变量的绳命周期,大致有3个阶段
创建 在作用域里占个坑 登记名字。
初始化 给这个坑填个初始值 undefined 也算的。
赋值 填入真正的用户数据 比如 1
var的待遇:
var 的“创建”和“初始化”是绑定在一起提升的。
当进入作用域(比如函数开始)时,V8 直接把 var a 创建出来,并且顺手就给它初始化为 undefined。
所以,你哪怕在第一行就访问 a,它虽然没数据,但起码是个合法的 undefined。
let const 的待遇:
它们的“创建”被提升了,但“初始化”被扣留了。
当进入作用域时,V8 确实在内存里给 let a 占个坑位,登记了名字,但是 V8 并没有给它初始化 undefined,而是给它填入了一个极其特殊的警卫 TheHole。
TheHole 是 V8 内部的一个特殊对象,可以把他理解为会吹哨子的警卫。
- 暂时性死区的所处阶段定义:从进入作用域(创建变量)开始,一直到代码执行到声明那一行(初始化变量)为止。这段时间,变量一直处于被警卫看守状态。
- 吹哨子:在这段时间内,任何试图读取该变量的操作,v8一看:“哎哟,这坑里是
TheHole?” 马上停止执行,抛出 ReferenceError: Cannot access 'a' before initialization。
暂时性死区,是暂时的,所以 关注点 一定要停留在 暂时的 这个时间点上。
被提升了,但是没真正被赋值, 都属于这个 暂时性 所包括的时间阶段内。
so,暂时性死区 并不是变量没有提升,而是变量被“冻结”了。
var:开局送装备(undefined)。
let/const:开局送警卫(TheHole)。警卫在变量真正初始化前一直吹哨子,阻止访问。只有等到代码执行流真正跑到声明的那一行,警卫才会扔掉哨子下岗走人,换上有效的值。
这也是 V8 强迫开发者养成先声明,后使用的好习惯的一种手段。
我们再讲一个双树的问题,然后就继续学习声明的解析。
当我们说解析阶段生成了AST树的时候,大多数人,就只会想到这棵凑想语法树。
但是在V8的解析过程中, 其实是还有一棵树在同步生成,和AST树互相缠绕。
这就是作用域树。
- AST (抽象语法树)
-
语法结构的树。
-
它描述了代码的 语法结构。
-
Block、FunctionLiteral、BinaryExpression、ReturnStatement...
-
给 Ignition 解释器看。解释器遍历这棵树,生成字节码。
- 看到
BinaryExpression --生成 Add 指令。
- 看到
Literal -- 生成 LdaSmi 指令。
-
就好像是搭建房子的 框架结构。墙在哪、窗户在哪、承重柱在哪。
- Scope Tree (作用域树)
-
逻辑关系的树。
-
它描述了变量的 可见性 和 生命周期。
-
GlobalScope、ModuleScope、FunctionScope、BlockScope。
-
给变量查看。
- 决定变量是住栈、住堆、还是住全局。
- 处理闭包的捕获关系。
-
就类似于 描述房子中的各个部件的逻辑关系。
- 主卧的开关能控制客厅的灯吗?(变量可见性)
- 这根水管是通向厨房还是通向市政总管道?(作用域链查找)
- 双树的纠缠
这两棵树虽然是分开的数据结构,但它们是 伴生 的。
-
伴生生长:
当解析器解析到一个 function 时:
- AST 层面:生成一个
FunctionLiteral 节点(AST长出了一个枝丫)。
- Scope 层面:
NewFunctionScope 被调用,生成一个 FunctionScope 对象,并且 outer 指针指向父级(作用域树也长出了一个枝丫)。
- 挂载:V8 会把这个
FunctionScope 挂在 FunctionLiteral 的身上。
- AST 节点说:“我的地盘归这个 Scope 管。
-
连接点:VariableProxy
还记得之前说的“小票”吗?
VariableProxy 是挂在 AST 上的节点(因为它出现在源码里)。
但它的 小票兑换 resolve 过程,是在 Scope Tree 上爬楼梯。
一旦 resolve 兑换成功,AST 上的这个“小票”就获得了一个通向 Scope Tree 上某个“槽位”的链接。
为什么要分两棵树?因为 结构 和 数据 是两码事。
-
if (true) { let a = 1 }
-
从 AST 看:这是一个 IfStatement 包着一个 Block。
-
从 Scope 看:IfStatement 本身不产生作用域,但里面的 Block 产生了一个 BlockScope。
-
有时候 AST 很复杂(嵌套很多层括号),但 Scope 很简单(还在同一个作用域);有时候 AST 很简单,但 Scope 变了(比如 static 块)。
-
AST 是为了 生成代码(怎么做)。
-
Scope Tree 是为了 查找数据(在哪里)。
-
解析器的工作,就是一边搭房子AST,一边生成Scope,并且铺好正确的链接关系,确保留在 AST 里的每一个Proxy,都能在 Scope 里找到对应的真身。
热爱学习的朋友可能又有疑问了: 为什么以前说作用域链 现在又是作用域树,到底是链还是树?
这其实是观察角度-视角的不同。
-
上帝的全局视角—— 它是“树”
站在 Global 的高度往下看:
全局下面有函数 A、函数 B、函数 C。
函数 A 下面又有子函数 A1、A2。
函数 B 下面有子函数 B1。
这时候,它们的关系是开枝散叶的,所以整体结构是 作用域树 (Scope Tree)。
-
执行时的蚂蚁视角—— 它是“链”
当你正在执行最里面的子函数 A1 时,你根本不关心隔壁的 A2,也不关心函数 B 和 C。
你只关心:我自己 --我爸爸(A) -- 我爷爷(Global)。
对于正在运行的代码来说,它只看到了一条通往全局的单行道。
这条线性的路径,就叫 作用域链 (Scope Chain)。
所以,说树,是说它的整体结构,说链,是说它的查找路径。
-
在第一大部分的第7小部分,我们首先讲了声明的解析,并用一个例子详细说明了解析过程,然后,插队讲解了几个比较重要的 而且在后续学习中需要用到的知识点,这几个知识点,即使在平时的前端开发中,也属于比较重要的。现在我们继续一起学习 声明的解析 吧。 如果对解析的流程有些忘记了朋友,可以往上翻,回看一下第一个函数的解析。
现在我们开始学习带闭包的函数的解析
function outer() {
let treasure = '大宝贝'; // 1. 声明变量
function inner() {
return treasure; // 2. 内部引用(闭包)
}
return inner;
}
-
解析外部函数
解析器进入outer函数,创建了 outerscope。
读到 let treasure 的时候,解析器和以前一样,进行登记。
“treasure 是个普通变量。按照 V8 的默认省钱规则,这种局部变量应该分配在 栈 (Stack) 上。因为栈最快,而且函数执行完,栈指针一弹,内存自动回收,多省心!”
于是,在 AST 的蓝图上,treasure 被暂时标记为:Stack Local(栈局部变量)。
它被分配了一个临时的寄存器索引(比如 r0)。
岁月静好啊。
-
解析内部函数
解析器继续往下走,看到了 function inner。
这时候,虽然 inner 可能只是预解析,但预解析器依然是需要工作的,它快速扫描 inner 的内部代码,目的是为了检查有没有语法错误,以及搜集变量引用。
扫描器读到了 return treasure。
关键时刻来了
- 生成小票:解析器生成了一个
VariableProxy("treasure")(寻找宝藏的小票)。
- 开始兑换:
- 问
InnerScope:“你有 treasure 吗?” --- 没有。
- 顺着
outer 指针往上爬,问 OuterScope:“你有 treasure 吗?” ---有!
找到了!但是,解析器并没有这就结束,它发现了一件事情:
这个 treasure 是定义在 outer 里的,但是却被 inner 这个下级给引用了!而且 inner 可能会被返回到外面去执行!
这就是 跨作用域引用。
-
强制搬家
解析器意识到有些麻烦了。
如果 treasure 依然留在 栈 上,那么等 outer 函数执行完毕,栈帧被销毁,treasure 就会灰飞烟灭。
等将来 inner 在外面被调用时,它想找 treasure,结果只找到一片废墟,那程序就崩了。
于是,解析器立马修改了 OuterScope 的蓝图,下达了 “强制搬家令”:
-
撕毁标签:把 treasure 身上的 Stack Local 标签撕掉。
-
贴新标签:换成 Context Variable(上下文变量)。
-
开辟专区:
V8 决定,在 outer 函数执行时,不能只在栈上干活了。必须在 堆内存 (Heap) 里专门开辟一个对象,这就叫 Context (上下文对象)。
- 分配槽位:
treasure 被分配到了这个 Context 对象里的某个槽位(比如 Slot 0)。
此时的内存蓝图变成了这样:
- 普通变量(如果有):依然住在栈上,用完即弃。
- 闭包变量 (
treasure):住在堆里的 Context 对象中,虽死犹生。
-
建立连接
既然变量搬家了,那 inner 函数怎么知道去哪找它呢?
在生成 inner 的 SharedFunctionInfo(这个就是在文章刚开始部分讲的,预解析时,会生成的占位符节点和一个SharedFunctionInfo相关联,SFI中有预解析得到的元信息)时,V8 会记录下这个重要的情报:
注意:本函数是一个闭包。执行时,请务必随身携带父级作用域的 Context 指针。
这就好比 inner 函数随身带着一把钥匙。
不管它流浪到代码的哪个角落,只要它想访问 treasure,它就会拿出钥匙,打开那个被精心保留下来的 Context 保险箱,取出里面的值。
-
总结一下
在解析层面,闭包不仅仅是“函数套函数”,它是一次 “变量存储位置的逃逸分析”。
- 没有闭包时:父函数的变量都在栈上,函数退栈,变量销毁。
- 有闭包时:解析器发现有内部函数引用了父级变量,强行把该变量从栈挪到堆 (Context)。
这就是为什么闭包会消耗更多内存。
并不是因为函数没销毁,而是因为本该随着栈帧销毁的变量,被迫搬到了堆里,并且必须长期养着它。
现在,再看闭包,是不是感觉看到的不再是代码,而是 V8 内存里那一个个被强行保留下来的 Context 小盒子。
-
我记得在前面某个地方,提到过,栈或context中怎么分配位置, 因为还是在解析阶段,都是画大饼阶段, 怎么来分配具体位置呢?
这个是使用 相对位置 来说的,
比如, 老板和你说 阿祖 你好好干 等咱公司有了自己的大楼,第88层出了电梯左手第一间办公室,就给你用。
旁边城武眼红了, 老板说 城武你也好好干,第188层出了电梯右手第一间办公室,给你用。
阿祖和城武感动的当晚就加班到凌晨8点整。
所以,虽然还是蓝图 还在画大饼 但是相对位置是可以确定的,类似于基址加偏移量的形式。
-
是的,现在又该无中生友了,有初学的朋友,说 ,闭包啊 就是把内部函数需要用到的外部函数的数据 都给打包封闭了。听起来似乎也可以。 那么,都包了什么东西在里面?是大包 中包 还是小包?
这个可能也不仅是初学朋友的疑惑。
那么 问题就真的来了:到底是包了多少东西?
V8 是非常抠搜的,它坚持“小包”,但有时候会被迫用中包,甚至大包。
v8在分析作用域时,会精准计算:
function factory() {
let heavyData = new Array(1000000); // 这是一个超大的数据
let lightData = '小喽啰';
function useHeavy() {
// 这个闭包用了 heavyData
console.log(heavyData.length);
}
function useLight() {
// 这个闭包只用了 lightData
console.log(lightData);
}
// 只把 useLight 返回出去了,useHeavy 根本没返回,扔了
return useLight;
}
const myClosure = factory();
-
扫描 useHeavy:发现它用了 heavyData。--- heavyData 必须进 Context。
-
扫描 useLight:发现它用了 lightData。--- lightData 必须进 Context。
关键点来了:
同一个作用域(factory)下生成的闭包,它们共享 同一个 Context 对象。
只要有一个闭包(哪怕是没被返回的 useHeavy)把 heavyData 拖进了 Context,那么这个 Context 里就实打实地存着 heavyData。
虽然只返回了 useLight,但 useLight 手里握着的钥匙,打开的是那个 包含了 heavyData 的 Context。
只要 useLight 还要活下去,那个 Context 就得活下去,那个超大的 heavyData 也就得活下去,无法被垃圾回收。
结论:打包的是 中包。同一个作用域下的所有闭包,共享同一个“包”。进了包以后,无法区分哪个被真的return出去,所以兄弟连坐。
function risk() {
let a = 1;
let b = 2;
// ... 这里还有 100 个变量 ...
return function inner() {
eval("console.log(a)"); // 沃特啊油督应?
};
}
解析器扫描 inner 时,看到了 eval。
瞬间捂着钱包痛哭:“这玩意儿能动态执行代码,它可能引用 a,也可能引用 b,甚至可能引用我还没读到的变量... 根本无法静态分析它到底要用谁!”
为了安全起见,V8 只能躺平了,
别分析了。把 risk 作用域里的 所有变量,统统打包进 Context!
这时候,就不再是按需分配了,而是真正的一锅端的大包。所有变量全部由栈转堆,性能和内存开销瞬间拉满。
这也是为什么编码提示里,都会提醒:不要用 eval 。
不仅是因为安全问题,更是因为它会打爆 V8 的逃逸分析优化,强制保留所有上下文。
-
上面我们花了很大篇幅讲了普通函数的解析。这时候肯定有朋友问:“不是说‘一类四函两变量’吗?还有三种函数(异步、生成器、异步生成器)呢?”
实际上,它们用的是同一套模具。
在 V8 里,ParseHoistableDeclaration 负责接待这四位天王。经过 ParseFunctionDeclaration 的简单包装后,处理函数字面量的入口全都指向同一个苦力:ParseFunctionLiteral。
无论是 function、function*、async function 还是 async function*,它们在 V8 眼里都是“穿了不同马甲”的普通函数。
解析器只需要在进门时做一次“安检”,根据 * 和 async 关键字打上不同的标签(Flag),接下来的解析流程——查参数、开作用域、切分代码块——完全复用。
不过,针对这三位“特权阶级”,解析器确实会偷偷做三件不同的小操作:
- 关键字变化: 在普通函数里,
yield 和 await 只是普通的变量名。但在特殊函数里,解析器会把它们识别为 操作符,生成专门的 AST 节点。
- 夹带
.generator: 对于生成器和异步函数,解析器会偷偷在作用域里塞一个隐形的 .generator 变量。 这是为了将来函数“暂停”时,能把当前的寄存器、变量值等 “案发现场” 保存在这个变量里。 所以,这几种函数 天然就是闭包,因为它们必须引用这个隐形的上下文。
- 休息点
Suspend: 解析器会在 AST 里埋下 Suspend (挂起) 节点。 这相当于告诉未来的解释器:“读到这儿别硬冲了,得停下来歇会儿,把控制权交出去。”
虽然具体解析时有不少差异,但是,有了前面我们解析普通函数的基础,再来解析这三种“魔改版”的函数,难度并不大。 我们就不具体展开了,毕竟,函数再美,看多了也会审美疲劳啊。
所以,我们现在学习声明中的 变量声明。
虽然前面一直在说 两变量,那是从规范上说的 var属于语句, 在 V8 中,let const var 这三个变量声明 ,是使用同一个解析函数处理的。
有一个核心函数叫 ParseVariableDeclarations。
不管解析器读到的是 var,还是 let,还是 const,在经过项级分流后,最终都会殊途同归,调用这个函数ParseVariableDeclarations。
下面,我们就开始变量的声明之旅吧。
-
项级分流
地点:ParseStatementListItem
场景:解析器正在一个大括号 { ... } 或者函数体里,逐行扫描代码。
-
偷看 :看看下一个 Token 是什么呢?
-
判断:
- 如果看到
var?
- 如果看到
let?
- 如果看到
const?
- 统一甩锅:
V8 发现是这三个关键字之一,立马决定:“这是声明变量的活儿!”
它不再区分你是语句还是声明,这里就直接把var也包括进来了,直接把这三兄弟打包,统一调用同一个函数:ParseVariableDeclarations。
但甩锅的时候,它给每人贴了个不同的参数:
- 遇到
var ---传参 kVar
- 遇到
let ---传参 kLet
- 遇到
const --- 传参 kConst
-
通用车间
地点:ParseVariableDeclarations
场景:这是三兄弟共用的车间。
这个函数是核心。它不仅要解析 var a = 1,还要负责解析 var a = 1, b = 2 这种连着写的,还要负责解构赋值。
步骤 1:消费关键字
解析器首先根据刚才传进来的不同参数,调用 consume() 吃掉对应的关键字(var/let/const)。
步骤 2:开启循环
因为 JS 允许 var a, b, c; 这种写法,所以这里开启了一个 do...while 循环,只要看到逗号 , 就继续。
步骤 3:解析变量名
- 解析器读取标识符(比如
a)。
- 语法检查:
- 如果是
let/const,且变量名叫 let?--- 报错 变量名想叫关键字 一边去吧。
- 如果是严格模式,变量名叫
arguments 或 eval?--- 报错,想在边缘试探 也一边去吧。
-
分头工作
地点:DeclareVariableName (在解析出名字后立刻调用)
场景:名字有了,现在要去Scope Tree(作用域树) 上登记户口了。这时候,必须根据
参数 来区分待遇。
这里是逻辑最复杂的地方,也是 var 和 let 行为差异的根源。
分支 A:手里拿的是 kVar 参数
- 向上穿墙:解析器无视当前的块级作用域
BlockScope,沿着 scope--outer_scope() 指针一直往上爬。
- 寻找宿主:直到撞到了一个
FunctionScope 或者 GlobalScope,函数作用域或全局作用域 是var的目标。
- 登记:在那个高层作用域里,记录下名字
a。
- 模式:标记为
VariableMode::kVar,嗯嗯嗯 这里是内部的东东了。
- 初始化:标记为
kCreatedInitialized(创建即初始化)。意思是:“var这家伙不用死区,直接给个 undefined 就能用。”
分支 B:手里拿的是 kLet 或 kConst 参数
- 原地不动:解析器直接锁定当前的
Scope(哪怕它只是一个 if 块)。
- 查重 :翻开当前作用域的小本本,看看有没有重名的?
- 有?-- 报错
SyntaxError: Identifier has already been declared。
- 登记:在当前作用域记录名字
a。
- 模式:标记为
VariableMode::kLet 或 VariableMode::kConst。
- 初始化:标记为
kNeedsInitialization(需要初始化)。
- 这就是 TDZ 的源头了! 这个标记意味着:在正式赋值之前,谁敢访问这个位置,就抛错。
- 注意点: 从这里能看出 let和const也会提升,只不过let和const的提升是小提升,只在自己的当前作用域里提升,提升归提升,没被真正赋值前,TDZ啊,被送会吹哨子的警卫看守着。
-
处理初始值
地点:回到通用车间
场景:名字登记完了,现在看有没有赋值号 =。
步骤 1:const 的检查
- 解析器偷看下一个 Token。
- 如果是
kConst 且后面没有 = 号?
- 直接崩了 抛出
SyntaxError: Missing initializer in const declaration。
var 和 let 会偷笑,因为它们允许没有 =。
步骤 2:解析赋值
- 如果看到了
=,吃掉它。
- 递归甩锅:调用
ParseAssignmentExpression 解析 = 右边的表达式(比如 1 + 2)。。。这里这里这里 前面超大篇幅讲过的表达式解析,看到亲切吗?
步骤 3:生成 AST 节点
这里是 AST 物理结构的生成。
由于 var 的名字已经提升走了,这里剩下的其实是一个 赋值操作。
V8 会生成一个 Assignment 节点(或者类似的初始化节点),挂在当前的语句列表中。
V8 会生成一个完整的 VariableDeclaration 节点,包含名字和初始值。
而且,如果这是 const,V8 会给这个变量打上 “只读” 的标签。如果以后 AST 里有别的节点想修改它,编译阶段或运行阶段就会拦截报错。
这个只读,是指绑定的引用不可变,如果引用的是个对象,对象内部的内容还是可以改的。
-
收尾喽
地点:循环末尾
- 逗号检查:偷看后面是不是逗号
,?
- 是 -- 吃掉逗号,回到 通用车间的步骤 3,继续解析下一个变量。
- 否 --- 结束循环。
- 分号处理:期待一个分号
;。如果没有,自动分号插入。
- 交货:返回这一整条语句的 AST 节点。
下面我们再以单个的例子来学习一下
function foo() {
if (true) {
var a = 1;
}
}
-
Scope 操作:
解析器拿到 a,开始在 Scope Tree 上进行一次爬树
它会问当前的 BlockScope(if 块):
“你是函数作用域吗?你是全局作用域吗?”
“我不是。”
“好,那我继续往上找。”
它会跳过 BlockScope,一直找到 FunctionScope(foo 函数)。
然后,调用 DeclareVariableName,把 a 登记在 FunctionScope 的花名册上。
注意:此时 a 的位置在逻辑上已经属于 foo 了,尽管物理代码还在 if 里。
-
解析器读到 = 1。
-
AST 生成:
对于 var a = 1,V8 在 AST 层面,通常会把它拆解成两部分:
- 声明 (Declaration):
var a。这部分在 AST 上被标记为“可提升”。
- 赋值 (Assignment):
a = 1。
解析器会在当前位置if 块的语句列表中,生成一个 Assignment (赋值) 节点,而不是一个单纯的声明节点。
-
Scope 树:名字被“穿墙”提到了顶层。
-
AST 树:原地留下了一个赋值节点 a = 1。
-
这就是为什么 var 有提升(名字上去了),但赋值没提升(赋值节点还在原地)。
{
let b = 2;
}
-
动作:消费 let,读到标识符 b。
-
Scope 操作:
解析器直接锁定当前的 BlockScope。
它不往上找,而是立刻查阅当前的花名册:
“这里面有叫 b 的吗?”
- 如果有:重复定义,报错 抛出
SyntaxError: Identifier 'b' has already been declared。
- 如果没有:登记
-
Scope 操作(关键):
在登记 b 的时候,V8 会给它打上一个特殊的 Mode:kLet。
并且在初始化标记位上,打上 kNeedsInitialization(需要初始化)。
在前面的三个变量一起讲的例子里讲过了,这就是 TDZ 的物理来源。这个标记表示:“在给 b 赋值之前,任何访问都要抛错。”
-
解析器读到 = 2。
-
AST 生成:
这次不像var那样需要拆分了。
解析器直接在当前位置,生成一个 VariableDeclaration 节点。
这个节点包含:
- Proxy:变量
b 的引用。
- Initializer:字面量
2。
- Mode:LET。
该节点被直接 Push 到当前 Block 的语句列表中。
-
Scope 树:名字登记在当前块,不可重复,标记为死区状态。
-
AST 树:原地生成一个完整的 VariableDeclaration 节点。
还剩下const了,const 的流程和 let 几乎一模一样,只有两个额外的检查环节。
第一,必须带初始值
- 在解析完变量名之后,解析器会立刻偷看下一个 Token。
- 如果不是
=?
- 没有初始化,报错 抛出
SyntaxError: Missing initializer in const declaration。
const 变量出生必须带值,这是语法层面的规定。
第二, 只读属性
-
Scope 操作:
在登记const的变量时,它的 Mode 被标记为 kConst。
这表示在 Scope 的记录里,这个变量是 Immutable 不可变 的。
如果 AST 的其他地方试图生成一个 Assignment 节点去修改const声明的变量,虽然解析阶段可能不会立刻报错(有时要等到运行时),但是后续一定会在写入只读变量的操作时,被拦截并抛错。
-
上面讲了var let const 三种变量的解析。我们继续声明的解析,还有一个类。
class Hero {
name = '阿祖'; // 1. 实例字段 (Field)
static version = '1.0'; // 2. 静态属性 (Static)
constructor(skill) { // 3. 构造函数
this.skill = skill;
}
say() { // 4. 原型方法
return '我是' + this.name;
}
}
-
环境初始化
当解析器读到class关键字的时候,还没看到内容,就必须先做三件事。
- 强制开启严格模式
- 解析器将当前的
language_mode 标志位强行设置为 kStrict。
- 一旦跨过
Hero { 这道门槛,所有严格模式的规则立即生效(比如禁用 with,禁用arguments 和参数不再绑定等)。
- 创建类作用域
- V8 调用
NewClassScope,创建一个新的作用域对象。
- 户籍登记:解析器读到标识符
Hero。它立刻在这个新的作用域里,声明一个名字叫 Hero 的变量。
- 锁起来:这个变量被标记为
CONST(常量)。这表示在类体内部,Hero = 1 这种代码会在解析阶段直接报错。
- 目的:这是为了让类内部的方法能引用到类本身(自引用)。
- 初始化列表
- 解析器在内存里准备了三个空的列表(List),用来分类存放即将切割下来的不同部位,像超市里鸡腿 鸡翅 鸡杂 分开摆盘:
instance_fields (实例字段列表):存放 name = ... 这种。
static_fields (静态字段列表):存放 static version = ... 这种。
properties (方法属性列表):存放 say(), constructor 这种。
-
开始解析
现在,解析器进入大括号 { ... },开始扫描。
name = '阿祖'; —— 实例字段的解析
- 识别 Key:解析器读到
name。
- 偷看 :往后偷看一眼,发现是
=。
- 判定:这不是方法,这是一个 Field (字段)。且没有
static,所以是 Instance Field (实例字段)。
- 解析:
- 解析器把
= 后面的 '阿祖' 作为一个 表达式 进行解析。
- 生成一个
Literal 字符串节点。
- 包装:
- 关键:消费完了以后,V8 不会把
'阿祖' 直接扔掉。它会创建一个 "合成函数" (Synthetic Function) 的外壳。
- 为什么要包一层? 这是 V8 为了隔离作用域而采用的策略。字段初始化表达式里可能会有
this,或者复杂的逻辑。通过封装成一个独立的函数壳,V8 确保了它和构造函数的参数(比如 skill)互不干扰,这也符合 JS 规范:字段定义本来就看不见构造函数的参数。
- 划重点:
name 的值怎么算,被封装成了一个可以在未来执行的函数。
- 这里需要注意,= 号后面的值,并不是一次性使用,有可能被使用很多次,虽然我们例子中是 阿祖,但是 也可能是其他包含逻辑的计算值,所以,我们需要的不是值,而是如何生成这个值的 整个逻辑, 因此 解析出来以后,给它包上一层带独立作用域的函数壳。
- 归档:把这个合成函数扔进
instance_fields 列表。
static version = '1.0'; —— 静态字段的解析
-
识别:读到 static 关键字。
-
标记:开启 is_static 标志位。
-
识别 Key:读到 version。
-
偷看:看到 =。
-
判定:这是一个 Static Field (静态字段)。
-
解析与归档:
- 解析
'1.0' 生成字符串节点。
- 同样包装成一个“合成函数”。
- 扔进
static_fields 列表。
- 注意:这个列表将来是要挂在
Hero 构造函数对象本身上的,不是挂在 this 上的。
constructor(skill) { ... } —— 核心内容的解析
- 识别:读到
constructor 关键字。
- 判定:这是类的 核心构造函数。
- 解析函数体:
- 解析参数
skill。
- 解析代码块
this.skill = skill。
- 生成一个
FunctionLiteral 节点。
- 归档:虽然它是核心内容,但在 AST 组装前,它暂时被存在一个叫
constructor_property 的特殊槽位里,等待后续的组装。
say() { ... } —— 原型方法的解析
- 识别:读到
say,后面紧跟 (。
- 判定:这是一个 Method (方法)。
- 属性描述符生成 (Property Descriptor):
- 这是类和对象最大的不同点。V8 会盘算着
writable: true
configurable: true
enumerable: false (类的方法默认不可枚举)
- HomeObject 绑定:
- 解析器会给
say 函数标记一个 HomeObject。这是为了如果你在 say 里用了 super,它知道去哪里找父类。
- 归档:把生成的
say 函数节点,扔进 properties 列表。
-
进行脱糖
扫描完 },所有的配件都摆好了。马上开始的,这就是传说中的 脱糖 过程。
类是语法糖,现在,我们要脱糖。
- 拿出刚才解析好的
constructor 函数节点。
- 定位:
- V8 寻找函数体的 起始位置。
- 如果有继承 (
extends),位置在 super() 调用之后(因为 super 返回前 this 还没出生)。
- 没有继承,位置就在函数体的 最前面。
- 添加:
- V8 把
instance_fields 列表里的内容拿出来(那个 name = '阿祖' 的合成函数)。
- 它将其转化为赋值语句 AST:
this.name = '阿祖'。
- 它把这条语句 插入 到
constructor 原本的用户代码 this.skill = skill 之前。
此时,在 V8 的内存 AST 中,构造函数实际上变成了这样:
// V8 内存中的构造函数(伪代码)
function Hero(skill) {
// --- V8 添加的字段初始化逻辑 ---
// 注意:这里是一个隐式的 Block
// 是因为这里是由合成函数转化的,包含了逻辑 也包含了独立的作用域
this.name = '阿祖';
// -----------------------------
// --- 用户写的逻辑 ---
this.skill = skill;
}
现在构造函数改造完毕,V8 开始组装最终的 ClassLiteral 节点。
- 挂载构造函数:把改造后的
Hero 函数放c位。
- 挂载原型方法:
- 遍历
properties 列表。
- 拿出
say。
- 生成指令:在运行时,将
say 挂载到 Hero.prototype 上,并设置 enumerable: false。
- 挂载静态字段:
- 遍历
static_fields 列表。
- 拿出
version = '1.0'。
- 生成指令:在类创建完成后,立刻执行
Hero.version = '1.0'。
- 关联作用域:把最开始创建的
ClassScope 关联到这个节点上。
-
完成喽
尽管我们写的是一个class,但是,实际的解析过程如下
- 开启严格模式。
- 创建一个叫
Hero 的常量环境。
- 定义一个叫
Hero 的函数。
- 函数体内:先执行
this.name = '阿祖'。
- 函数体内:再执行
this.skill = skill。
- 定义一个叫
say 的函数。
- 把它挂到
Hero.prototype 上,设为不可枚举。
- 定义一个叫
version 的值。
- 返回这个
Hero 函数。
你会发现,解析器最终生成的是一个表示类的 ClassLiteral,但也是仅是名字而已,其他的所有内容,已经脱糖为函数、赋值、原型挂载 这些js语法。
所以,从 V8 的实现上来说,类解析的本质,就是解析器通过引入 合成函数 和 代码植入 等手段,把现代化的语法糖,翻译成了底层引擎能理解的函数、作用域和原型操作。
-
我们前面首先学习的就是语句里的兜底表达式的解析,然后是声明中的 函数 变量 类, 现在就还剩语句中的可以用关键字甩锅的部分了。
我们回到ParseStatementListItem 的分流路口,如果来的 Token 不是 class,不是 function,也不是 var/let/const,那它极大可能就是一个普通的 语句Statement。
解析器大手一挥:“去吧,找 ParseStatement。”
ParseStatement是语句解析的总调度
场景:这是普通语句的总调度中心。
逻辑:查表分发(Lookahead Dispatch)。
解析器盯着当前 Token 的脸,看关键字是什么,然后决定甩锅给谁:
- 看到
{? - 甩给 ParseBlock(代码块)。
- 看到
if? -甩给 ParseIfStatement(条件判断)。
- 看到
for/while/do? -甩给循环解析家族。
- 看到
return/break/continue? -甩给跳转解析家族。
- 啥关键字都不是?(比如
a = 1 + 2;) -甩给 ParseExpressionStatement(表达式语句)。这是兜底的,也是最常见的,也是我们花了大力气学习过的。
代码块解析:{ ... }
当解析器看到 { 时,它知道这是一个 Block (块)。
解析流程:
- 消费:吃掉
{。
- 递归:此时,仿佛又回到了世界起源。解析器会再次调用那个最最最核心的循环驱动者 ——
ParseStatementList。
- 这就是为什么代码可以无限嵌套:块里套块,套娃套娃娃。
-
消费:吃掉 }。
注意:透明作用域 (Scope Optimization)
这里有个比较重要的地方,我们在写代码时,看到 {...} 就会本能地觉得:“这有一个块级作用域”。
但在 V8 眼里,不一定。 V8 非常抠搜,它会根据块里的内容决定要不要建墙--块级作用域。
场景 A:透明的框
{
var a = 1;
console.log(a);
}
V8 扫描这个块,发现里面只有 var(或者普通语句),没有 let/const/class。
V8 会想:“欸,只有 var 这种穿墙怪?或者只是普通的计算?那我没必要专门申请一个 BlockScope 对象浪费内存了。”
结果就是 这个 Block 在 Scope 树上是 透明 的。AST 上虽然有 Block 节点,但它不对应任何 Scope。变量 a 直接登记在所在的函数作用域里。
场景 B:实体的墙
{
let b = 1;
}
V8 扫描到了 let。
V8 拱手:“新贵小王子,必须给待遇。”
结果就是 V8 才会真的创建一个 BlockScope,把 b 关在里面。
所以,代码块 {} 在 AST 上肯定是个 Block 节点,但在 Scope 树上不一定有对应的节点。
这个问题,在前面我们好像已经讲过两三次了,多讲一次,就当加深印象了。
条件判断:if
当解析器看到 if 时,甩锅给 ParseIfStatement。
解析流程:
- 消费:吃掉
if,吃掉 (。
- 条件:调用
ParseExpression 解析条件(比如 a > 1),拿到 Condition 节点。
- 消费:吃掉
)。
- Then 分支:调用
ParseStatement 解析 then 的部分。
- Else 分支:
- 偷看:后面有
else 吗?
- 有:吃掉
else,调用 ParseStatement 解析 else 的部分。
- 没有:那
else 部分就是空的。
遇到语法歧义问题匹配哪个呢?
if (a)
if (b) x++;
else y++;
这个 else 到底属于哪个 if?是属于 if(a) 还是 if(b)?
V8使用 “贪婪匹配” 原则:
else 总是匹配最近的、还没配对的那个 if。
所以在 AST 里,这个 else 是挂在内层 if (b) 后面的。如果你想让它属于外层,必须显式地加 {},所以 ,从写法上减少这些歧义是最好的。
循环解析:for
while 和 do-while 比较简单,我们重点讲最复杂的 for 循环。
当解析器看到 for,甩锅给 ParseForStatement。
AST 的结构:
V8 会生成一个 ForStatement 节点,它有 4 个插槽:
-
Init (初始化):比如 let i = 0。
-
Cond (条件):比如 i < 10。
-
Next (步进):比如 i++。
-
Body (循环体):比如 { console.log(i) }。
嗯嗯嗯,这里又有个面试官容易被吊打的地方了
就是 for 循环作用域问题,V8 在这里做了比较复杂的处理。
如果这里用的是 var,V8 根本不管,直接扔给外层函数作用域。
但如果是 let,V8 必须制造出 “多重作用域” 的效果。
在解析 for(let ...) 时,V8 会在 AST 和 Scope 树上构建出 两层 甚至 N+1 层 作用域:
-
循环头作用域 (Loop Header Scope):
- 循环体作用域 (Loop Body Scope):
- 迭代作用域 (Per-Iteration Scope):
。。。。。。看起来似乎挺复杂,实际上也不是很简单,所以我们需要仔细耐心的学习。
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
分析这个例子
第一阶段:主线程 循环阶段
因为 var 声明的 i 没有块级作用域,它是一个全局变量(或函数作用域变量)。在这个内存里,只有一个 i。
- 初始化:
i = 0。
- 检查
0 < 3?是的。
- 遇到
setTimeout:浏览器把“打印 i”这个任务记在宏任务队列的小本本上。注意:此时不执行打印,也不存 i 的值,只是记下“回头要找 i 打印”这件事。
- 步进:
i 变成 1。
- 检查
1 < 3?是的。
- 遇到
setTimeout:再记一笔“回头找 i 打印”。
- 步进:
i 变成 2。
- 检查
2 < 3?是的。
- 遇到
setTimeout:再记一笔“回头找 i 打印”。
- 步进(关键步骤):
i 变成 3。
重点来了: 此时循环结束了,变量 i 停留在什么值? 答案是 3。 因为它必须变成 3,条件判断 i < 3 才会失败,循环才会停止。
第二阶段:异步队列回调 打印阶段
现在主线程空闲了,Event Loop 开始处理刚才记在小本本上的 setTimeout 任务。
- 第 1 个回调运行:
console.log(i)。
- 它去内存里找
i。
- 这时候的
i 是多少?是 3。
- 打印:3。
- 第 2 个回调运行:
console.log(i)。
- 它还是去同一个内存地址找
i。
i 还是 3。
- 打印:3。
-
第 3 个回调运行:
这个例子的重点:
一:“循环到 2 就结束了,所以 i 应该是 2”
- 实际情况:循环体确实只执行到
i=2 的时候。但是 for 循环的 i++ 是在循环体执行之后执行的。最后一次,i 从 2 变成了 3,然后判断 3 < 3 失败,才退出的。所以 i 的最终尸体是 3。
二:“setTimeout 会捕获当时的 i”
- 实际情况:
var 不会捕获快照。因为 var 只有一个共享的 i,闭包引用的是引用(地址),而不是值(快照)。等到打印的时候,大家顺着地址找过去,看到的都是那个已经变成 3 的 i。
我们再来看这个例子
for (var i = 0; i < 3; i++) {
let x = i;
setTimeout(() => console.log(x), 0);
}
这里有两个变量:
var i:公共大挂钟
-
定义位置:for 循环头部。
-
性质:var。
-
住址:函数作用域(或者全局)。它就像挂在墙上的唯一的一个大时钟。不管循环跑多少次,大家都看这同一个时钟,它的指针一直在变(0 - 1 - 2 - 3)。
let x 私人的手表:
-
定义位置:循环体 { ... } 内部。
-
性质:let。
-
住址:Block Scope(块级作用域)。它就像是你手里拿的记事本。每次循环,V8 都会撕一张新的纸(创建新作用域)给你。
这个例子的核心逻辑在于 let x = i;。 对于 v8来说 就是 “请把墙上那个公共时钟(i)当前的时间,复印一份,写在我这张新的纸(x)上。”
第一轮循环 (i = 0)
- 公共时钟
i:指向 0。
- 进入房间:V8 遇到
{,创建一个全新的 Block Scope A。
- 执行
let x = i:
- V8 在 Scope A 里创建变量
x。
- 读取外面的
i (0)。
- 赋值:
x = 0。
- 闭包生成:
setTimeout 里的箭头函数生成。
- 关键点:它捕获的是谁?是 Scope A 里的
x。
- 此时,这个闭包手里紧紧攥着 x=0 的照片。
第二轮循环 (i = 1)
- 公共时钟
i:变成了 1(注意:i 还是那个 i,只是值变了)。
- 进入房间:V8 遇到
{,创建一个全新的 Block Scope B(和 A 没关系)。
- 执行
let x = i:
- V8 在 Scope B 里创建变量
x。
- 读取外面的
i (1)。
- 赋值:
x = 1。
- 闭包生成:
- 生成第二个箭头函数。
- 它捕获的是 Scope B 里的
x。
- 这个闭包手里攥着 x=1 的照片。
第三轮循环 (i = 2)
- 公共时钟
i:变成了 2。
- 进入房间:创建 Block Scope C。
- 执行
let x = i:
- 闭包生成:
- 捕获 Scope C 里的
x。
- 手里攥着 x=2 的照片。
循环结束了。
-
公共变量 i:变成了 3。如果这时候有人打印 i,那就是 3。
-
刚才那三个闭包(定时器回调),根本不关心 i 是多少。
当 0ms 之后,定时器触发:
- 回调 1:拿出 Scope A 里的
x - 打印 0。
- 回调 2:拿出 Scope B 里的
x - 打印 1。
- 回调 3:拿出 Scope C 里的
x - 打印 2。
这个例子是利用了 let 在 Block 里的生命周期。
-
var i 负责在外面跑动,不断变化,维持循环的进行。
-
let x 负责在里面定格,每次循环都创建一个新的实例,把那一瞬间的 i 值给“固化”下来。
上面是简单的讲了一下var 和let配合的正确方式。 现在,我们回到使用let的例子
for (let i = 0; i < 3; i++) {
let x = i;
setTimeout(() => console.log(x), 0);
}
这个才是我们for循环重点的例子。
当解析器读到 for (let i ...) 时,它在 Scope Tree 上并不是简单地挂一个 BlockScope,而是构建了一个精密的层级。
第 1 层:外层作用域 (Outer Scope)
这是 for 循环所在的地方(比如函数作用域)。没有什么特殊的。
这是关键层!
为了满足“每次循环都是新 i”的变态要求,V8 会悄悄的把代码进行重写
伪代码
{ // 1. 循环头作用域 (Header Scope)
let i = 0; // 真正的 i 声明在这里
// 循环开始
loop_start:
if (i < 3) {
// 2. [v8偷摸施法] 迭代作用域 (Iteration Scope)
// V8 会在每次进入循环体前,悄悄的创建一个新作用域
// 并且把当前的 i 值,"复印" 给一个临时变量
{
let _k = i; // 影子变量,捕获当前的 i
// 3. 循环体作用域 (Body Scope)
{
let x = _k; // 用户写的 x = i,实际上变成了 x = _k
setTimeout(() => console.log(x), 0);
}
}
// 步进操作
i++;
goto loop_start;
}
}
这段伪代码很简单,解析器在分析作用域时,识别出 for 头部定义了 let,并且循环体内有闭包引用了这个 let。
于是,它悄悄开启自己的魔法-迭代的作用域
所以
- 物理上:
i 确实只有一个,在 Header Scope 里,不断 ++ 变成 0, 1, 2, 3。
- 逻辑上:每次进入大括号,V8 都会偷偷创建一个 影子作用域。
- 复印:在这个影子作用域里,V8 会把此刻的
i 的值,赋值给一个新的隐藏变量 伪代码里我们叫它 _k 。
-
捕获:循环体里的闭包,实际上捕获的不是那个一直在变的 i,而是这个 永远不会变的影子变量 _k。
下面,我们再详细的走一下流程:
步骤 1:解析头部 for (let i = 0;
- 消费:
for, (, let, i。
- Scope 操作:创建 Loop Header Scope。
- 登记:在 Header Scope 里登记变量
i。
- AST:生成
ForStatement 节点,把 let i = 0 挂在 Init 插槽。
步骤 2:解析条件与步进 ; i < 3; i++)
- 解析:在 Header Scope 的环境下解析
i < 3 和 i++。
- 关联:这里的
i 指向 Header Scope 里的 i。
步骤 3:解析循环体 { ... }
- 消费:
{。
- Scope 操作:创建 Loop Body Scope。
- 连接:Body Scope 的爸爸是 Header Scope。
步骤 4:解析 let x = i
- 登记:在 Body Scope 里登记变量
x。
- 查找
i:
- Body Scope 里有
i 吗?无。
- Header Scope 里有
i 吗?有!
- 关键判定:解析器发现
i 是 Header Scope 里的 let 变量,而且正在被内部作用域引用。
- 打个标记:解析器给
i 打上 "需按迭代拷贝 (Copy on Iteration)" 的标签。
步骤 5:解析闭包 setTimeout(...)
- 闭包引用了
x。
x 引用了 i(实际上是那个影子的 i)。
- 解析器确认:这不仅是个闭包,还是个 Loop 里的闭包。必须强制把这些变量分配到 堆内存 (Context) 中,不能留在栈上。
这个for讲起来很费劲的吧
是因为表面上只声明了一个 i,
但实际上(AST/Scope) V8 构建了 Header Scope(放真正的 i)和 Body Scope(放循环体)。
运行的时候 V8 通过 影子变量拷贝技术,在每一轮循环里都生成了一个新的、只属于这一轮的 i 的副本。闭包锁死的是这个副本,而不是外面那个一直在变的本体。
我们也甩个锅,甩给规范:
为什么 for (let ...) 比 for (var ...) 复杂?
因为规范要求对 let 循环变量实现 per-iteration(每次迭代)语义:表面上你只写了一个 i,但每轮迭代要表现为一个新的绑定副本,以便闭包捕获到的是该轮的快照。var 没有块级绑定(它是函数/全局作用域的共享绑定),因此不会产生快照效果。
跳转语句:return
return、break、continue 的解析逻辑都很直白:“吃掉关键字 --检查分号”。
但 ParseReturnStatement 有一个巨大的坑,叫做 ASI (自动分号插入)。
看这段
return
true;
解析器读到 return 后,它的动作是这样的:
- 偷看:偷看下一个 Token。
- 发现:哎哟,是一个 换行符 (LineTerminator)。
- 判定:根据 JS 语法规则,
return 后面不能跟换行符。既然你换行了,我就当你写完了。
- 插入:V8 强行在这里插入一个分号
;。
- 结果:代码变成了
return;(返回 undefined)。下面的 true; 变成了永远执行不到的废话。
这就是为什么要强调的:return 的值千万别换行写!
兜底:表达式语句
这个就不用讲了,都讲的头晕了。
二. Ignition解释器(上)
这是第二篇的上半部分,我们主要学习ignition V8的解释器的一些基础和前置知识。
这部分内容,主要是以了解为主,所以在学习的时候,除了第一篇中说的 有些细节做了省略 有些边界情况做了简化表述以外,也不需要过多的深入。 读完了就好。 目的就是对于ignition解释器的一个祛魅。
当然,感兴趣的朋友也可以认真阅读,本文内容依旧保持一定的深度,依旧是力求高准确性,符合规范,贴合实现。 但需注意的是,为了文章的可读性,有可能在前面仅做简化的通俗的描述,在后面做了详细的讲解描述,所以,可能的情况下,请尽量阅读全文。
通过上一篇的解析,我们手里已经拿到了一份完整的AST抽象语法树。 但是对于cpu来说,它只认指令。
在早期版本的 V8 中,JavaScript 代码在解析完成后,会直接被编译成本机机器码执行。这种方式运行得很快,但机器码体积通常比较大,也不够灵活。
在后来,直到现在,V8不再直接生成庞大复杂的机器码,而是生成了一种非常紧凑 小巧的中间代码,就是 字节码 bytecode。
但是cpu也不认识字节码,V8使用 ignition 模拟了一个可以运行字节码的环境,相当于一个极其轻量的虚拟机。
1 . ignition是什么
Ignition 是 V8 引擎里的字节码解释器,它把 JavaScript 的 AST 编成紧凑的字节码,然后在虚拟机里解释执行,同时收集各种信息,供优化器生成更快的机器码。
ignition的工作 最主要是下面三个:
翻译:把 AST 翻译成字节码。
执行:在一个虚拟的寄存器机里执行这些字节码。
收集情报:在执行过程中,偷偷记录类型信息,为后续的优化做准备。
Ignition 在软件层面模拟了一套 类 CPU 的工作模式:
它不是只用栈,也不是纯寄存器机,而是采用“虚拟寄存器 + 累加器(Accumulator)”的模式。
这里的寄存器并不是 CPU 里的硬件寄存器,而是函数栈帧里的一些槽位(slots),只是把它抽象成寄存器来使用,看起来更像 CPU 工作方式,但成本非常低。
Ignition 还设计了一个解释器独占的累加器 acc。大多数运算的中间结果都会临时存放在 acc 里,这样指令只需要携带一个参数,就不用全部写出所有的目的寄存器,字节码就会变得非常短小。
当一个函数开始执行时,Ignition 会在内存的 栈 (Stack) 上划出一块地盘, 跑起来的时候,本质上就是在那块栈内存上,不停地把数据从一个位置搬到另一个位置,计算,然后再搬回另一个位置,就是这样搬来搬去。ignition操作的并不是真正的cpu内的寄存器,它操作的是内存位置/槽位。
2 . 几个简单的指令
ignition就像一个勤劳干活的老师傅,他有一个随身腰包,他不管干什么活,不管需要用什么工具,都是优先用随身腰包里的东西, 干完活得到的结果,也顺手塞回自己的随身腰包里。
老师傅有个随身腰包, 不管干什么,都优先使用随身腰包。
摆放各种材料的原料架,一格一格的, r0 ,r1,r2.。。。编着号,上面摆满了原材料。
好了,这就是ignition的架构。就是这么简单。
这个随身腰包,就是老师傅最重要的东西了。师傅偷懒全靠它了。
假如没有这个随身包,想象一下,老板下达指令非常啰嗦麻烦:
把 r1原料架 的东西 和 r2原料架 的东西拿下来,拼装好,然后再放回 r3原料架 去
(类似于指令:ADD r1, r2, r3)
而老师傅有了随身包,就简单了,老板只需要喊一声:
“去把 r2 原料架的东西拼进来!”
(指令:Add r2)
老板完全不需要废话“跟谁拼”(默认跟包里的东西拼),也不需要废话“拼完放哪”(默认拼完还放在包里)。
这就是 Ignition 的核心节省秘籍。通过强行规定“随身包优先”,每一条指令都能省掉好几个参数的位置。成千上万行代码跑下来,省下的内存就是一个天文数字。
这个老师傅的随身包 就是累加器,原料架 就是内存位置/槽位
-
Lda (Load Accumulator) ---Lda 是个前缀,实际使用时,后面可跟很多合法的数据来源,比如 LdaSmi、LdaConstant、LdaUndefined、Ldar 等等。。。
这是使用最高频的指令,因为所有的活 都得在随身包里干,所以第一步,基本上都是把东西装进包里。
- 指令:
Ldar r1 (Load Accumulator from Register r1)
- 动作:老师傅走到 r1 原料架,把那里的材料拿下来,塞进随身包里。
- 状态:此时,随身包里的东西 = r1 的东西。
- Ldar 这个指令 很好记忆,ld是装载,a是累加器,r是寄存器,ldar就是装载累加器from寄存器, 把寄存器的内容装进累加器。
- Ldar r1 就是把寄存器r1里的东西 装进累加器。
- 老师傅把r1的原料装进随身包里。
-
Star (Store Accumulator)
活干完了,结果总不能一直烂在包里,得腾出地方干下一票,或者把结果存起来。
- 指令:
Star r2 (Store Accumulator to Register r2)
- 动作:老师傅把随身包里刚刚加工好的成品掏出来,放到 r2 原料架上去。
- 状态:此时,r2 原料架的东西 = 随身包里的东西。
- star 这个指令,也很好记忆,st是储存,a是累加器,r是寄存器,star就是存储累加器里的东西到寄存器。
-
Add / Sub ...
这是真正的关键步骤。
- 指令:
Add r0
- 动作:老师傅走到 r0 原料架,拿个东西,直接跟随身包里原本的东西进行合体(相加)。
- 状态:随身包里的东西 = 原包里的值 + r0 的值。
- 重点:注意 结果依然留在包里,老师傅并没有急着去储存结果。
指令都很简单,ld load,st store,a accumulator,r register
类似的 ldglobal stglobal ldarg0 ldcurrentcontext 也都差不多,
基本上都是 动作+对象 的模式。
另外需要注意的是,累加器 Accumulator 通常会写作 acc。虽然它叫“累加器”,但千万不要理解成它只能做加法运算。从本质上讲,acc 就是解释器里唯一的“通用临时寄存器”:当前这条字节码要处理的值,或者运算后的中间结果,几乎都会暂时放在这里。
acc 里可以装任何 JavaScript 的值,比如常量、小整数、字符串、对象引用、undefined 等。之所以要设计这么一个“统一的临时位置”,就是为了让大多数字节码只需要写明“另一个参与运算的对象是谁”,而不用每次都额外声明多个寄存器参数,从而让字节码更短、更规整,也让解释器实现更简单。
Ldar r1 ; acc = r1
Add r0 ; acc = acc + r0
Star r2 ; r2 = acc
累加器acc的变化。 是不是非常简单。
3 . 栈帧和槽位
在第一部分解析篇里 我们也提到过 槽位 这个术语, 上面又提到了,那么,槽位到底是什么呢?
槽位(slot)就是栈帧里一格一格固定大小的“存储单元”或“格子”,用于按索引存放函数的参数、局部变量、临时值、以及其它元数据。 它不仅是“位置”,还隐含了大小、类型(通常是指针/Tagged 值)、地址计算规则和生命周期语义。
内存可寻址的最小单位是 8bit 即一字节, 虽然最小使用单位是8bit 即一字节。但是,因为需要字节对齐 和 机器指针大小的要求,所以 在32位系统上, 需要4字节表示指针, 64位系统上 需要8字节表示指针。 每个槽位的大小,也是按照操作系统机器指针的大小来划分的。 即 64位系统,一个槽位 占 8个字节 。 只有这样,64位系统使用指针寻址时,8字节,即64bit,才能够装得下一个指针。
所以,每个槽位(slot)占 8个字节大小。
当函数开始执行时,运行时runtime在栈上为它分配一段连续内存作为栈帧。在 Ignition 中,栈帧大致分为三部分:参数区、固定头部, 工作区 。栈帧里的每个“格子”称为槽位(slot),按索引存放局部变量和临时值,槽位的大小通常等于机器指针大小, 64 位下为 8 字节。
特别注意:前面我们一般并没有明确的区分栈帧里槽位和字段,现在明确一下,我们仅仅是把工作区中按索引的格子(r0, r1, r2…)称为槽位(frame slots)。固定头部里的字段(返回地址、saved FP、Context、BytecodeArray 等)我们不把它们称为槽位;参数一般称为 argument slots(a0,a1)或单独描述。
严格从 V8 内存视角来看,栈帧中的每一个 8 字节存储单元(无论是 Context、返回地址还是局部变量)在源码中都统称为 Slot。
但在解释器字节码的语句环境中,为了区分功能:
- 我们将固定位置、用途单一的区域称为 “固定头部字段”(如 Function, Context)。
- 我们将用于存储局部变量和临时结果、通过索引动态访问的区域特称为 “寄存器槽位” 或 “局部变量槽位” 或简称为 槽位 。
- 有时候可能会有混用,将栈上某字段也称为槽位,从规范从v8源码上来说,完全没有错误,只是因为手抖或者写快了,没有按照我们通常的按功能区分的约定称呼。
- 当然,你可以按照自己的意愿,区分或者不区分,都是正确的。前提是 你要知道观察的视角的不同。
下面我们详细介绍一下栈帧的结构。
栈的生长顺序,是从高地址到低地址,即入栈早的在高地址, 最后入栈的处于栈顶 在低地址。
我们首先介绍个术语:Tagged Pointer
Tagged Pointer 标签指针
64位系统中,每个槽位都是 8 字节(64 位),V8 在这里面存数据时,使用了一个编码技巧,叫做 Tagged Pointer (带标签的指针)。
在静态语言立,比如 C++ 这种静态语言,编译器知道变量是 int 还是 Object*。但在 JavaScript 中,类型是动态的。如果 V8 为每个变量额外存一个“类型字段”,内存消耗会翻倍。 V8 的做法是:把类型信息直接编码进这 64 位数据本身。
方法就是复用“对齐留下的低位” 在 64 位平台上,内存地址通常是 8 字节对齐 的。合法地址的二进制形式,最低的几位通常都是 0。 V8 就是使用了这些闲置的低位,用来打上类型标签Tag。
Tagged Value 的分类: V8 把槽位里的机器字统称为 Tagged Value,根据低位标签不同,分为两类:
Smi (Small Integer,标签立即量)
- 特征:最低位通常为
0。
- 含义:这 不是 指针,这 64 位数据本身就存着一个整数。
- 优势:整数直接住在栈上,不需要去堆里申请内存,速度极快。
- 还原:使用时,通过右移 (Shift) 运算去掉标签,就能得到整数值。
Tagged Pointer (堆对象引用)
- 特征:最低位通常为
1。
- 含义:这是一个指向堆内存中对象(HeapObject)的 强引用。
- 注意:它不能直接当做物理地址用。
- 还原:使用时,必须通过位掩码 (Mask) 运算去掉标签(Untag),还原成纯净的内存地址,才能去访问堆里的对象。
这样使用Tag以后, Tagged value 就像给数据穿了一件“马甲”。Ignition 看一眼马甲(标签位),就知道是整数还是对象。虽然使用前必须“脱马甲”(Untag),但这带来的性能提升和内存节省是巨大的。
另外需要注意,使用tag标记, 能直接判定的类型集合很小(主要是 Smi 或HeapObject),更细的类型,需要读取对象头来获取。
那么 ,栈帧的结构是怎样的呢?它的组成如下:
-
第一层 参数区 Arguments
当调用一个函数时,调用者Caller需要给它传实参,同时还有个隐形参数this,这些内容,都在栈帧的第一层参数区。
- Receiver (this):
- 这是个隐形参数。当你写
obj.func() 时,obj 就是 Receiver。它是参数列表里的隐形老大哥。
- Arguments (
a0, a1...):
- 这就是
function foo(x, y) 里的 x 和 y。
- Ignition 给它们编的号是
a0, a1...
- 要注意的是,这里的
a 代表 Argument,不要和 Accumulator 搞混了。
-
第二层 固定头部 Fixed header / fixed frame part
这是整个栈帧中最重要、最关键的区域。
既然 Ignition 是个软件模拟的 CPU,那 CPU 运行时需要的那些状态 比如“我现在运行到哪一行了?”、“我的环境是谁?” 。。。等等信息, 都是存在哪的?
没错,就存在这儿,固定头部。
每个函数栈帧的中间,都夹着这么一块雷打不动的区域,保存着维持虚拟机运行的元数据
它里面的主要内容:
-
Return Address (返回地址):
- 作用:等这个函数执行完,底层调用栈就会根据这个返回地址,跳回调用处继续执行。
- 这里腰注意,返回地址, 是控制流,是返回的应该到代码的哪个位置去继续执行。
-
Caller's Frame Pointer (上一层栈帧指针):
-
作用:链表指针。当前函数执行完、调用者的栈帧在哪里?
-
这样依旧要注意,这个指针,指的是数据,上个字段返回地址,是控制流的返回,这里
-
的上层栈帧指针,是控制流返回以后,继续执行, 应该从哪里去找变量,返回的是那个
-
栈帧,可以理解为数据。
-
在理解上,还可以大致认为,
-
返回地址是等这个函数执行完,要回到哪一行继续执行代码,也就是控制流该跳回哪里。这是时间上的返回 ,代码继续从哪里跑。
上一层栈帧指针是调用我的那个函数,它的栈帧从哪里开始?
这是空间上的返回 ,要去哪一块内存里继续访问局部变量和作用域数据。
-
Context (上下文指针)
如果函数里用到的变量是自己的 let a,直接去栈上找(r0)。
但如果用到了闭包变量(外层函数的变量),Ignition 必须拿着这个 Context 指针,去堆里的上下文链表上一层层找。
-
地位:它是连接 “栈世界(临时数据)” 和 “堆世界(持久数据)” 的唯一桥梁。
-
这个概念非常重要,值得我们深入了解。另外插一句,虽然说 这整个部分都可以了解为主,但是如果认真学习,能够掌握,还是有很大的用处。比如这个栈帧,对于js开发还是很重要的。
-
上面有个上一层栈帧指针,这里又有个上下文指针,怎么正确而深入的理解他们呢?
-
上一层栈帧指针是“动态调用链”
-
回答的问题: “我是被谁调用的?”
-
指向哪里: 指向栈(Stack)上的上一级栈帧。
-
作用: 函数执行完(return)后,底层会根据返回地址跳回调用者继续执行,而上一层栈帧指针则用来恢复调用者的栈帧布局,用于继续访问它的局部变量等数据。
Context 是“静态作用域链”
-
回答的问题: “我是被定义在哪里的?”
-
指向哪里: 指向堆(Heap)上的 Context 对象。
-
作用: 它是数据流的查找路线。当函数访问一个不在自己内部的变量(自由变量)时,V8 会顺着这条链去查找。
-
理解的关键点
-
上一层栈帧指向的是栈内存:栈帧是临时的,函数一返回,栈帧就销毁了。
Context 指向的是堆内存:这是为了实现 JavaScript 的闭包特性。
-
Function / Frame Marker (函数/帧类型标记):
-
这个字段的位置在[FP - 16] 请注意 这里的偏移值 16 仅是示意。 这种表示方法,后面会详细介绍。
-
这是一个具有多态性 (Polymorphic) 的关键槽位。它用于当前栈帧的身份识别。V8 引擎利用这个槽位来区分当前栈帧是属于标准的 JavaScript 函数调用,还是属于引擎内部的 C++ 调用。
-
V8 的栈遍历器(Stack Walker)在扫描堆栈时(例如进行 GC 标记、生成错误堆栈或反优化时),会读取该槽位的值,并根据 指针标记位 (Tag Bit) 进行判断:
-
如果是对象指针(Heap Object):判定为 Interpreted Frame(解释器帧)。
-
如果是小整数(Smi):判定为 Internal Frame(内部帧)。
-
具有两种可能的状态:
-
状态 A:存放 JSFunction (Closure)
-
场景:当执行常规 JavaScript 代码时。
-
内容:指向当前正在执行的函数对象(闭包)的指针。
-
作用:
- 作为资源入口:解释器通过它访问
SharedFunctionInfo(获取字节码)和 FeedbackVector(获取优化反馈)。
- 连接堆与栈:保持对堆上函数对象的强引用,防止被 GC 回收。
状态 B:存放 StackFrame::Type (Marker)
- 场景:当执行 V8 内部代码(如
EntryFrame, ConstructFrame, BuiltinFrame)时。
- 内容:一个枚举值(Smi),标识具体的帧类型。
- 作用:
- 路标作用:告诉栈遍历器如何解析当前帧的其余部分(不同类型的内部帧,布局可能不同)。
- 边界界定:标记 JS 代码与 C++ 代码的转换边界
-
Bytecode Array (字节码数组指针):
-
这个字段的位置: [FP - 24] 依旧请注意,偏移值 24 仅是示意。
-
这个字段的内容: 一个指向堆内存中 BytecodeArray 对象的 Tagged Pointer。
-
定义: 它是解释器 Ignition 真正“读取”和“执行”的指令序列源头。
-
这个字段是一个指针,指向堆(Heap)上的一个BytecodeArray 对象。
之所以叫 Array,是因为它的主体部分确实是一串连续的、变长的字节序列。
在 V8 的底层 C++ 定义中,凡是符合 “定长头部 + 变长尾部” 结构的对象,通常都以此命名。
- 普通对象 (
JSObject):大小通常是固定的(或者由 Map 描述)。
- 数组类对象 (
FixedArray, ByteArray):
- 它是变长的(在分配时决定大小)。
- 它的主要内容是可以通过索引(Index)访问的序列。
之所以叫 BytecodeArray 是为了强调它的存储形态是线性的字节序列。
-
Bytecode Offset (字节码偏移量 / PC):
- 作用:程序计数器。记录当前执行到第几条指令了。
- 位置:
[FP - 32] (即 StandardFrameConstants::kBytecodeOffset) ,偏移值32,仅为示意,并非确定值。
- 形式: Smi (小整数)。
- 含义: 它记录了当前执行到了
BytecodeArray 中的第几个字节。
- 细节:在正常解释执行期间,PC 状态常驻在真实的物理寄存器,在之中不停的变动,只有在需要外部可见或恢复时(GC/中断/断点/反优化/进入 runtime 等),解释器会把寄存器的值写回栈帧 BytecodeOffset 字段)。恢复时会把它再装回物理寄存器。
-
第三层 工作区 Work Area / Virtual Registers
这是栈帧中位于固定头部后面、向低地址延伸的区域。
Ignition 将这段连续的内存槽位,给它们编上号:r0, r1, r2...
虽然它们在物理上只是连续的 8 字节内存格子,但在逻辑上,它们通常划分成了三种截然不同的用途。
1. 显式局部变量 (Explicit Locals)
这是最好理解的部分。它们直接对应你在 JavaScript 代码中声明的局部变量。
生成器(BytecodeGenerator)会按照特定算法(通常与声明顺序相关)为这些变量分配槽位。
function demo() {
var name = 'v8'; // 编译器决定:分配给 r0
let age = 10; // 编译器决定:分配给 r1
}
当代码执行到这里时,r0 槽位里就填入了 "v8" 的指针,r1 槽位里填入了 10 的 Smi 值。
- 关键点:作用域分析 (Scope Analysis)
要注意 并不是你写的所有局部变量都能住在这个“栈上的工作区”。
在生成字节码之前,V8 会先进行一次 作用域分析。
- 判断标准:如果一个变量被内部函数(闭包)捕获 (Captured) 了,它就不能住在栈上
- 原因:栈帧生命是有限的,函数执行完就销毁了。但闭包可能在函数执行完后还需要访问这个变量。
- 结果:被捕获的变量会被请到堆内存的 Context 对象 中。
- 结论:所以,能安稳住在
r0, r1 里的,都是身家清白的、未被捕获的局部变量。
2. 隐式临时变量 (Implicit Temporaries)
这是在源代码里完全看不到,但机器执行时必须存在的变量。这也是 寄存器分配 (Register Allocation) 算法大显身手的地方。
想象一下计算 var x = a + b + c;
Ignition 的累加器(老师傅的随身包)只有一个。
- 先把
a 拿进包,把 b 加进来。包里现在是 (a+b)。
- 下一步要加
c。指令要求 Add c。
- 发生冲突:如果
c 的获取过程很复杂(比如 c 是个函数调用 getC()),那么在执行 getC() 的过程中,累加器会被反复使用、覆盖。
- 如何解决:必须先把
(a+b) 的结果找个格子 暂存 Spill 起来。
Ignition 会在局部变量后面,划出一些格子作为 临时寄存器。
这些格子就像老师傅手边的“小黑板”。
- 复用性:这行代码算完了,这张“小黑板”擦干净,立刻给下一行代码复用。所以即使代码很长,只要不同时通过大量中间结果,Frame Size 也不会很大。
3. 神秘的洞 (The Hole)
这是 ES6 引入 let/const 后,V8 在底层实现 TDZ (暂时性死区) 的最硬核手段。
在第一部分解析篇中,我们已经详细学习了这个 会吹哨子的警卫thehole,忘记了的朋友,可以复习一下第一篇中的相关内容。这里我们略微的再讲一下。
在栈帧刚刚被创建,但代码还没执行到 let a = ... 这一行时,rX 槽位里放的是什么?
V8 会把对应的槽位初始化为 undefined。所以在赋值前访问它,拿到的就是 undefined(变量提升)。
V8 会把对应的槽位填入一个特殊的 会吹哨子的警卫,在内部被称为 The Hole。
Ignition 在执行读取变量的指令(如 LdaRep)时,内置了一段小逻辑:
// 伪代码
value = load(r1);
if (value == The_Hole_Value) {
throw ReferenceError("Cannot access before initialization");
}
TDZ 并不难理解,它在物理层面上,就是一个槽位里放着 The Hole,而解释器在读取时不仅读数据,还顺手做了一次安全检查,如果摸到的是警卫,哨子就响。
4. 寻址机制:如何找到 r5?
工作区只是一段连续的内存,Ignition 怎么知道 r5 在哪?
这就要用到汇编里的 基址寻址 了。
-
基准点:FP (Frame Pointer),指向固定头部的特定位置。
-
有朋友可能会有疑问了,前面说栈帧有3部分,第一部分是参数区,可是为什么FP基准点指向固定头部 ,而不是指向参数部分。
-
参数空间是调用者的区域,因此在语义上它属于caller 的部分 ,而不是 callee 用来分配本地变量/临时的 workspace。FP 作为被调用者的栈帧基准点,通常是不包括参数区的。
-
计算公式:
由于栈是向低地址增长的,所以寄存器的地址是 FP 减去一个偏移量。
Address(rn) = FP - fixed_header_size - (n * slot_size)
其中 fixed_header_size 是固定头部的字节长度,slot_size 通常等于机器指针大小(在 64 位系统下常为 8 字节)
-
示例:
-
FP 指向这里。
-
往下走 8 字节... 是 Context [FP-8]。
-
再往下... 是 Function [FP-16]。
-
再往下... 是 BytecodeArray [FP-24]。
-
再往下... 是 BytecodeOffset [FP-32]。(固定头部结束)
-
再往下... 终于到了工作区的 r0 [FP-40]。
-
r0 再往下 8 字节是 r1。
所以,字节码里的简单指令 Ldar r5,翻译到底层 CPU 动作,就是去读更深处的内存地址。
4 . 调用约定和内存布局
通过前面的学习,我们已经大致了解了栈帧的内容,现在我们就需要在脑子中建立起动态的栈帧模型。
-
建立我们自己的心智模型,内存的想象图
那么 我们怎么想象内存呢? 梯子,高耸入云的梯子,一格一格的代表内存单元。
地面(最底下):是 高地址(比如 0xFF...)。对于栈帧来讲,这是稳固的地基。
天空(往上看):是 低地址(比如 0x00...)。这是延伸空间。
最底下是高地址,越往上,地址越低。
有朋友可能会问:“书上或者 V8 源码注释里,通常都是画‘高地址在上,低地址在下’,栈是‘向下生长’的,为什么我们要反着来?”
这其实是为了贴合直觉。 如果你使用过 OllyDbg、x64dbg 或 IDA 等调试工具,你会发现它们的内存视图通常是这样的:
- 上面 显示的是 低地址。
- 下面 显示的是 高地址。
这种视角的好处极其直观:
- 入栈 Push:就像盖楼一样,在现有的楼顶上,往上 再盖一层(地址变低/变小)。
- 出栈 Pop:就像拆楼一样,把最上面的一层拆掉(地址变回高/变大)。
- 栈底:在最下面(高地址),通常存放着调用者的环境,在一个栈帧中,很少变动。
- 栈顶:在最上面(低地址),数据频繁进进出出出栈入栈,变动剧烈。
所以,为了理解起来更顺畅,建议我们在脑海中建立的模型如下:
- 高地址在下(地基)。
- 低地址在上(天空)。
- 栈帧的生长方向:从下往上,向低地址生长。
-
指针和内存单元
最小刻度:字节 (Byte) 在计算机里,8 bit (1字节) 是内存可寻址的最小单位。
实际步长: 虽然刻度是按 字节 画的,但在 64 位系统里,Ignition 这个老师傅手很大。 他干活时,不会像学友哥那样捏着兰花指去抓 1 个字节。 他每一次伸手,都要抓走 8 个字节(64 bit)。这 8 个字节合起来,才构成了一个完整的 槽位 (Slot)。
内存对齐: 每一次都要操作 8 个字节,所以,所操作的地址,都是8的倍数:
0 8 16 24 。。。(这些数字仅仅是示意地址是8的倍数)
这就是 内存对齐。
注意: 这并不代表地址 1, 2, 3...7 是“空闲”或者“没用”的。 当你向地址 0 写入一个 64 位指针或者数据时,这个指针或数据用64bit的庞大的身躯填满了从 0 到 7 的所有空间。 只不过,当我们想找到这个数据时,我们只在这个数据的头部(首地址) 找起。
地址 0:是第一个槽位的门口。地址 8:是第二个槽位的门口。
这就解释了我们在前面内容中提到的 Tagged Pointer 原理: 因为地址只在 0, 8, 16 这些 8 的倍数上,所以这些地址的二进制表示,最后 3 位通常是 0。 V8 也是看准了这一点,才敢把这 3 位挪作他用(存类型 Tag)。
指针 如何理解?
指针就是地址,之所以说是指针 而不是直接说地址,是因为 指针收紧了地址的概念。
“地址”是物理层面的客观存在,而“指针”是软件层面的主观定义。指针对地址具有收紧和约束作用。
准确的说 指针是对地址概念的一次“收紧”和“赋予语义”。
-
地址
-
本质:它只是一个冷冰冰的数字编号(比如 0x0000FFFF)。
-
缺陷:它没有任何约束。给你一个地址,你根本不知道那里住的是什么。
-
是 4 个字节的整数?
-
还是 1 个字节的字符?
-
或者是一段可执行的代码?
-
甚至可能是一个无效的垃圾值?
-
状态:如果你只拿到了一个地址,你面对的是未知的、混乱的内存空间。
-
指针
-
本质:指针 = 地址 + 类型约束(解释方式)。
-
收紧的概念:
当我们定义一个指针(比如 C++ 里的 int* p 或 V8 里的 Tagged Pointer)时,我们实际上是收紧了对那个内存地址的操作权限和理解方式。
- 它告诉 CPU:“别乱猜了,这个地址里存的一定是 对象,而不是整数。”
- 它告诉编译器:“当你去读这个地址时,请按照 8 字节 为单位去读,不要只读 1 个字节。”
在 V8 的 Ignition 中,这种“收紧”体现得更全面:
-
标签 (Tagging):
V8 的指针(Tagged Pointer)利用最低位(Tag Bit)强行规定了语义。
- 如果最后一位是
0:收紧为“立即数”(Smi)。不需要去内存里找,它自己就是值。
- 如果最后一位是
1:收紧为“堆指针”(HeapObject)。必须去堆里找。
-
隐藏类 (Map/Hidden Class):
当你顺着 V8 的指针找到堆里的对象时,对象的第一个属性通常是 Map(隐藏类)。
这实际上是进一步的“收紧”:
- “这个地址不仅是个对象,而且它是一个 数组,长度是 10,元素类型是...”。
最后需要注意,越往底层,比如到了汇编 到了代码调试 ,对于指针和地址的区分,就越趋近于无,很多时候,都是混着叫的,基本上都是使用指针就是地址 这个本质概念了。因为约束已经剥离,只剩本质了。
调用约定
我们在前面学习了栈帧的物理结构:参数在高地址,返回地址在中间,变量在低地址。
这时候,无中生友的朋友又出现了:为什么要这么放?我倒过来放不行吗 我混着放不行吗?
这就引出了一个重要的概念 调用约定。
- 简单来说,调用约定就是 调用者 (Caller) 和 被调用者 (Callee) 之间达成的一份 “协议” 或 “合同”。
想象一下两个人在玩球球:
在 V8 里,这份 协议/合同 规定了三个最核心的问题:
- 参数放哪? (传递方式)
- 是放在 CPU 寄存器里?还是压到栈内存里?
- 如果是压栈,是从左往右压,还是从右往左压?
- 结果放哪? (返回方式)
- 函数算完了,结果放在哪个寄存器里带回去?(通常是累加器/rax)。
- 谁来打扫卫生? (堆栈平衡)
- 参数占用的栈空间,是 Caller 负责回收,还是 Callee 负责回收?
V8的特殊之处:垃圾回收
在c / c++ 中, 标准约定通常会优先把前几个参数放在 物理寄存器 里传递,这样速度会达到极致。
但是在V8的 Ignition 解释器里,我们看到参数几乎都是乖巧的排列在栈上。这是为什么?
因为 V8 有一个幽灵暗卫 ,这就是 垃圾回收器 (GC)。
- GC 的全年无休:GC 需要时刻扫描内存,看看哪些对象还活着(有指针指向它)。
- 寄存器无法跟踪:如果参数散落在各种物理寄存器里,这就很难追踪。
- V8 的折中拖鞋:确保栈上有一份“可扫描”的备份。
即使某些参数是通过寄存器传进来的,为了方便 GC 撸羊肉串式的扫描,V8 通常也会保证这些参数在栈上有一个确定的位置(或者把寄存器的值“抄写”到栈上)。记得前面说PC的时候,提过一次。
so 这就形成了我们在栈帧图中看到的那样,参数在内存里连续排列,GC 扫起来非常舒服。
再说栈帧的内存布局
在前面我们讲栈帧的结构时,从高地址到低地址,依次是 参数区--固定头部区--工作区,但是
对于栈帧的分界和字段的所有者,并没有详细的说明。 现在我们有了足够的铺垫,可以详细了解了。
我们需要按时间顺序走一遍流程。
这对于理解 FP(栈帧指针)这个“界碑”至关重要。
第一阶段:调用者准备工作
调用者在执行 CALL 指令之前,需要先准备好贡品:
-
Push 参数:调用者把参数(Receiver, a0, a1...)按顺序压入栈。
- (这是 Caller 划拨的内存,属于 Caller 的栈帧范围,但供 Callee 使用)
-
执行 CALL 指令:CPU 自动将 返回地址 (Return Address) 压入栈顶,并跳转到 Callee 的代码处。
- (此时,FP 指针依然指向 Caller 的老基准点)
第二阶段:被调用者接手
控制权来到了 Ignition(被调用者)手中,它进门后的头等大事就是建立自己的宗门(栈帧):
- Push Caller's FP:Ignition 做的第一件事,就是把旧的 FP(上一层的基准点)压入栈中保存起来。
- (这一步形成了 Saved FP,也就是栈帧中间的那个连接点)
- Set New FP:Ignition 把当前的栈顶指针 (SP) 赋值给 FP。
- (从此,FP 指向了 Saved FP。新的栈帧基准点正式建立)
- Push Fixed Header:接着,Ignition 依次压入 Context、Function、BytecodeArray、BytecodeOffset 等固定字段。
- Allocate Locals:最后,根据 Frame Size,一次性把栈顶指针 (SP) 往下移,为局部变量(r0, r1...)留出空间,并初始化为
undefined 或 The Hole。
有了这个流程,我们再看“户口归属”就非常清晰了:
-
FP 及其上方 (参数、返回地址): 虽然物理上和 FP 连在一起,但它们是 Caller 在第一阶段留下的“遗产”。
-
参数是 Caller 带来的。
-
返回地址是 Caller 带来的。
-
Saved FP 是 Callee 为了保护 Caller 而存的。
-
FP 下方 (固定头部、工作区): 这是 Callee 在第二阶段亲手创建的“资产”。
-
Context 是 Callee 找来的。
-
局部变量是 Callee 分配的。
这样,我们再来看栈帧的结构,理解上的逻辑就完全闭环了: Caller 给资源(参数) --- 硬件给退路(返回地址) --- Callee 建地基(保存旧FP) --- Callee 建房子(头部和变量)。
FP和偏移量
在前面我们学习栈帧的固定头部中的字段时,我们使用了 FP加偏移值 的表示方式。
爱琢磨的朋友肯定会有疑问:为什么所有东西都要盯着 FP 看?为什么是这些特定的数字?FP 里面到底装了什么?
- 为什么选 FP (Frame Pointer) 做基准?
你可能会问:“栈顶指针 SP (Stack Pointer) 也是个指针,而且它就在栈顶,为什么不用 SP 来找数据,非要专门维护一个 FP 呢?”
原因就是:SP 是“动”的,FP 是“静”的。
在函数执行过程中,Ignition 可能会频繁地入栈、出栈(比如压入临时变量、准备子函数参数)。
这就导致 SP 的位置一直在变。
如果用 SP 做基准,当你找 变量 a 时,上一行代码可能是 [SP + 8],下一行代码因为压了个临时值,就变成 [SP + 16] 了。编译器计算起来会疯掉。
一旦栈帧建立完毕(Prologue 结束),在整个函数执行期间,FP 指针就是钉在栈帧的固定位置(Saved FP 那个槽位),雷打不动。
此时,我们以 FP 为原点,向上下看:
往下看(向地基/高地址):不管栈顶怎么变,参数 a0 永远在 FP 往下数 第 2 格的位置(偏移量是正数,如 FP + 16)。
往上看(向天空/低地址):不管栈顶怎么变,变量 r0 永远在 FP 往上数 第 5 格的位置(偏移量是负数,如 FP - 40)。
所以:FP 提供了一个静态的、绝对的参考坐标系。
- FP和地址和内容
这是初接触的朋友,理解栈帧链表最容易迷糊的地方。
我们要区分三个概念:
可以简化理解为,这是 CPU 里的一个物理部件(或 Ignition 的虚拟指针)。
FP中的内容就是 一个内存地址。
这个内存地址是个指针,指向当前栈帧中的一个字段,
同时,这个内存地址/指针,也是当前栈帧的 “零点”。即 Offset = 0。
那么 这个栈帧中的字段,里面的内容是什么?
答案是:Caller's FP (调用者的 FP)。
即:上一层栈帧的基准地址。
这同时也是“栈回溯”的原理:
- 当前 FP 指向
Saved FP。
Saved FP 里存着 上一层 FP。
上一层 FP 里存着 上上层 FP。
- ...
- 这就形成了一条链表。调试器(Debugger)就是顺着这条链子,一层层往上爬,才打印出了完整的调用栈。
- 偏移量 (Offset) 是怎么确定的?
搞懂了 FP 是零点,那么,对于参数和变量的寻址,就非常容易理解了。
让我们站在 FP 这个零点,开始巡视:
A. 往下看:Caller 留下的遗产 (因为我们使用高地址在下,低地址在上的模式)
这里是地址 增加 的方向(Offset 是 正数 +),因为我们在往高地址走。
- Offset +0 (
[FP + 0]): 就是脚下。这里存的是 Saved FP。
- Offset +8 (
[FP + 8]): 往下 走 1 格。 这里是 Return Address。 (为什么是 +8?因为往高地址走了 8 字节。)
- Offset +16 (
[FP + 16]): 再往下 走 1 格。 这里是 Receiver (this)。 (注:这是雷打不动的专座,离 FP 最近的参数。)
- Offset +24 (
[FP + 24]): 再往下 走 1 格。 这里是 第一个显式参数 (Arguments a0)。 (注:如果有更多参数 a1, a2... 会继续往下排在 +32, +40...)
B. 往上看:Callee 自己的资产 (低地址区)
关于偏移量的具体值,在前面,特别说明是用于 示意 , V8源码中的偏移如下,暂时可以作为确定值,但是以后很有可能会更改。
>
>FP (基准)
>
>[FP - 8]: StandardFrameConstants::kContextOffset -> Context
>
>[FP - 16]: StandardFrameConstants::kFunctionOffset -> Function
>
>[FP - 24]: InterpretedFrameConstants::kBytecodeArrayFromFp -> BytecodeArray (解释器特有)
>
>[FP - 32]: InterpretedFrameConstants::kBytecodeOffsetFromFp -> BytecodeOffset (PC)
>
>[FP - 40]: InterpretedFrameConstants::kRegisterFileFromFp -> r0 (Register 0) (工作区起点)
这里也需要加一个限定:
在 64 位系统下,Ignition 解释器栈帧的固定头部布局通常如下:
这里是地址 减小 的方向(Offset 是 负数 -),因为我们在往低地址(栈顶)方向爬。
- Offset -8 (
[FP - 8]): 往上 爬 1 格。 这里是 Context。 (为什么是负数?因为离天空更近了,地址变小了。)
- Offset -16 (
[FP - 16]): 再往上 爬 1 格。 这里是 Function。
- Offset -24 (
[FP - 24]): 再往上 爬 1 格。 这里是 BytecodeArray。
- Offset -32 (
[FP - 32]): 再往上 爬 1 格。 这里是 BytecodeOffset (PC)。 (注:到这里,固定头部结束)
- Offset -40 (
[FP - 40]): 再往上 爬 1 格。 终于到了 工作区。这里是 r0。
二. 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 时,靠的就是这里保留的坐标信息。
{
"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。
例三:半空半满的火车
代码:
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):
如果只有两层, 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 三层结构是如何工作的?
例五:火车被打包了
代码:
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)。
-
*遇到 **
- 现在的解析器回到了主线(外层函数),紧接着看到了
*。
* 说:“我要一个左操作数。”
- 谁是左操作数? 正是刚才从副本里带回来的那个 加法节点
-
最终组装:
最终生成的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.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
代码: !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(极高,和
. 还有 () 平起平坐)。
- 特点: 括号是它的保镖,一旦带了括号,它就变得极其强势,必须先执行。
- 形态二(无参数):
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)
- 标识符与字面量 (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 ...
在前面,我们说过,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 }
}
}
}
- 前面我们几乎都是从单独的节点来学习的,现在我们把视线调高点。
-
容器: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都有使用。
-
循环:ForStatement
for 循环是 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 必须区分 “我是要读这个函数” 还是 “我是要执行这个函数”。
可能有朋友会有疑惑,为什么是 调用表达式 而不是 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, null
AST 结构:
JSON
{
"type": "Literal",
"value": 1,
"raw": "1" // 源码中的原始样子
}
V8 视角: 这是最简单的节点。生成器看到它,直接把值放进 Constant Pool (常量池),然后生成 LdaConstant 指令。它是静态的,编译期就能确定。
B. Identifier (标识符) —— 活名
代码: a, add, undefined
AST 结构:
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语法糖:ClassDeclaration
ES6 引入了 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 全局指令!”
通过这个例子,我们在纯正的 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 (字节码构建器):
绝对的舞台中心: 聚光灯 —— 累加器 (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变量
对于以前较老的脚本(非模块)来说,用 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 的片场里,这个并不是一个简单的取值。
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 的每一次属性访问,都在收集运行时的类型情报,后续这些情报将直接驱动编译器在热路径上生成激进的机器码,从而把慢路径的“龟速查找”瞬间变成极速的“偏移量访问”。
-
函数调用
函数调用,是片场最兴师动众的动作,它表示要临时搭建一个全新的分会场(新栈帧)。演员、道具、场地全得现成准备。
-
导演喊话流程:
-
找对象: 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]
导演在生成这段字节码的同时,已经在内存的 常量池(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 节点
步骤 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 解释器。
- 兜底执行:解释器临危受命,翻开原始剧本,老老实实向内存管理器申请空间,盖出别墅交给闭包。同时,新的分支执行信息被记录进反馈向量,确保下一次优化编译更加安全。
2.23
下一篇: Ignition解释器(下) 码字中。。。