这是完整的一篇超长文章,内容为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默认你写的函数暂时不会执行,除非是已经显式的通过语法告诉它,这段这行代码 马上就要跑 你赶快全量解析。
下面 我们稍微详细的说一下
-
默认绝大多数函数都是预解析
v8认为js在初始运行时,仅仅只有很少很少一部分代码 是需要马上使用的 其他觉得大部分 都是要么是回调 要么是其他的暂时用不到的,所以,凡是具名函数声明、嵌套函数,默认都是预解析。
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父作用域。
- 每进入一个函数,V8 就会创建一个新的
- 结果: 这种“父子关系”是静态锁定的。无论你将来在哪里调用这个函数,它的“父级”永远是定义时的那个作用域。
变量引用关系被识别
这是解析器最忙碌的工作之一,叫做 变量解析。
- 声明: 当解析器遇到
let a = 1,它会在当前 Scope 记录:“我有了一个叫a的变量”。 - 引用: 当解析器遇到
console.log(a)时,它会生成一个 变量代理。 - 链接过程: 解析器会尝试“连接”这个代理和声明:
- 先在当前 Scope 找
a。 - 找不到?沿着 Scope Tree 往上找父作用域。
- 找到了?建立绑定。
- 一直到了全局还没找到?标记为全局变量(或者报错)。
- 先在当前 Scope 找
这里要注意: 这个“找”的过程是在编译阶段完成的逻辑推导。
闭包的蓝图被预判
这一步是 V8 性能优化的关键,也就是作用域分析。
-
发现闭包: 解析器发现内部函数
inner引用了外部函数outer的变量x。 -
打个大标签:
- 解析器会给
x打上一个标签:“强制上下文分配”。 - 意思是:“虽然
x是局部变量,但因为有人跨作用域引用它,所以它不能住在普通的栈(Stack)上了... 必须搬家,住到堆(Heap)里专门开辟的 Context(上下文对象) 中去。”
- 解析器会给
-
还没有实例化:
-
此时内存里没有上下文对象,也没有变量
x的值(那是运行时的事)。 -
AST 只是生成了一张“蓝图”,图纸上写着:“注意,将来运行的时候,这个
x要放在特别的地方 - Context里,别放在栈上。”
-
- AST 节点: 当解析器遇到一个
-
现在 我们来复一下盘 重点学习解析过程
字节流---被切成有语法意义的最小单元token---成为token流---解析阶段(进行预解析或者全量解析)---得到AST和作用域树和变量引用关系 这就是我们第一部分所讲的词法分析和语法分析的内容。
因为这部分比较重要,所以我们将继续深入的学习一下。。。反正学都学了 要学还不趁机多学点,所以 前面的内容 只是开胃菜 惊不惊喜 意不意外 .
其实,是因为在整个链条中,从开始到AST生成,是一个较为完整的独立的小阶段。此时,仅仅是静态分析过程完成。
从整个流程来看, AST生成,表示物理层级确定 作用域链构建完成,闭包蓝图依托作用域链 变量路径引用依托作用域链,甚至连栈和context中的位置分配都有了蓝图。 所以 重点了解这部分内容,也是获得感满满了。
下面 我们来重点学习解析的过程。
上面讲了解析的过程叫 递归下降分析法 听起来是不是很高大上,其实 它还有个小名,叫“层层甩锅工作法”。
-
解析器有两大神技,这两大神技,是它的最大倚仗
-
提前偷看 Lookahead
它处理当前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
三元解析说 看不懂 不归它管 依旧往下甩锅。
-
ParseBinaryExpression
两元解析 依旧甩锅
-
ParseUnaryExpression
一元解析 依旧甩锅
-
ParseLeftHandSideExpression
LHS 处理new
,(),.,[] 的解析, 依旧甩锅 -
ParsePrimaryExpression
到了原子层,这里是专门处理m
,1,(expr),this 的地方。这层一看 欸 是我的活呀, 然后吃掉 token
m。生成
VariableProxy(m)节点。 交回上层。 -
返回到ParseLeftHandSideExpression
这层的解析拿到m节点,偷看后面 是个 = 号,嗯 没我的事,快走吧。继续往上交
-
返回到ParseUnaryExpression
这层拿到m,偷看 是个=号,和我的工作没关系,快走吧
-
返回到ParseBinaryExpression
这层拿到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)节点,偷看 是个 + 号,快走吧
-
返回到ParseUnaryExpression
拿到Literal(1)节点,偷看 是个+号,和我的工作没关系,快走吧
-
返回到ParseBinaryExpression
拿到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() {} //四种形式的functionfunction* 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 会在后续的编译/实例化阶段,确保在任何代码执行前,这个名字就已经指向了完整的函数体。这就实现了我们常说的“函数整体提升”。
- 所以,虽然此时只是在小本本上记了个名字(占位),真正的函数对象创建和绑定要等到后续阶段。但对解析器来说,名字有了,就可以继续往下走了。
- Global Scope 记录:
- 准备进入实体: 名字搞定后,剩下的
(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)”。
- 为什么是候选?因为现在还不知道有没有闭包这个老登在后面等着捕获它。先按“住栈”处理,等最后算总账时再决定。
- 问自己:当前 FunctionScope 有
- 偷看: 后面是
=,这表示有初始值,需要解析赋值表达式。
9. ParseAssignmentExpression (赋值解析)
- 眼熟吧,俺表达式解析又回来了。熟悉的情节也回来了。
- 左手: 拿到
result的变量代理节点。 - 消费: 吃掉
=。 - 右手(递归): 解析
x + y。- ParseBinaryExpression (+号):
- 读到
xResolve:在当前 Scope 找到参数x,生成引用节点。 - 吃掉
+。 - 读到
yResolve:在当前 Scope 找到参数y,生成引用节点。 - 组装: 生成
BinaryOperation(+, x, y)节点。
- 读到
- 这里的读到变量的时候,首先在当前的作用域找,找不到就通过指向父作用域的指针,到上层作用域里找。
- ParseBinaryExpression (+号):
- 终极组装:
- 生成
Assignment节点:result = (x + y)。
- 生成
- AST 挂载: 这个 Assignment 节点被 push 到函数体的 statements 列表中。
- 消费: 吃掉
;。
====== 第二行代码:return result; ======
10. ParseStatementListItem
-
偷看: Token 是
return。 -
判断: 这是个
ReturnStatement。 -
甩锅: 调用
ParseReturnStatement。项级分流,这里是语句,被甩锅给语句解析函数,然后根据关键字,被甩锅给
ParseReturnStatement。过程还记得吧?假如关键字匹配不到,就甩给兜底的表达式解析。继续重复一下,加深印象。
11. ParseReturnStatement (返回语句解析)
- 消费: 吃掉
return。 - 偷看: 后面不是
;,说明有返回值。 - 甩锅表达式: 调用
ParseExpression解析result。- 又甩给表达式解析了,继续那一套过程。。。
- 变量的解决:
- 读到
result。 - 查找: 在当前 FunctionScope 找到了刚刚登记的
result。 - 生成:
VariableProxy(result)节点。
- 读到
- 组装: 生成
ReturnStatement(result)节点。 - AST 挂载: 挂到函数体列表中。
- 消费: 吃掉
;。
第四阶段:收工阶段
12. ParseStatementListItem (循环继续)
- 偷看: Token 是
}。 - 判断: 列表结束了。
- 返回: 退出
ParseStatementList。
13. 退出函数体与作用域计算 (Scope Finalization)
-
消费: 吃掉
}。 -
作用域收尾 (Scope Finalization) —— 算总账时刻:
现在代码解析完了,要离开 FunctionScope 了。但是还必须做一次最终盘点。
-
检查有无“内部函数”: 看这个
add函数里,有没有定义其他的子函数。add是个光杆司令,肚子里没有子函数。
-
决定变量命运: 逐个检查
x,y,result。- 如果有子函数引用了它们,它们就得“被迫搬家”,被放进 堆内存 (Context) 里,供子函数随时访问。
- 但在这里,因为没有子函数引用,这几个变量都是清白的(没有被捕获)。
-
计算栈帧: 既然都不用进堆,那就全部安排在 栈 (Stack) 上。解析器计算出:运行这个函数只需要申请几个栈上槽位就可以了。
栈分配极其廉价,函数执行完,栈指针一弹,内存瞬间回收。比进堆(Context)快得多。
-
最终结果: 这个作用域被标记为“不需要 Context”。
-
-
AST 终极打包:
- 创建一个巨大的 FunctionLiteral 节点。
- 把
Name: add挂上去。 - 把
Scope: FunctionScope挂上去。 - 把
Body: [AssignmentNode, ReturnNode]挂上去。 - 把
Length: 2(参数个数) 挂上去。
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的声明吗?”- 回答:有!我这里有个
var 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 管。
- AST 层面:生成一个
-
连接点: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在分析作用域时,会精准计算:
-
变量 A:被内部函数引用了吗?没有?好,留你在栈上,用完就销毁。
-
变量 B:被引用了?好,你搬进 Context 里去。
只捕获用到的,绝不浪费一粒米。默认的 小包
-
-
特殊情况一:被迫连坐 中包
function factory() {let heavyData = new Array(1000000); // 这是一个超大的数据let lightData = '小喽啰';function useHeavy() {// 这个闭包用了 heavyDataconsole.log(heavyData.length);}function useLight() {// 这个闭包只用了 lightDataconsole.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出去,所以兄弟连坐。
-
-
特殊情况二:eval 一锅端大包
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:
由于 var 的名字已经提升走了,这里剩下的其实是一个 赋值操作。
V8 会生成一个 Assignment 节点(或者类似的初始化节点),挂在当前的语句列表中。
- 意思是:“名字归上面管,但我得在这里把值赋进去。”
- 这里也需要注意,var的名字被提升走了,但是赋值操作还留在这里呢,在赋值之前,var都是undefined。
-
对于 let / const:
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(赋值) 节点,而不是一个单纯的声明节点。 - 声明 (Declaration):
-
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的语句列表中。 - Proxy:变量
-
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这种代码会在解析阶段直接报错。 - 目的:这是为了让类内部的方法能引用到类本身(自引用)。
- V8 调用
- 初始化列表
- 解析器在内存里准备了三个空的列表(List),用来分类存放即将切割下来的不同部位,像超市里鸡腿 鸡翅 鸡杂 分开摆盘:
instance_fields(实例字段列表):存放name = ...这种。static_fields(静态字段列表):存放static version = ...这种。properties(方法属性列表):存放say(),constructor这种。
- 解析器在内存里准备了三个空的列表(List),用来分类存放即将切割下来的不同部位,像超市里鸡腿 鸡翅 鸡杂 分开摆盘:
- 强制开启严格模式
-
开始解析
现在,解析器进入大括号
{ ... },开始扫描。name = '阿祖';—— 实例字段的解析- 识别 Key:解析器读到
name。 - **偷看 **:往后偷看一眼,发现是
=。 - 判定:这不是方法,这是一个 Field (字段)。且没有
static,所以是 Instance Field (实例字段)。 - 解析:
- 解析器把
=后面的'阿祖'作为一个 表达式 进行解析。 - 生成一个
Literal字符串节点。
- 解析器把
- 包装:
- 关键:消费完了以后,V8 不会把
'阿祖'直接扔掉。它会创建一个 "合成函数" (Synthetic Function) 的外壳。 - 为什么要包一层? 这是 V8 为了隔离作用域而采用的策略。字段初始化表达式里可能会有
this,或者复杂的逻辑。通过封装成一个独立的函数壳,V8 确保了它和构造函数的参数(比如skill)互不干扰,这也符合 JS 规范:字段定义本来就看不见构造函数的参数。 - 划重点:
name的值怎么算,被封装成了一个可以在未来执行的函数。 - 这里需要注意,= 号后面的值,并不是一次性使用,有可能被使用很多次,虽然我们例子中是 阿祖,但是 也可能是其他包含逻辑的计算值,所以,我们需要的不是值,而是如何生成这个值的 整个逻辑, 因此 解析出来以后,给它包上一层带独立作用域的函数壳。
- 关键:消费完了以后,V8 不会把
- 归档:把这个合成函数扔进
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: trueconfigurable: trueenumerable: false(类的方法默认不可枚举)
- HomeObject 绑定:
- 解析器会给
say函数标记一个HomeObject。这是为了如果你在say里用了super,它知道去哪里找父类。
- 解析器会给
- 归档:把生成的
say函数节点,扔进properties列表。
- 识别 Key:解析器读到
-
进行脱糖
扫描完
},所有的配件都摆好了。马上开始的,这就是传说中的 脱糖 过程。类是语法糖,现在,我们要脱糖。
-
改造构造函数
- 拿出刚才解析好的
constructor函数节点。 - 定位:
- V8 寻找函数体的 起始位置。
- 如果有继承 (
extends),位置在super()调用之后(因为super返回前this还没出生)。 - 没有继承,位置就在函数体的 最前面。
- 添加:
- V8 把
instance_fields列表里的内容拿出来(那个name = '阿祖'的合成函数)。 - 它将其转化为赋值语句 AST:
this.name = '阿祖'。 - 它把这条语句 插入 到
constructor原本的用户代码this.skill = skill之前。
- V8 把
此时,在 V8 的内存 AST 中,构造函数实际上变成了这样:
// V8 内存中的构造函数(伪代码) function Hero(skill) {// --- V8 添加的字段初始化逻辑 ---// 注意:这里是一个隐式的 Block// 是因为这里是由合成函数转化的,包含了逻辑 也包含了独立的作用域this.name = '阿祖'; // -----------------------------// --- 用户写的逻辑 ---this.skill = skill; } - 拿出刚才解析好的
-
组装 ClassLiteral
现在构造函数改造完毕,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函数对象上。
- 把它直接挂到
- 返回这个
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) 后面的。如果你想让它属于外层,必须显式地加 {},所以 ,从写法上减少这些歧义是最好的。
循环解析:
forwhile 和 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。- 检查
3 < 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 个回调运行:
- 同理,打印: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。
- V8 在 Scope A 里创建变量
- 闭包生成:
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。
- V8 在 Scope B 里创建变量
- 闭包生成:
- 生成第二个箭头函数。
- 它捕获的是 Scope B 里的
x。 - 这个闭包手里攥着 x=1 的照片。
第三轮循环 (i = 2)
- 公共时钟
i:变成了 2。 - 进入房间:创建 Block Scope C。
- 执行
let x = i:x = 2。
- 闭包生成:
- 捕获 Scope C 里的
x。 - 手里攥着 x=2 的照片。
- 捕获 Scope C 里的
循环结束了。
- 公共变量
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循环所在的地方(比如函数作用域)。没有什么特殊的。第 2 层:循环头作用域 (Loop Header Scope)
这是关键层!
- 诞生时刻:解析器读到
for (且发现后面跟着let时,立刻创建。 - 住户:循环变量
i就住在这里。 - 职责:它包裹着整个循环,包括初始化、条件判断、步进操作。它就像是循环的总指挥部。
第 3 层:循环体作用域 (Loop Body Scope)
- 诞生时刻:解析器读到
{时创建。 - 住户:循环体内的变量
x住在这里。 - 关系:它的
outer指针指向 循环头作用域。
为了满足“每次循环都是新
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 = _ksetTimeout(() => 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)" 的标签。
- Body Scope 里有
步骤 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没有块级绑定(它是函数/全局作用域的共享绑定),因此不会产生快照效果。跳转语句:
returnreturn
、break、continue 的解析逻辑都很直白:“吃掉关键字 --检查分号”。但
ParseReturnStatement有一个巨大的坑,叫做 ASI (自动分号插入)。看这段
return true;解析器读到
return后,它的动作是这样的:- 偷看:偷看下一个 Token。
- 发现:哎哟,是一个 换行符 (LineTerminator)。
- 判定:根据 JS 语法规则,
return后面不能跟换行符。既然你换行了,我就当你写完了。 - 插入:V8 强行在这里插入一个分号
;。 - 结果:代码变成了
return;(返回undefined)。下面的true;变成了永远执行不到的废话。
这就是为什么要强调的:
return的值千万别换行写!兜底:表达式语句
这个就不用讲了,都讲的头晕了。
原本是想全部写完以后再发的,但是现在解析篇写完就已经三万五千多字了,篇幅太大,不知道发文章有没有单篇字数限制,就一篇一篇的发吧。
至此解析篇 的内容全部结束。
- 看到
静态的旅程结束了。 接下来,是一个新的开始-------------Ignition 解释器篇。
本文首发于: 掘金社区
同步发表于: csdn
博客园
码字虽不易 知识脉络的梳理更是不易 ,但是知识的传播更重要,
欢迎转载,请保持全文完整。
谢绝片段摘录。
参考资料:
https://github.com/v8/v8
https://v8.dev/blog
https://v8.dev/blog/scanner
https://v8.dev/blog/preparser
https://tc39.es/ecma262/