找回密码
 立即注册
首页 业界区 业界 V8引擎 精品漫游指南 -解析篇 语法解析 AST 作用域 闭 ...

V8引擎 精品漫游指南 -解析篇 语法解析 AST 作用域 闭包 字节码 优化 一文通关

劳欣笑 3 天前
这是完整的一篇超长文章,内容为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在初始运行时,仅仅只有很少很少一部分代码 是需要马上使用的  其他觉得大部分 都是要么是回调 要么是其他的暂时用不到的,所以,凡是具名函数声明、嵌套函数,默认都是预解析。
      1. function clickHandler() {
      2.   console.log("要不要解析我");
      3. }
      4. // 引擎认为 这是一个函数声明  看起来还没人调勇它
      5. // 先不浪费时间了,只检查一下括号匹配吧,
      6. // 把它标记为 'uncompiled',然后跳过。"
      复制代码
    • 那么  如何才能符合它进行全量解析的条件呢

      • 顶层代码
        写在最外层 不在任何函数内 的代码,加载完必须立即执行。
        判断依据: 只要不在 function 块里的代码,全是顶层代码,必须全量解析。
      • 立即执行函数
        那么这里有个问题,就是V8 如何在还没运行代码时,就知道这个函数是立即调用执行函数呢?
        答案就是  看括号()
        当解析器扫描到一个函数关键字 function 时,它会看一眼这个 function 之前有没有左括号 (

        • 没括号
          1. function foo() { ... }
          2. // 没看到左括号,那你先靠边吧, 对它预解析。
          复制代码
        • 有括号
          1. (function() { ... })();
          2. // 扫描器扫到了这个左括号
          3. // 欸,这有个左括号包着 function
          4. // 根据万年经验,这是个立即执行函数,马上就要执行。
          5. // 直接上大菜,全量解析,生成 AST
          复制代码
        • 其他的立即执行的迹象:除了括号,!、+、- 等一元运算符放在 function 前面,也会触发全量解析
          1. !function() { ... }(); // 全量解析
          复制代码

      • 除了这些以外, v8还有一些启发式的规则来触发全量解析。比如  如果是体积很小的函数,V8 有时也会直接全量解析,因为预解析再全量解析的开销可能比直接解析还大。。。等等。

    • 如果有嵌套函数咋办呢
      嵌套函数默认是预解析,即使外部函数进行的是全量解析,它内部定义的子函数,默认依然是预解析。只有当子函数真的被调用时,V8 才会暂停执行,去把子函数的全量解析做完 把 AST 补齐
      1. //顶层代码全量解析
      2. (function outer() {
      3.   var a = 1;
      4.   // 内部函数 inner:
      5.   // 虽然 outer 正在执行,但 inner 还没被调用
      6.   // 引擎也不确定 inner 会不会被调用。
      7.   // 所以inner 默认预解析。
      8.   function inner() {
      9.     var b = 2;
      10.   }
      11.   inner(); // 直到执行到这一行,引擎才会回头去对 inner 进行全量解析
      12. })();
      复制代码
    • 那么  引擎根据自己的判断 进行全量解析或者预解析,会出错吗
      当然会,
      如果是本该预解析的  结果判断错了  进行了全量解析   浪费了时间和内存生成了 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 性能优化的关键,也就是作用域分析。

    • 发现闭包: 解析器发现内部函数 inner 引用了外部函数 outer 的变量 x。
    • 打个大标签:

      • 解析器会给 x 打上一个标签:“强制上下文分配”
      • 意思是:“虽然 x 是局部变量,但因为有人跨作用域引用它,所以它不能住在普通的栈(Stack)上了... 必须搬家,住到堆(Heap)里专门开辟的 Context(上下文对象) 中去。”

    • 还没有实例化:

      • 此时内存里没有上下文对象,也没有变量 x 的值(那是运行时的事)。
      • AST 只是生成了一张“蓝图”,图纸上写着:“注意,将来运行的时候,这个 x 要放在特别的地方 - Context里,别放在栈上。”


  • 现在 我们来复一下盘  重点学习解析过程
    字节流---被切成有语法意义的最小单元token---成为token流---解析阶段(进行预解析或者全量解析)---得到AST和作用域树和变量引用关系  这就是我们第一部分所讲的词法分析和语法分析的内容。
    因为这部分比较重要,所以我们将继续深入的学习一下。。。反正学都学了  要学还不趁机多学点,所以 前面的内容 只是开胃菜  惊不惊喜 意不意外 .
    其实,是因为在整个链条中,从开始到AST生成,是一个较为完整的独立的小阶段。此时,仅仅是静态分析过程完成
    从整个流程来看, AST生成,表示物理层级确定  作用域链构建完成,闭包蓝图依托作用域链 变量路径引用依托作用域链,甚至连栈和context中的位置分配都有了蓝图。 所以 重点了解这部分内容,也是获得感满满了。
    下面 我们来重点学习解析的过程。
    上面讲了解析的过程叫 递归下降分析法 听起来是不是很高大上,其实  它还有个小名,叫“层层甩锅工作法”。

    • 解析器有两大神技,这两大神技,是它的最大倚仗

      • 提前偷看 Lookahead
        它处理当前token时,总是喜欢盯着下一个( 甚至下几个),比如 当它手里拿着const了,然后它提前偷看后面的 欸 是个 a, 那就没错 这把稳了,是个变量声明。
        这个神技,有个比较正规的名字 叫前瞻 lookahead。
        当解析器在解析某句或某段代码时,是解析器中的某一个解析函数在工作,很有可能是被上面层层甩锅甩下来的,轮到这个解析函数时,很大的可能,是这句或这段代码的解析,就属于它的本职工作,它按照自己的解析流程判断逻辑,来使用前瞻技能,预判下一个token是否符合它的工作逻辑需求。
      • 消费 consume   当确认这个当前的 Token 没问题,就把它“吃掉”,consume 即消费掉,同时指针移动,指向下一个token,准备处理下一个。
        比如 当前指针指着const,它偷看后面的,是个a,它就确定  符合它变量声明的岗位的判断逻辑,于是,它就吃掉 消费掉当前指针指着的const,然后指针移动到a,重复它的偷看和消费的步骤。

    • 简单来说,解析过程就是:用 前瞻 提前偷看 lookahead 决策,用 消费 consume 前进,一层层把工作交给合适的解析函数,直到整段代码被解析完成。
    • 前面说  懒惰为主  全量为辅,意思是从解析结果  从解析数量上来看, 很大很大部分都是做的懒惰解析  预解析,是占主要的部分。  而全量解析做的很少。
      那么  从解析流程的决策层面来看,从“指挥权”来看,全量解析为主

      • 全量解析负责开场,它负责做决定,它负责把控全局。没有它,预解析根本不知道什么时候进场工作。即 全量解析是主导流程的
      • 这里要特别注意,我们把 主解析器和全量解析 作为一个整体来讲的,在v8中,主解析器和全量解析器 基本上可以划上等号,所以 说全量解析为主导流程 ,就是说主解析器主导流程。  主解析器/全量解析 推进流程, 遇到非立即执行的代码,就呼唤预解析器来工作。
      1. // 全量解析 即主解析器正在干活 构建全局AST
      2. var a = 1;
      3. // 突然遇到了一个函数声明!
      4. function lazy() {
      5.   var b = 2;
      6.   console.log(b);
      7. }
      8. // 全量解析:"哎呀,是个函数声明,估计没人调用它,我不进去了,太费劲。"
      9. // 于是指挥 预解析去干活
      10. // 切换到了 预解析
      11. // 预解析快速扫描 lazy 内部:
      12. // 1. 检查有没有语法错误?(没有)
      13. // 2. 检查有没有引用外部变量?(没有)
      14. // 3. 检查结果:"里面安全,是个普通函数。"
      15. // 4. 于是生成一个"占位符节点",预解析器收工。
      16. // 切换回到了 全量解析/主解析器
      17. // 全量解析继续往下
      18. var c = 3;
      19. // 遇到了 立即执行函数
      20. // 全量解析一看:"哎,这后面有个括号 (),马上要跑"
      21. // 不能喊外包了,得自己来干这一票。
      22. // 全量解析进入函数内部构建 AST
      23. (function urgent() {
      24.    var d = 4;
      25. })();
      复制代码
    • 我们说预解析 虽然不生成AST节点,只是生成占位符节点,但是也需要快速扫描内部。
      1. // 对外部函数进行全量解析,对内部函数进行预解析
      2. function father() {
      3.   let dad = '爸爸';
      4.   //全量解析中,遇到内部函数,额太累,呼叫外包 预解析
      5.   // 预解析进来  开始快速扫描 son 的内部文本...
      6.   function son() {
      7.     console.log(dad);
      8.   }
      9. }
      10. 预解析 :
      11. 它看到 console.log,不生成 AST 节点。
      12. 它看到 dad 这个标识符
      13. 判断:son 内部声明过 dad 吗?(没有)。
      14. 判断:这是一个未解析的引用 (Unresolved Reference)。
      15. 结果: 预解析 扫描完 son 后,虽然把中间的信息扔了(不存 AST),但会给 father 的作用域留下一条极其重要的情报:
      16. 该子函数内部引用了你的 dad 变量
      17. father函数的反应 (Context 分配)
      18. 收到预解析的情报后,father 函数此时已经在忙碌中了,它就会做出反应:
      19. 本来 dad 是准备分配在 栈 (Stack) 上的。
      20. 因为收到了预解析提供的闭包引用信息,所以
      21. father 的作用域分析结果中,dad 被标记为 需要 Context 分配。
      22. 结果: dad 被移入堆内存的 Context 中,确保 father 死后 dad 还能活。
      23. 这里要特别注意,这是蓝图 蓝图, 此时是静态解析阶段,所说的都是蓝图  都是画的大饼。
      24. 关于怎么描述 被移入堆内存的上下文中,后面会详细讲。
      25. 那么 这个占位符里是什么内容呢?
      26. 对于预解析的函数 son,
      27. AST 树上只有一个“占位符节点”(UncompiledFunctionEntry),在 V8 中,这个占位表示会与一个 SharedFunctionInfo 关联,用来保存函数的元信息(如参数、作用域、是否为闭包等),供后面真正全量解析和编译时使用,
      28. 元信息中大致有如下内容:
      29. 没有 AST节点:也就是没有具体的代码逻辑结构。
      30. 有作用域信息 (ScopeInfo):
      31. 它知道自己内部引用了哪些外部变量。
      32. 它知道自己是不是闭包。
      复制代码
      关于作用域,后面会详细讲。上面是先讲占位符里是有这些信息的,否则无法保证闭包蓝图的完整性和准确性。
    • 经过上面的铺垫,我们现在开始AST的解析了。这部分内容是否有必要展开, 我纠结了起码两盏热茶的时间,因为从了解的角度来说 ,上面的内容,已经足够了,甚至在中级高级前端开发的岗位面试中,也足够了。 但是,我又觉得具体的解析也有必要讲讲,毕竟都学到这块内容了,稍微再往深处瞄那么几眼,也可以的。
      我们以v8为例。
      为了说明白,现在开始就不得不使用具体的函数名了,不过基本上这些函数名都有规律,看名字就差不多知道含义了。
      ParseStatementList(语句列表解析)是真正的循环驱动者。如果不严格区分顶层入口的话,我们可以把它看作解析流程的主引擎。它的工作非常单纯枯燥:就是开启一个 while 循环,只要没到文件结尾,就驱动 项/Item 这一级别的解析。
      而在循环内部,它会把每一次的处理任务甩锅给 ParseStatementListItem(项级入口)。
      可能有朋友会疑问:什么是“项(item / 条目)”这一级别?可以这样理解:从语法上讲,语句加上声明,就构成了 项/item/条目, 但是语句和声明   他们有很大的不同。既要区分他们,又要在一个大循环里统一处理他们,所以有了 项 这个称呼。
      有些 声明、模块的 import / export、在允许位置上需要提升并且登记到作用域的函数声明、需要做早期错误检测的地方等等,就要求优先的处理   比如提前登记名称和作用域信息、报早期错误,或者做预解析并留下占位符
      ParseStatementListItem() 负责做项级的分流,如果检测到是 import/export、可提升的函数声明或其他项级必须优先处理的内容,就在此处定向甩锅,通常是直接甩给对应的具体解析函数,如果检测到不是需要优先处理的声明定义,而是普通的语句,它会把该条甩锅给 ParseStatement(),就是普通的语句级解析,由语句级负责普通语句(控制流、块、表达式语句等)的详细解析。在解析器层面上的这两种分流保证了 提升、模块 规则和语句语义既能正确又便于优化实现。
      1. ParseStatementList:负责整体推动循环,偷看一眼,现在只要不是eof结束标记,不管其他是什么内容,统统一股脑的甩锅。
      2. ParseStatementListItem:负责在 项级 这一层面分流, 综合以下判断:
      3. 当前 token + 当前的语境 + 语法规则 + 可能有的预判
      4. 分流为声明级解析和普通语句级解析,
      5. 如果是声明级  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循环里处理。
      1. 伪代码
      2. // 入口:解析二元表达式,传入当前允许的最小优先级
      3. function ParseBinaryExpression(min_precedence) {
      4.   //  [初始左值] 先搞定左边的原子 (例如: 1)
      5.   let x = ParseUnaryExpression();
      6.   //   开启大循环
      7.   // 只要后面还有能吃的符号,就一直在这个循环里转
      8.   while (true) {
      9.       let op = Peek(); // 偷看下一个符号
      10.   
      11.       // 遇到这两种情况 1是符号没了,到头了 2是下个符号太弱了,该上层递归要管的事情,
      12.       // 这时,就带着手里积攒的 x 赶紧返回
      13.       if (!op || op.precedence <= min_precedence) {
      14.          return x;
      15.        }
      16.       //  [消费] 优先级够格,吃掉符号 (比如 +)
      17.        Consume(op);
      18.       // [递归获取右值]
      19.       // 让递归函数去拿右边的数。
      20.       // 关键点:把当前 op 的优先级传下去
      21.       // 这样如果右边是同级运算(如 1+2+3),递归函数会发现优先级不够,只拿一个数就立马       //   返回。
      22.       // 如果右边是高级运算(如 1+2*3),递归函数会深入处理。
      23.       let y = ParseBinaryExpression(op.precedence);
      24.       // [原地累加 像滚雪球]
      25.       // 把左边(x)、符号(op)、右边(y) 组装成新节点。
      26.       // 核心动作:把新节点赋值回 x
      27.       // 现在的 x 从 "1" 变成了 "(1+2)"。
      28.       x = NewBinaryNode(op, x, y);
      29.       //  [循环继续]
      30.       // 代码运行到这里,会回到大循环开始处。
      31.       // 此时手里拿着新的 x, (1+2),去偷看下一个符号(比如 +3 的那个 +)。
      32.       // 如果下一个符号优先级还够,就继续吃;不够就由if语句退出。  
      33.    }
      34.    
      35. }
      36.       
      复制代码
      这就是覆盖语法 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的编码时,要知道这两种的区别和性能上的差异。虽然说 现在电脑性能快到飞起,都得用石头压住,而且浏览器本身的优化也很厉害,一丢丢丢丢的性能差异完全不用担心,但是,万一你换工作去面试,正巧问到你这两种的区别。。。嘿嘿嘿,你就真的可以像那些八股文里说的那样  吊打面试官了。想想都刺激。


  • 在前面,我们了解了,在 项 级的解析中,它实际是个分流处,把声明的项拦截后直接甩锅, 把语句的项甩锅给语句解析。而上面我们花了大篇幅讲的表达式解析,是语句解析中,负责兜底的表达式解析。 所以 我们还剩下可用关键字匹配的语句解析  和 在项 级就被直接派发的声明的解析。现在我们开始了解声明的解析。
声明的解析
声明的解析不多,总结起来,就是:一类四函两变量
  1. function (a, b) {
  2.   return a + b;
  3. }
复制代码
可能有朋友会问了:var哪儿去了?  在js规范中, var属于 语句,不属于声明,即 var属于VariableStatement  。 但是 从var的效果和语义上来说,它确实是声明变量。
所以 从规范的角度来说, 声明 只有这一类四函两变量, 没有var, 但可是, 在v8的具体实现中,let const var  这三个却是被分到一起 作为变量定义 派发到了ParseVariableDeclarations中解析,只是在里面解析的时候 他们有不同的处理分支。
在进行下一步学习之前 ,我们再次的总结一下:
开始解析之后,来到 项级  被分流成两种, 一个是声明  包括(一类四函两变量)4种函数被发到ParseHoistableDeclaration,类被发到ParseClassDeclaration,变量声明(这里要注意,js规范var不属于声明,但是v8中 var也在这里被分发了) 被发到ParseVariableDeclarations,
还有一个是语句,语句统一被甩锅到ParseStatement进行解析,在解析时  先按关键字派发,无关键字匹配的甩给表达式兜底。
我们首先以一个简单的函数声明的解析为例。
  1. class C {}   // 类
  2. function f() {}  //四种形式的function
  3. function* g() {}
  4. async function f() {}
  5. async function* g() {}
  6. let     //变量
  7. const
复制代码
初始情况:

  • 当前作用域: 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

  • 偷看: 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
  1. function add(x, y) {
  2.   let result = x + y;
  3.   return result;
  4. }
复制代码
第一步:生成代理 解析器解析 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 ,它没有作用域。
  1. function order() {
  2.   return dish;     // A: 使用 dish
  3. }
  4. var dish = '周黑鸭'; // B: 定义 dish
复制代码
为什么,同样是大括号,函数那里是作用域,对象这里却只是一个框框,只表示一个数据结构?
可以从以下几个方面来说:

  • 语法
    作用域的大括号,它里面装的是语句, 是动词 是命令 比如 a=1,这里=是赋值运算,表示一个动作,他的意思是 在这个作用域里面,开一个槽位,把1放进去。
    对象的大括号,它里面装的是属性定义,属性是描述,是名词。比如 name:’阿祖‘ ,这里要用冒号, 不能用=号,如果手抖用了=号,马上出错 SyntaxError: Unexpected token '=' 。 在对象里,没有变量的说法 只有 键 和  值 的映射关系,只可以用冒号。
  • 时序
    函数是有提升的, 而对象没有,
    1. var obj = {
    2.   name: '阿祖',
    3.   say: '我是' + name  // 报错!或者是拿到全局的 name
    4. };
    复制代码
    当引擎解析时,
    读到 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现在都能在里面写 = 号了。
  1. var obj = {
  2.   a: 1,
  3.   b: a  // 想引用上面的 a
  4. };
复制代码
class是构造函数的语法糖,在es6以后,确实可以写=号。
但是  可以写=号,也是一个语法糖。引擎并不会把类里的=号 当成变量声明,而是把它放到constructor构造函数里面, 改成
  1. class Obj {
  2.   name = '阿祖'; // 这里写了等号
  3.   say = () => { console.log(this.name) }; // 这里也用了变量
  4. }
复制代码
引擎悄悄的使用 this.name=。。。  进行了属性赋值,而不是 var name=。。。,它使用的依旧是对象的规则,不是作用域的规则。
你在 class 里面写 name,如果不加 this,依然访问不到这个属性,还得去外层作用域找。
总结对象:

  • 对象没有墙:它只是数据的容器,不是变量的隔离区。
  • 对象的大括号是骗子:不要因为长得像块级作用域,就以为它是作用域。
  • 冒号不是等号:: 是画地图(定义结构),= 是发指令(执行赋值)。
  • 目的不同:作用域是为了执行代码,对象是为了存储数据。V8 从底层就把它们分到了不同的“部门”。
话音未落,又有朋友大声说 骗子 现在类里面不止=号,什么都能写,还有作用域。
  1. // 引擎偷摸的操作
  2. function Obj() {
  3.   this.name = '阿祖'; // 变成了属性赋值
  4.   this.say = ...
  5. }
复制代码
它并不是 对象属性, 而是披着大括号外衣的函数。
虽然static写在class里面,但是 static{...} 并不是定义一个叫 static 的属性(不像 name: '阿祖')。在 V8 眼中,看到 static 关键字后面紧跟一个 {,解析器会立马切换模式:
“注意,这不是在列清单定义属性,这是要执行代码!给我开辟一个新的 类作用域 (Class Scope)
所以,static { ... } 内部,实打实地拥有一个块级作用域。
你在static{...}里面 let a = 1,这个 a 就死在这个大括号里,外面谁也看不见。这完全符合作用域的定义。
本质上,这个静态块相当于一个绑定了 this 的立即执行函数 ,this值为这个class构造函数本身。
  1. class Database {
  2.   static data = [];
  3.   
  4.   // 静态初始化块
  5.   static {
  6.     try {
  7.       const content = loadFromFile(); // 可以写逻辑呀
  8.       this.data = content;
  9.     } catch {
  10.       this.data = []; // 可以写 try-catch呀
  11.     }
  12.   }
  13. }
复制代码
正因为它本质上是代码执行,而不是数据描述,所以它里面当然可以有作用域,当然可以写语句。
这并不是对象大括号变成了作用域,
而是 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小部分,我们首先讲了声明的解析,并用一个例子详细说明了解析过程,然后,插队讲解了几个比较重要的  而且在后续学习中需要用到的知识点,这几个知识点,即使在平时的前端开发中,也属于比较重要的。现在我们继续一起学习 声明的解析 吧。 如果对解析的流程有些忘记了朋友,可以往上翻,回看一下第一个函数的解析。
    现在我们开始学习带闭包的函数的解析
    1. // 我们的代码
    2. class C {
    3.   static { ...code... }
    4. }
    5. // V8 眼中的代码
    6. class C { ... }
    7. // 马上执行的立即执行函数
    8. (() => {
    9.    // ...code...
    10.    // 这里的 this 指向 C
    11. }).call(C);
    复制代码

    • 解析外部函数
      解析器进入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 里去。
          只捕获用到的,绝不浪费一粒米。默认的 小包

      • 特殊情况一:被迫连坐 中包
        1. function outer() {
        2.   let treasure = '大宝贝'; // 1. 声明变量
        3.   
        4.   function inner() {
        5.     return treasure;     // 2. 内部引用(闭包)
        6.   }
        7.   
        8.   return inner;
        9. }
        复制代码

        • 扫描 useHeavy:发现它用了 heavyData。--- heavyData 必须进 Context。
        • 扫描 useLight:发现它用了 lightData。--- lightData 必须进 Context。
          关键点来了:
          同一个作用域(factory)下生成的闭包,它们共享 同一个 Context 对象。
          只要有一个闭包(哪怕是没被返回的 useHeavy)把 heavyData 拖进了 Context,那么这个 Context 里就实打实地存着 heavyData。
          虽然只返回了 useLight,但 useLight 手里握着的钥匙,打开的是那个 包含了 heavyData 的 Context。
          只要 useLight 还要活下去,那个 Context 就得活下去,那个超大的 heavyData 也就得活下去,无法被垃圾回收。
          结论:打包的是 中包。同一个作用域下的所有闭包,共享同一个“包”。进了包以后,无法区分哪个被真的return出去,所以兄弟连坐。

      • 特殊情况二:eval  一锅端大包
        1. function factory() {
        2.   let heavyData = new Array(1000000); // 这是一个超大的数据
        3.   let lightData = '小喽啰';
        4.   function useHeavy() {
        5.     // 这个闭包用了 heavyData
        6.     console.log(heavyData.length);
        7.   }
        8.   function useLight() {
        9.     // 这个闭包只用了 lightData
        10.     console.log(lightData);
        11.   }
        12.   // 只把 useLight 返回出去了,useHeavy 根本没返回,扔了
        13.   return useLight;
        14. }
        15. const myClosure = factory();
        复制代码
        解析器扫描 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 节点。

    下面我们再以单个的例子来学习一下
    1. function risk() {
    2.   let a = 1;
    3.   let b = 2;
    4.   // ... 这里还有 100 个变量 ...
    5.   
    6.   return function inner() {
    7.     eval("console.log(a)"); // 沃特啊油督应?
    8.   };
    9. }
    复制代码

    • 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 有提升(名字上去了),但赋值没提升(赋值节点还在原地)。
    1. function foo() {
    2.   if (true) {
    3.     var a = 1;
    4.   }
    5. }
    复制代码

    • 动作:消费 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 三种变量的解析。我们继续声明的解析,还有一个类。
    1. {
    2.   let b = 2;
    3. }
    复制代码

    • 环境初始化
      当解析器读到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 中,构造函数实际上变成了这样:
        1. class Hero {
        2.   name = '阿祖';             // 1. 实例字段 (Field)
        3.   static version = '1.0';    // 2. 静态属性 (Static)
        4.   
        5.   constructor(skill) {       // 3. 构造函数
        6.     this.skill = skill;
        7.   }
        8.   say() {                    // 4. 原型方法
        9.     return '我是' + this.name;
        10.   }
        11. }
        复制代码
      • 组装 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:透明的框
    1. // V8 内存中的构造函数(伪代码)
    2. function Hero(skill) {
    3.   // --- V8 添加的字段初始化逻辑 ---
    4.   // 注意:这里是一个隐式的 Block
    5.   // 是因为这里是由合成函数转化的,包含了逻辑  也包含了独立的作用域
    6.   this.name = '阿祖';
    7.   // -----------------------------
    8.   // --- 用户写的逻辑 ---
    9.   this.skill = skill;
    10. }
    复制代码
    V8 扫描这个块,发现里面只有 var(或者普通语句),没有 let/const/class。
    V8 会想:“欸,只有 var 这种穿墙怪?或者只是普通的计算?那我没必要专门申请一个 BlockScope 对象浪费内存了。”
    结果就是 这个 Block 在 Scope 树上是 透明 的。AST 上虽然有 Block 节点,但它不对应任何 Scope。变量 a 直接登记在所在的函数作用域里。
    场景 B:实体的墙
    1. {
    2.   var a = 1;
    3.   console.log(a);
    4. }
    复制代码
    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 部分就是空的。

    遇到语法歧义问题匹配哪个呢?
    1. {
    2.   let b = 1;
    3. }
    复制代码
    这个 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)
    。。。。。。看起来似乎挺复杂,实际上也不是很简单,所以我们需要仔细耐心的学习。
    1. if (a)
    2.   if (b) x++;
    3. else y++;
    复制代码
    分析这个例子
    第一阶段:主线程 循环阶段

    因为 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。
    我们再来看这个例子
    1. for (var i = 0; i < 3; i++) {
    2.   setTimeout(() => console.log(i), 0);
    3. }
    复制代码
    这里有两个变量:
    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

      • x = 2。

    • 闭包生成

      • 捕获 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的例子
    1. for (var i = 0; i < 3; i++) {
    2.   let x = i;
    3.   setTimeout(() => console.log(x), 0);
    4. }
    复制代码
    这个才是我们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. for (let i = 0; i < 3; i++) {
    2.   let x = i;
    3.   setTimeout(() => console.log(x), 0);
    4. }
    复制代码
    这段伪代码很简单,解析器在分析作用域时,识别出 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 (自动分号插入)
    看这段
    1. 伪代码
    2. { // 1. 循环头作用域 (Header Scope)
    3.   let i = 0; // 真正的 i 声明在这里
    4.   // 循环开始
    5.   loop_start:
    6.   if (i < 3) {
    7.       
    8.       // 2. [v8偷摸施法] 迭代作用域 (Iteration Scope)
    9.       // V8 会在每次进入循环体前,悄悄的创建一个新作用域
    10.       // 并且把当前的 i 值,"复印" 给一个临时变量
    11.       {
    12.          let _k = i; // 影子变量,捕获当前的 i
    13.          
    14.          // 3. 循环体作用域 (Body Scope)
    15.          {
    16.             let x = _k; // 用户写的 x = i,实际上变成了 x = _k
    17.             setTimeout(() => console.log(x), 0);
    18.          }
    19.       }
    20.       // 步进操作
    21.       i++;
    22.       goto loop_start;
    23.   }
    24. }
    复制代码
    解析器读到 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/

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

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