淄博市网站建设_网站建设公司_图标设计_seo优化
2025/12/29 18:22:19 网站建设 项目流程

这是完整的一篇超长文章,内容为javascript V8引擎的 词法分析 语法分析 编译 执行 优化 等完整的一个链条,内容详略得当 可以按需要部分阅读 也可以通篇仔细观看。

依旧是无图无码,网文风格。我觉得,能用文字把逻辑或者概念表述清楚,一是对作者本身的能力提升有好处,二是对读者来说 思考文字表达的内容 有助于多使用抽象思维和逻辑思维能力,构建自己的思考模式,用现在流行的说法 就是心智模型。你自己什么都可以脑补,那不是厉害大了嘛。

上面的话不要相信,其实我就是为自己懒找的借口。

这部分内容,能学习了解,当然最好,对平时的前端开发,也有好处,不了解,也不影响日常的工作。但是总体来说,很多开发中的问题,在这部分内容中 都可以找到根源。有些细节做了省略 有些边界情况做了简化表述。不过 , 准确性还是相当不错的。依旧是力求高准确性,符合规范,贴合实现。

篇幅比较长,可以按需要阅读,内容链条如下:

1识别-2流式处理-3切分-4预解析和全量解析-5解析概述-6解析具体过程.表达式的解析-7声明的解析-8函数的解析-9变量的解析-10类的解析-11语句的解析

其中包含单个完整的知识点分散在各部分:闭包 作用域 作用域链/树 暂时性死区。。。可搜索关键字查找。

版权声明呢。。。码字不易,纯脑力狂暴输出更不易

欢迎以传播知识为目的全文转载,谢绝片段摘录。 谢绝搞私域流量的转载。

一.词法分析和语法分析

当浏览器从网络下载了js文件,比如app.js,浏览器引擎拿到的最初形态是一串**字节流 **。

  1. 识别:浏览器根据 HTTP 响应头,通常是 Content-Type: text/javascript; charset=utf-8 将下载的字节流解码为字符流并交给 V8。V8 在内存中存储字符串时采用动态编码策略:在可行的情况下优先使用单字节(Latin-1)格式存储,只有当字符串中出现 Latin-1 范围外的字符(如中文、Emoji)时,才会转为双字节(UTF-16)格式。

  2. 流式快速处理: 引擎并不是等整个文件下载完才开始干活的。只要网络传过来一段数据,V8 的扫描器就开始工作了。 这样可以加快启动速度。此时的状态就是毫无意义的字符 c, o, n, s, t, , a, , =, , 1, ; ...

  3. 然后的这一步叫 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 (符号 ";")

    这一步,注释和多余的空格和换行符会被抛弃。

  4. 现在就是解析阶段了

    其实解析是一个总称,它分为 全量解析 和 预解析 两种形式。

    这就是v8的懒解析机制。看到这个懒字,也差不多能明白了吧。

    对于那些不是立即执行的函数(比如点击按钮才触发的回调),V8 会先用预解析快速扫一遍。

    检查基本的语法错误(比如有没有少写括号),确认这是一个函数。并不会生成复杂的 AST 结构,也不建立具体的变量绑定,只进行最基础的闭包引用检查。御姐喜的结果是这个函数在内存里只是一个很小的占位符,跳过内部细节。

    而只有那些立即执行函数或者顶层代码,才会进入真正的全量解析,进行完整的 AST 构建。

    那么,问题就来了,v8怎么判断到底是使用预解析还是使用全量解析呢?

    它的原则就是 懒惰为主 全量为辅

    就是v8默认你写的函数暂时不会执行,除非是已经显式的通过语法告诉它,这段这行代码 马上就要跑 你赶快全量解析。

    下面 我们稍微详细的说一下

    • 默认绝大多数函数都是预解析

      v8认为js在初始运行时,仅仅只有很少很少一部分代码 是需要马上使用的 其他觉得大部分 都是要么是回调 要么是其他的暂时用不到的,所以,凡是具名函数声明、嵌套函数,默认都是预解析。

      function clickHandler() {console.log("要不要解析我");
      }
      // 引擎认为 这是一个函数声明  看起来还没人调勇它
      // 先不浪费时间了,只检查一下括号匹配吧,
      // 把它标记为 'uncompiled',然后跳过。"
      
    • 那么 如何才能符合它进行全量解析的条件呢

      1. 顶层代码

        写在最外层 不在任何函数内 的代码,加载完必须立即执行。

        判断依据: 只要不在 function 块里的代码,全是顶层代码,必须全量解析。

      2. 立即执行函数

        那么这里有个问题,就是V8 如何在还没运行代码时,就知道这个函数是立即调用执行函数呢?

        答案就是 看括号()

        当解析器扫描到一个函数关键字 function 时,它会看一眼这个 function 之前有没有左括号 (

        • 没括号

          function foo() { ... }
          // 没看到左括号,那你先靠边吧, 对它预解析。
          
        • 有括号

          (function() { ... })();
          // 扫描器扫到了这个左括号
          // 欸,这有个左括号包着 function
          // 根据万年经验,这是个立即执行函数,马上就要执行。
          // 直接上大菜,全量解析,生成 AST
          
        • 其他的立即执行的迹象:除了括号,!+- 等一元运算符放在 function 前面,也会触发全量解析

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

    • 如果有嵌套函数咋办呢

      嵌套函数默认是预解析,即使外部函数进行的是全量解析,它内部定义的子函数,默认依然是预解析。只有当子函数真的被调用时,V8 才会暂停执行,去把子函数的全量解析做完 把 AST 补齐

      //顶层代码全量解析
      (function outer() {var a = 1;// 内部函数 inner:// 虽然 outer 正在执行,但 inner 还没被调用// 引擎也不确定 inner 会不会被调用。// 所以inner 默认预解析。function inner() {var b = 2;}inner(); // 直到执行到这一行,引擎才会回头去对 inner 进行全量解析
      })();
      
    • 那么 引擎根据自己的判断 进行全量解析或者预解析,会出错吗

      当然会,

      如果是本该预解析的 结果判断错了 进行了全量解析 浪费了时间和内存生成了 AST 和字节码,结果这代码根本没跑。

      如果是本该全量解析的又巨又大又重的函数 结果判断错了 进行了预解析,然后马上下一行代码就调用了,结果就是 白白预解析了一遍,浪费了时间,发现马上被调用,又马上回头全量解析一边 又花了时间,两次的花费。

  5. 在上面只是讲了解析阶段的预解析和全量解析的不同,现在我们讲解析阶段的过程

    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) 时,它会生成一个 变量代理
    • 链接过程: 解析器会尝试“连接”这个代理和声明:
      1. 先在当前 Scope 找 a
      2. 找不到?沿着 Scope Tree 往上找父作用域。
      3. 找到了?建立绑定。
      4. 一直到了全局还没找到?标记为全局变量(或者报错)。

    这里要注意: 这个“找”的过程是在编译阶段完成的逻辑推导。

    闭包的蓝图被预判

    这一步是 V8 性能优化的关键,也就是作用域分析。

    • 发现闭包: 解析器发现内部函数 inner 引用了外部函数 outer 的变量 x

    • 打个大标签:

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

      • 此时内存里没有上下文对象,也没有变量 x 的值(那是运行时的事)。

      • AST 只是生成了一张“蓝图”,图纸上写着:“注意,将来运行的时候,这个 x 要放在特别的地方 - Context里,别放在栈上。”

  6. 现在 我们来复一下盘 重点学习解析过程

    字节流---被切成有语法意义的最小单元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 的形式,尝试匹配所有具有确定起始关键字或符号的语句形式(如 ifforreturn{ 等)。匹配上以后 对准那个匹配成功的解析函数,甩锅下去。其他尚未识别的 则甩给表达式解析,这是因为表达式的形式有很多,而且无法根据关键字来识别,所以 可以说表达式解析是个兜底。 如果是被甩锅到表达式解析,首先由表达式的赋值解析接手, 解析流程统一从 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

      1. 赋值层启动:赋值解析拿到 m,消费掉 = 号,并记住 =
      2. 开始第一次递归调用(赋值表达式解析):为了解析右值。
        • 甩锅环节:拿到 1,不认识,甩甩甩...
        • 1 节点被返回,返回到 二元解析(Level 0) 这里。
      3. 二元解析(Level 0)
        • 状态:接收 1 节点。
        • 偷看+ 号(优先级 12)。
        • 判断:当前门槛 0,12 > 0,消费 + 号,记忆 + 号。
        • 递归调用:调用二元解析,门槛设为 12。
      4. 第一次递归二元解析(Level 1)开始
        • 甩锅环节2 不认识,甩甩甩... 返回 2 节点。
        • 状态:接收 2 节点。
        • 偷看* 号(优先级 13)。
        • 判断:当前门槛 12,13 > 12,可以吃! 消费 * 号,记忆 * 号。
        • 递归调用:调用二元解析,门槛设为 13。
      5. 第二次递归二元解析(Level 2)开始
        • 甩锅环节3 不认识,甩甩甩... 返回 3 节点。
        • 状态:接收 3 节点。
        • 偷看:没了(或者分号)。
        • 判断:优先级不够。
        • 返回:直接返回 3 节点。
      6. 回到第一次递归(Level 1)
        • 组装:接收到 3 节点。左手是 2,右手是 3,记忆是 *
        • 动作:组合成 2 * 3 节点。
        • 返回:把 2 * 3 节点往上交。第一次递归结束。
      7. 回到二元解析(Level 0)
        • 组装:接收到 2 * 3 节点。左手是 1,右手是 2 * 3,记忆是 +
        • 动作:组合成 1 + (2 * 3) 节点。
        • 返回:往上交。直到赋值表达式。
      8. 回到赋值表达式(第一次递归调用处)
        • 状态:接收 1 + 2 * 3 节点。
        • 偷看:没了。
        • 返回:第一次赋值解析递归调用返回。
      9. 回到最顶层赋值解析
        • 组装:当前左手 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 自己负责函数调用和模板字符串的解析。

      简要流程:

      1. 先找头: 先解析出 m

      2. 进入循环: ParseMemberExpression 启动 while 循环,偷看后面。

      3. 处理中括号: 发现是 [,吃掉它。

        这里会调用 ParseExpression(true)。这个 true 表示允许包含逗号,表示中括号里可以写完整的表达式(比如 1+1或者更复杂的表达式)。

      4. 组装: ParseExpression 返回节点 2,吃掉 ],将 m2 组装起来。

      5. 继续循环: 如果后面还有 [.(比如二维数组或链式调用),就继续解析、继续包在外面组装;如果没有,就返回。

      下面我们进入思考模式

      我们说 在赋值解析的时候 要使用递归调用,这是没有任何问题的,因为递归调用本身就可以得到右结合的目的,和连等赋值的定义是相符合的。

      在二元解析的时候,我们也说使用递归调用,但是这就有些问题,因为递归调用会产生右结合,而通过使用优先级 和遇到同级操作符 则退出递归 由上级处理左结合以后 再次递归,这样也可以达到左结合的目的。 这种方式本身也没问题,从嵌套深度上来讲,极限情况下 也不过是十多个递归嵌套,并不会栈溢出。 但是从横向上来看,比如 有多个同级操作符的时候 就比较繁琐,极其频繁的函数调用,开销比较大。

      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) 这是最重要的一步。解析器不销毁节点,而是修改节点的 性质

      • 它把 abVariableProxy 节点,原地转化参数声明
      • 关键动作
        • 之前,a 指向的是外层作用域(试图引用)。
        • 现在,解析器把 a 从外层作用域的引用列表中摘除
        • 然后,把 a 作为 新声明,登记到即将创建的 FunctionScope 里。

      从此,ab 从“消费者”(引用)变成了“生产者”(声明)。

      阶段四:解析函数体

      参数搞定了,现在处理 => 后面的 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):

      1. 解析器在当前 Scope 找 this
      2. 找不到!(因为根本没声明)。
      3. 往上找:沿着 outer_scope 指针去父级作用域找。
      4. 结果:它自然而然地就用了外层的 this

      这不是什么特殊的“绑定机制”,这单纯就是“变量查找机制”的自然结果。

      因为它自己没有,所以只能用老爸的。这就是 词法作用域 (Lexical Scoping) 的本质。

      从解析器的角度看,箭头函数是一个 “三无” 产品,这正是它轻量的原因:

      1. this:Scope 里不声明 this,直接透传外层。
      2. arguments:Scope 里不声明 arguments 对象,也是透传。
      3. construct:生成的 FunctionLiteral 节点会被标记为“不可构造”。如果你想 new 它,现在炸不了你,过一会肯定炸飞你。

      通过箭头函数的学习,说明俩问题。

      1. 解析层面的歧义(为什么解析器要回溯、重解释)。
      2. 作用域层面的 this 本质(不是绑定,而是查找)。

      上面 我们已经基本上将表达式解析的比较常见的形式 从超级详细的撕扯到简略的梳理,讲了几个,如果能耐心的看完,相信自己也可以分析了,即使还有没遇到的表达式形式,根据惯用的套路,也能自己搞定。

      在学习这些内容时,要联系到在js层面编码时,表现出的特点。这样不仅js能掌握的牢, 底层也记得住。 比如obj.data.list的解析,主要是在LHS层里的while大循环里解析点后面的内容,内容是字符串的形式, 是固定的, 而m[2],解析的时候,Lhs看到是中括号里的内容,是调用了顶层的表达式解析函数来干活的,表达式解析可以解析的东西那可多了,而且还可能有递归,所以在js的编码时,要知道这两种的区别和性能上的差异。虽然说 现在电脑性能快到飞起,都得用石头压住,而且浏览器本身的优化也很厉害,一丢丢丢丢的性能差异完全不用担心,但是,万一你换工作去面试,正巧问到你这两种的区别。。。嘿嘿嘿,你就真的可以像那些八股文里说的那样 吊打面试官了。想想都刺激。

  7. 在前面,我们了解了,在 项 级的解析中,它实际是个分流处,把声明的项拦截后直接甩锅, 把语句的项甩锅给语句解析。而上面我们花了大篇幅讲的表达式解析,是语句解析中,负责兜底的表达式解析。 所以 我们还剩下可用关键字匹配的语句解析 和 在项 级就被直接派发的声明的解析。现在我们开始了解声明的解析。

声明的解析

声明的解析不多,总结起来,就是:一类四函两变量

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 会在后续的编译/实例化阶段,确保在任何代码执行前,这个名字就已经指向了完整的函数体。这就实现了我们常说的“函数整体提升”。
    • 所以,虽然此时只是在小本本上记了个名字(占位),真正的函数对象创建和绑定要等到后续阶段。但对解析器来说,名字有了,就可以继续往下走了。
  • 准备进入实体: 名字搞定后,剩下的 (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 的时候,直接去查一下不就行了吗?为什么还要这么麻烦搞个代理?

原因主要有两个:

  1. 是因为 JS 允许在变量定义前使用它:比如函数提升、var 提升。当它读到一个不确定的变量时,不能报错也不能立刻绑定,所以它只能先生成一个 VariableProxy(a) 放在 AST 里面,表明这里有个 a 的坑,等全部解析完了,我得过来填坑。
  2. 是因为解析的顺序限制:解析器是从上往下读的。举个最简单的例子: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 函数内部:

  1. 读到 return
  2. 读到 dish。 “这是个变量名。但我现在只负责造树,不知道 dish 是谁。”
  3. 动作:创建一个 VariableProxy 节点。
    • 名字: "dish"
    • 状态: Unresolved (未解决/未找到)
  4. 把这个节点挂在 ReturnStatement 下面。

此时 AST 的状态: ReturnStatement - VariableProxy("dish") (手里拿这个只有名字的小票,不知道去哪领菜)

第二步:变量解决 (Variable Resolution) —— 兑换 这一步通常发生在前面讲解例子的时候的第13步, Scope Finalization(作用域收尾/算总账) 阶段,也有可能是后续的编译阶段。

V8 开始拿着这张小票(Proxy)去兑换:

  1. 问当前作用域 (FunctionScope):“你这里有 dish 的声明吗?”
    • 回答:没有。
  2. 问父作用域 (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,就圈了一块地。函数里的 varlet、参数,都归它管。
  • 块 (Block):这是 ES6 新晋的小地主。凡是 { ... } 包起来的(比如 iffor 或者直接写的大括号),在语法上都算作“块”。

但是,V8 在块级作用域这里是非常现实的。

如果大括号里没有 letconst,V8 觉得专门为你建一个 Scope 对象太浪费内存了,根本懒得搭理你。此时,它在 V8 眼里实际上并不构成独立作用域,变量查找直接走外层。

只有当大括号里出现了 letconst 这种新贵小王子时,V8 才会真的给它发“房产证”,专门创建一个由大括号为标志的块级作用域 BlockScope

注意 var:至于 var,它比较特殊。它看不上块级这种小地盘,这种大括号根本关不住它。它会直接穿墙出去,去找外面的函数地主或者全局地主。

那么,变量有没有作用域呢?

准确地说:变量本身并不能拥有作用域,但是变量属于某个作用域。

我们说 a 的作用域是函数 f,实际是在说,变量 a 处在函数 f 的作用域里。

在 V8 内部,每个作用域都有一个清单,上面详细记录了:

“我这块地盘上,住了张三、李四、还有老王...”

如果解析器在这一层没找到人,说明这个人不住这儿,就会沿 作用域链 去往上找。

那么 问题来了,

作用域链是怎么形成的呢?

当一个新的作用域被创建出来的时候,新的作用域里都有一个 outer 指针,拴在父级作用域上。

子函数的作用域里,也有个 outer 指针拴着外部函数的作用域;

外部函数的作用域里,也有个 outer 指针拴着全局的作用域,这就形成了一根链条

肯定有朋友会有疑问了:

“什么作用域链?不就是子函数指向父函数吗?平时咱写代码,函数嵌套个两三层也就顶天了,这么短一点,也好意思叫‘链’?

这里有两点:

第一,这是由数据的组织形式决定的。 只要是通过指针一个连一个的数据结构,都叫 链表。这跟它长短没关系,只要是这种结构,5厘米是链表,25厘米也是链表,特指它这种“顺藤摸瓜”的连接方式。它不是数组,不能通过下标直接访问;也不是树或图。哪怕它只有两层,只要是靠指针指过去的,它就是链表结构。

第二,它是内存里实实在在的物理链条。 一定要分清解析和执行。现在我们是在解析阶段,这根链条在图纸上,是蓝图。等到后续代码真正执行的时候,在堆内存里,真的会创建出一串串的 Context 对象,它们之间真的是通过物理指针连接起来的。 所以,它不光是逻辑上的链,更是物理上的链。

想象一下查找过程: 当要查找一个变量时:

  1. 先看自己家:当前作用域有吗?木有。
  2. 顺着绳子找爸爸:父级作用域有吗?木有。
  3. 一层层往上:直到找到全局作用域。
    • 找到了:皆大欢喜。
    • 到顶了还没找到
      • 如果是赋值 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树互相缠绕。

这就是作用域树。

  1. AST (抽象语法树)
  • 语法结构的树。
  • 它描述了代码的 语法结构
  • BlockFunctionLiteralBinaryExpressionReturnStatement...
  • 给 Ignition 解释器看。解释器遍历这棵树,生成字节码。
    • 看到 BinaryExpression --生成 Add 指令。
    • 看到 Literal -- 生成 LdaSmi 指令。
  • 就好像是搭建房子的 框架结构。墙在哪、窗户在哪、承重柱在哪。
  1. Scope Tree (作用域树)
  • 逻辑关系的树。
  • 它描述了变量的 可见性生命周期
  • GlobalScopeModuleScopeFunctionScopeBlockScope
  • 给变量查看。
    • 决定变量是住栈、住堆、还是住全局。
    • 处理闭包的捕获关系。
  • 就类似于 描述房子中的各个部件的逻辑关系。
    • 主卧的开关能控制客厅的灯吗?(变量可见性)
    • 这根水管是通向厨房还是通向市政总管道?(作用域链查找)
  1. 双树的纠缠

这两棵树虽然是分开的数据结构,但它们是 伴生 的。

  • 伴生生长:

    当解析器解析到一个 function 时:

    1. AST 层面:生成一个 FunctionLiteral 节点(AST长出了一个枝丫)。
    2. Scope 层面NewFunctionScope 被调用,生成一个 FunctionScope 对象,并且 outer 指针指向父级(作用域树也长出了一个枝丫)。
    3. 挂载:V8 会把这个 FunctionScope 挂在 FunctionLiteral 的身上。
    4. 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)。

所以,说树,是说它的整体结构,说链,是说它的查找路径。

  1. 在第一大部分的第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

      关键时刻来了

      1. 生成小票:解析器生成了一个 VariableProxy("treasure")(寻找宝藏的小票)。
      2. 开始兑换
        • InnerScope:“你有 treasure 吗?” --- 没有
        • 顺着 outer 指针往上爬,问 OuterScope:“你有 treasure 吗?” ---有!

      找到了!但是,解析器并没有这就结束,它发现了一件事情:

      这个 treasure 是定义在 outer 里的,但是却被 inner 这个下级给引用了!而且 inner 可能会被返回到外面去执行!

      这就是 跨作用域引用

    • 强制搬家

      解析器意识到有些麻烦了。

      如果 treasure 依然留在 栈 上,那么等 outer 函数执行完毕,栈帧被销毁,treasure 就会灰飞烟灭。

      等将来 inner 在外面被调用时,它想找 treasure,结果只找到一片废墟,那程序就崩了。

      于是,解析器立马修改了 OuterScope 的蓝图,下达了 “强制搬家令”

      1. 撕毁标签:把 treasure 身上的 Stack Local 标签撕掉。

      2. 贴新标签:换成 Context Variable(上下文变量)

      3. 开辟专区:

        V8 决定,在 outer 函数执行时,不能只在栈上干活了。必须在 堆内存 (Heap) 里专门开辟一个对象,这就叫 Context (上下文对象)。

      4. 分配槽位:

        treasure 被分配到了这个 Context 对象里的某个槽位(比如 Slot 0)。

      此时的内存蓝图变成了这样:

      • 普通变量(如果有):依然住在栈上,用完即弃。
      • 闭包变量 (treasure):住在堆里的 Context 对象中,虽死犹生。
    • 建立连接

      既然变量搬家了,那 inner 函数怎么知道去哪找它呢?

      在生成 innerSharedFunctionInfo(这个就是在文章刚开始部分讲的,预解析时,会生成的占位符节点和一个SharedFunctionInfo相关联,SFI中有预解析得到的元信息)时,V8 会记录下这个重要的情报:

      注意:本函数是一个闭包。执行时,请务必随身携带父级作用域的 Context 指针

      这就好比 inner 函数随身带着一把钥匙。

      不管它流浪到代码的哪个角落,只要它想访问 treasure,它就会拿出钥匙,打开那个被精心保留下来的 Context 保险箱,取出里面的值。

    • 总结一下

      在解析层面,闭包不仅仅是“函数套函数”,它是一次 “变量存储位置的逃逸分析”

      1. 没有闭包时:父函数的变量都在上,函数退栈,变量销毁。
      2. 有闭包时:解析器发现有内部函数引用了父级变量,强行把该变量从挪到堆 (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();
        
        1. 扫描 useHeavy:发现它用了 heavyData。--- heavyData 必须进 Context。

        2. 扫描 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 的逃逸分析优化,强制保留所有上下文。

  2. 上面我们花了很大篇幅讲了普通函数的解析。这时候肯定有朋友问:“不是说‘一类四函两变量’吗?还有三种函数(异步、生成器、异步生成器)呢?”

    实际上,它们用的是同一套模具。

    在 V8 里,ParseHoistableDeclaration 负责接待这四位天王。经过 ParseFunctionDeclaration 的简单包装后,处理函数字面量的入口全都指向同一个苦力:ParseFunctionLiteral

    无论是 functionfunction*async function 还是 async function*,它们在 V8 眼里都是“穿了不同马甲”的普通函数。

    解析器只需要在进门时做一次“安检”,根据 *async 关键字打上不同的标签(Flag),接下来的解析流程——查参数、开作用域、切分代码块——完全复用

    不过,针对这三位“特权阶级”,解析器确实会偷偷做三件不同的小操作:

    1. 关键字变化: 在普通函数里,yieldawait 只是普通的变量名。但在特殊函数里,解析器会把它们识别为 操作符,生成专门的 AST 节点。
    2. 夹带 .generator: 对于生成器和异步函数,解析器会偷偷在作用域里塞一个隐形的 .generator 变量。 这是为了将来函数“暂停”时,能把当前的寄存器、变量值等 “案发现场” 保存在这个变量里。 所以,这几种函数 天然就是闭包,因为它们必须引用这个隐形的上下文。
    3. 休息点 Suspend: 解析器会在 AST 里埋下 Suspend (挂起) 节点。 这相当于告诉未来的解释器:“读到这儿别硬冲了,得停下来歇会儿,把控制权交出去。”

    虽然具体解析时有不少差异,但是,有了前面我们解析普通函数的基础,再来解析这三种“魔改版”的函数,难度并不大。 我们就不具体展开了,毕竟,函数再美,看多了也会审美疲劳啊。

    所以,我们现在学习声明中的 变量声明。

    虽然前面一直在说 两变量,那是从规范上说的 var属于语句, 在 V8 中,let const var 这三个变量声明 ,是使用同一个解析函数处理的。

    有一个核心函数叫 ParseVariableDeclarations

    不管解析器读到的是 var,还是 let,还是 const,在经过项级分流后,最终都会殊途同归,调用这个函数ParseVariableDeclarations。

    下面,我们就开始变量的声明之旅吧。

    • 项级分流

      地点:ParseStatementListItem

      场景:解析器正在一个大括号 { ... } 或者函数体里,逐行扫描代码。

      1. **偷看 **:看看下一个 Token 是什么呢?

      2. 判断

        • 如果看到 var
        • 如果看到 let
        • 如果看到 const
      3. 统一甩锅:

        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?--- 报错 变量名想叫关键字 一边去吧。
        • 如果是严格模式,变量名叫 argumentseval?--- 报错,想在边缘试探 也一边去吧。
    • 分头工作

      地点:DeclareVariableName (在解析出名字后立刻调用)

      场景:名字有了,现在要去Scope Tree(作用域树) 上登记户口了。这时候,必须根据

      参数 来区分待遇。

      这里是逻辑最复杂的地方,也是 varlet 行为差异的根源

      分支 A:手里拿的是 kVar 参数

      1. 向上穿墙:解析器无视当前的块级作用域 BlockScope,沿着 scope--outer_scope() 指针一直往上爬。
      2. 寻找宿主:直到撞到了一个 FunctionScope 或者 GlobalScope,函数作用域或全局作用域 是var的目标。
      3. 登记:在那个高层作用域里,记录下名字 a
      4. 模式:标记为 VariableMode::kVar,嗯嗯嗯 这里是内部的东东了。
      5. 初始化:标记为 kCreatedInitialized(创建即初始化)。意思是:“var这家伙不用死区,直接给个 undefined 就能用。”

      分支 B:手里拿的是 kLetkConst 参数

      1. 原地不动:解析器直接锁定当前的 Scope(哪怕它只是一个 if 块)。
      2. **查重 **:翻开当前作用域的小本本,看看有没有重名的?
        • 有?-- 报错 SyntaxError: Identifier has already been declared
      3. 登记:在当前作用域记录名字 a
      4. 模式:标记为 VariableMode::kLetVariableMode::kConst
      5. 初始化:标记为 kNeedsInitialization(需要初始化)。
        • 这就是 TDZ 的源头了! 这个标记意味着:在正式赋值之前,谁敢访问这个位置,就抛错。
      6. 注意点: 从这里能看出 let和const也会提升,只不过let和const的提升是小提升,只在自己的当前作用域里提升,提升归提升,没被真正赋值前,TDZ啊,被送会吹哨子的警卫看守着。
    • 处理初始值

      地点:回到通用车间

      场景:名字登记完了,现在看有没有赋值号 =。

      步骤 1:const 的检查

      • 解析器偷看下一个 Token。
      • 如果是 kConst 且后面没有 = 号?
        • 直接崩了 抛出 SyntaxError: Missing initializer in const declaration
        • varlet 会偷笑,因为它们允许没有 =

      步骤 2:解析赋值

      • 如果看到了 =,吃掉它。
      • 递归甩锅:调用 ParseAssignmentExpression 解析 = 右边的表达式(比如 1 + 2)。。。这里这里这里 前面超大篇幅讲过的表达式解析,看到亲切吗?

      步骤 3:生成 AST 节点

      这里是 AST 物理结构的生成。

      • 对于 var:

        由于 var 的名字已经提升走了,这里剩下的其实是一个 赋值操作。

        V8 会生成一个 Assignment 节点(或者类似的初始化节点),挂在当前的语句列表中。

        • 意思是:“名字归上面管,但我得在这里把值赋进去。”
        • 这里也需要注意,var的名字被提升走了,但是赋值操作还留在这里呢,在赋值之前,var都是undefined。
      • 对于 let / const:

        V8 会生成一个完整的 VariableDeclaration 节点,包含名字和初始值。

        而且,如果这是 const,V8 会给这个变量打上 “只读” 的标签。如果以后 AST 里有别的节点想修改它,编译阶段或运行阶段就会拦截报错。

        这个只读,是指绑定的引用不可变,如果引用的是个对象,对象内部的内容还是可以改的。

    • 收尾喽

      地点:循环末尾

      1. 逗号检查:偷看后面是不是逗号 ,
        • 是 -- 吃掉逗号,回到 通用车间的步骤 3,继续解析下一个变量。
        • 否 --- 结束循环。
      2. 分号处理:期待一个分号 ;。如果没有,自动分号插入。
      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 层面,通常会把它拆解成两部分:

      1. 声明 (Declaration)var a。这部分在 AST 上被标记为“可提升”。
      2. 赋值 (Assignment)a = 1

      解析器会在当前位置if 块的语句列表中,生成一个 Assignment (赋值) 节点,而不是一个单纯的声明节点。

    • Scope 树:名字被“穿墙”提到了顶层。

    • AST 树:原地留下了一个赋值节点 a = 1

    • 这就是为什么 var 有提升(名字上去了),但赋值没提升(赋值节点还在原地)。

    {let b = 2;
    }
    
    • 动作:消费 let,读到标识符 b

    • Scope 操作:

      解析器直接锁定当前的 BlockScope。

      它不往上找,而是立刻查阅当前的花名册:

      “这里面有叫 b 的吗?”

      • 如果有:重复定义,报错 抛出 SyntaxError: Identifier 'b' has already been declared
      • 如果没有:登记
    • Scope 操作(关键):

      在登记 b 的时候,V8 会给它打上一个特殊的 Mode:kLet。

      并且在初始化标记位上,打上 kNeedsInitialization(需要初始化)。

      在前面的三个变量一起讲的例子里讲过了,这就是 TDZ 的物理来源。这个标记表示:“在给 b 赋值之前,任何访问都要抛错。”

    • 解析器读到 = 2

    • AST 生成:

      这次不像var那样需要拆分了。

      解析器直接在当前位置,生成一个 VariableDeclaration 节点。

      这个节点包含:

      • Proxy:变量 b 的引用。
      • Initializer:字面量 2
      • Mode:LET。

      该节点被直接 Push 到当前 Block 的语句列表中。

    • Scope 树:名字登记在当前块,不可重复,标记为死区状态。

    • AST 树:原地生成一个完整的 VariableDeclaration 节点。

    还剩下const了const 的流程和 let 几乎一模一样,只有两个额外的检查环节。

    第一必须带初始值

    • 在解析完变量名之后,解析器会立刻偷看下一个 Token。
    • 如果不是 =
    • 没有初始化,报错 抛出 SyntaxError: Missing initializer in const declaration
    • const 变量出生必须带值,这是语法层面的规定。

    第二, 只读属性

    • Scope 操作:

      在登记const的变量时,它的 Mode 被标记为 kConst。

      这表示在 Scope 的记录里,这个变量是 Immutable 不可变 的。

      如果 AST 的其他地方试图生成一个 Assignment 节点去修改const声明的变量,虽然解析阶段可能不会立刻报错(有时要等到运行时),但是后续一定会在写入只读变量的操作时,被拦截并抛错。

  3. 上面讲了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关键字的时候,还没看到内容,就必须先做三件事。

      1. 强制开启严格模式
        • 解析器将当前的 language_mode 标志位强行设置为 kStrict
        • 一旦跨过 Hero { 这道门槛,所有严格模式的规则立即生效(比如禁用 with,禁用arguments 和参数不再绑定等)。
      2. 创建类作用域
        • V8 调用 NewClassScope,创建一个新的作用域对象。
        • 户籍登记:解析器读到标识符 Hero。它立刻在这个新的作用域里,声明一个名字叫 Hero 的变量。
        • 锁起来:这个变量被标记为 CONST(常量)。这表示在类体内部,Hero = 1 这种代码会在解析阶段直接报错。
        • 目的:这是为了让类内部的方法能引用到类本身(自引用)。
      3. 初始化列表
        • 解析器在内存里准备了三个空的列表(List),用来分类存放即将切割下来的不同部位,像超市里鸡腿 鸡翅 鸡杂 分开摆盘:
          • instance_fields (实例字段列表):存放 name = ... 这种。
          • static_fields (静态字段列表):存放 static version = ... 这种。
          • properties (方法属性列表):存放 say(), constructor 这种。
    • 开始解析

      现在,解析器进入大括号 { ... },开始扫描。

      name = '阿祖'; —— 实例字段的解析

      1. 识别 Key:解析器读到 name
      2. **偷看 **:往后偷看一眼,发现是 =
      3. 判定:这不是方法,这是一个 Field (字段)。且没有 static,所以是 Instance Field (实例字段)
      4. 解析
        • 解析器把 = 后面的 '阿祖' 作为一个 表达式 进行解析。
        • 生成一个 Literal 字符串节点。
      5. 包装
        • 关键:消费完了以后,V8 不会把 '阿祖' 直接扔掉。它会创建一个 "合成函数" (Synthetic Function) 的外壳。
        • 为什么要包一层? 这是 V8 为了隔离作用域而采用的策略。字段初始化表达式里可能会有 this,或者复杂的逻辑。通过封装成一个独立的函数壳,V8 确保了它和构造函数的参数(比如 skill)互不干扰,这也符合 JS 规范:字段定义本来就看不见构造函数的参数。
        • 划重点name 的值怎么算,被封装成了一个可以在未来执行的函数。
        • 这里需要注意,= 号后面的值,并不是一次性使用,有可能被使用很多次,虽然我们例子中是 阿祖,但是 也可能是其他包含逻辑的计算值,所以,我们需要的不是值,而是如何生成这个值的 整个逻辑, 因此 解析出来以后,给它包上一层带独立作用域的函数壳。
      6. 归档:把这个合成函数扔进 instance_fields 列表。

      static version = '1.0'; —— 静态字段的解析

      1. 识别:读到 static 关键字。

      2. 标记:开启 is_static 标志位。

      3. 识别 Key:读到 version

      4. 偷看:看到 =

      5. 判定:这是一个 Static Field (静态字段)

      6. 解析与归档

        • 解析 '1.0' 生成字符串节点。
        • 同样包装成一个“合成函数”。
        • 扔进 static_fields 列表。
        • 注意:这个列表将来是要挂在 Hero 构造函数对象本身上的,不是挂在 this 上的。

      constructor(skill) { ... } —— 核心内容的解析

      1. 识别:读到 constructor 关键字。
      2. 判定:这是类的 核心构造函数
      3. 解析函数体
        • 解析参数 skill
        • 解析代码块 this.skill = skill
        • 生成一个 FunctionLiteral 节点。
      4. 归档:虽然它是核心内容,但在 AST 组装前,它暂时被存在一个叫 constructor_property 的特殊槽位里,等待后续的组装。

      say() { ... } —— 原型方法的解析

      1. 识别:读到 say,后面紧跟 (
      2. 判定:这是一个 Method (方法)
      3. 属性描述符生成 (Property Descriptor)
        • 这是类和对象最大的不同点。V8 会盘算着
        • writable: true
        • configurable: true
        • enumerable: false (类的方法默认不可枚举)
      4. HomeObject 绑定
        • 解析器会给 say 函数标记一个 HomeObject。这是为了如果你在 say 里用了 super,它知道去哪里找父类。
      5. 归档:把生成的 say 函数节点,扔进 properties 列表。
    • 进行脱糖

      扫描完 },所有的配件都摆好了。马上开始的,这就是传说中的 脱糖 过程。

      类是语法糖,现在,我们要脱糖。

      • 改造构造函数

        1. 拿出刚才解析好的 constructor 函数节点。
        2. 定位
          • V8 寻找函数体的 起始位置
          • 如果有继承 (extends),位置在 super() 调用之后(因为 super 返回前 this 还没出生)。
          • 没有继承,位置就在函数体的 最前面
        3. 添加
          • V8 把 instance_fields 列表里的内容拿出来(那个 name = '阿祖' 的合成函数)。
          • 它将其转化为赋值语句 AST:this.name = '阿祖'
          • 它把这条语句 插入constructor 原本的用户代码 this.skill = skill 之前。

        此时,在 V8 的内存 AST 中,构造函数实际上变成了这样:

        // V8 内存中的构造函数(伪代码)
        function Hero(skill) {// --- V8 添加的字段初始化逻辑 ---// 注意:这里是一个隐式的 Block// 是因为这里是由合成函数转化的,包含了逻辑  也包含了独立的作用域this.name = '阿祖'; // -----------------------------// --- 用户写的逻辑 ---this.skill = skill;
        }
        
      • 组装 ClassLiteral

        现在构造函数改造完毕,V8 开始组装最终的 ClassLiteral 节点。

        1. 挂载构造函数:把改造后的 Hero 函数放c位。
        2. 挂载原型方法
          • 遍历 properties 列表。
          • 拿出 say
          • 生成指令:在运行时,将 say 挂载到 Hero.prototype 上,并设置 enumerable: false
        3. 挂载静态字段
          • 遍历 static_fields 列表。
          • 拿出 version = '1.0'
          • 生成指令:在类创建完成后,立刻执行 Hero.version = '1.0'
        4. 关联作用域:把最开始创建的 ClassScope 关联到这个节点上。
    • 完成喽

      尽管我们写的是一个class,但是,实际的解析过程如下

      1. 开启严格模式。
      2. 创建一个叫 Hero 的常量环境。
      3. 定义一个叫 Hero 的函数。
        • 函数体内:先执行 this.name = '阿祖'
        • 函数体内:再执行 this.skill = skill
      4. 定义一个叫 say 的函数。
        • 把它挂到 Hero.prototype 上,设为不可枚举。
      5. 定义一个叫 version 的值。
        • 把它直接挂到 Hero 函数对象上。
      6. 返回这个 Hero 函数。

      你会发现,解析器最终生成的是一个表示类的 ClassLiteral,但也是仅是名字而已,其他的所有内容,已经脱糖为函数、赋值、原型挂载 这些js语法。

      所以,从 V8 的实现上来说,类解析的本质,就是解析器通过引入 合成函数代码植入 等手段,把现代化的语法糖,翻译成了底层引擎能理解的函数、作用域和原型操作。

  4. 我们前面首先学习的就是语句里的兜底表达式的解析,然后是声明中的 函数 变量 类, 现在就还剩语句中的可以用关键字甩锅的部分了。

    我们回到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 (块)

    解析流程:

    1. 消费:吃掉 {
    2. 递归:此时,仿佛又回到了世界起源。解析器会再次调用那个最最最核心的循环驱动者 —— ParseStatementList
      • 这就是为什么代码可以无限嵌套:块里套块,套娃套娃娃。
    3. 消费:吃掉 }

    注意:透明作用域 (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

    解析流程:

    1. 消费:吃掉 if,吃掉 (
    2. 条件:调用 ParseExpression 解析条件(比如 a > 1),拿到 Condition 节点。
    3. 消费:吃掉 )
    4. Then 分支:调用 ParseStatement 解析 then 的部分。
    5. Else 分支
      • 偷看:后面有 else 吗?
      • 有:吃掉 else,调用 ParseStatement 解析 else 的部分。
      • 没有:那 else 部分就是空的。

    遇到语法歧义问题匹配哪个呢?

    if (a)if (b) x++;
    else y++;
    

    这个 else 到底属于哪个 if?是属于 if(a) 还是 if(b)

    V8使用 “贪婪匹配” 原则:

    else 总是匹配最近的、还没配对的那个 if。

    所以在 AST 里,这个 else 是挂在内层 if (b) 后面的。如果你想让它属于外层,必须显式地加 {},所以 ,从写法上减少这些歧义是最好的。

    循环解析:for

    while 和 do-while 比较简单,我们重点讲最复杂的 for 循环。

    当解析器看到 for,甩锅给 ParseForStatement。

    AST 的结构:

    V8 会生成一个 ForStatement 节点,它有 4 个插槽:

    1. Init (初始化):比如 let i = 0
    2. Cond (条件):比如 i < 10
    3. Next (步进):比如 i++
    4. Body (循环体):比如 { console.log(i) }

    嗯嗯嗯,这里又有个面试官容易被吊打的地方了

    就是 for 循环作用域问题,V8 在这里做了比较复杂的处理。

    如果这里用的是 var,V8 根本不管,直接扔给外层函数作用域。

    但如果是 let,V8 必须制造出 “多重作用域” 的效果。

    在解析 for(let ...) 时,V8 会在 AST 和 Scope 树上构建出 两层 甚至 N+1 层 作用域:

    1. 循环头作用域 (Loop Header Scope)

    2. 循环体作用域 (Loop Body Scope)

    3. 迭代作用域 (Per-Iteration Scope)

    。。。。。。看起来似乎挺复杂,实际上也不是很简单,所以我们需要仔细耐心的学习。

    for (var i = 0; i < 3; i++) {setTimeout(() => console.log(i), 0);
    }
    

    分析这个例子

    第一阶段:主线程 循环阶段

    因为 var 声明的 i 没有块级作用域,它是一个全局变量(或函数作用域变量)。在这个内存里,只有一个 i

    1. 初始化i = 0
      • 检查 0 < 3?是的。
      • 遇到 setTimeout:浏览器把“打印 i”这个任务记在宏任务队列的小本本上。注意:此时不执行打印,也不存 i 的值,只是记下“回头要找 i 打印”这件事。
    2. 步进i 变成 1
      • 检查 1 < 3?是的。
      • 遇到 setTimeout:再记一笔“回头找 i 打印”。
    3. 步进i 变成 2
      • 检查 2 < 3?是的。
      • 遇到 setTimeout:再记一笔“回头找 i 打印”。
    4. 步进(关键步骤)i 变成 3
      • 检查 3 < 3不成立!
      • 循环结束

    重点来了: 此时循环结束了,变量 i 停留在什么值? 答案是 3。 因为它必须变成 3,条件判断 i < 3 才会失败,循环才会停止。

    第二阶段:异步队列回调 打印阶段

    现在主线程空闲了,Event Loop 开始处理刚才记在小本本上的 setTimeout 任务。

    1. 第 1 个回调运行console.log(i)
      • 它去内存里找 i
      • 这时候的 i 是多少?是 3
      • 打印:3
    2. 第 2 个回调运行console.log(i)
      • 它还是去同一个内存地址找 i
      • i 还是 3
      • 打印: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)

    1. 公共时钟 i:指向 0。
    2. 进入房间:V8 遇到 {,创建一个全新的 Block Scope A
    3. 执行 let x = i
      • V8 在 Scope A 里创建变量 x
      • 读取外面的 i (0)。
      • 赋值x = 0
    4. 闭包生成
      • setTimeout 里的箭头函数生成。
      • 关键点:它捕获的是谁?是 Scope A 里的 x
      • 此时,这个闭包手里紧紧攥着 x=0 的照片。

    第二轮循环 (i = 1)

    1. 公共时钟 i:变成了 1(注意:i 还是那个 i,只是值变了)。
    2. 进入房间:V8 遇到 {,创建一个全新的 Block Scope B(和 A 没关系)。
    3. 执行 let x = i
      • V8 在 Scope B 里创建变量 x
      • 读取外面的 i (1)。
      • 赋值x = 1
    4. 闭包生成
      • 生成第二个箭头函数。
      • 它捕获的是 Scope B 里的 x
      • 这个闭包手里攥着 x=1 的照片。

    第三轮循环 (i = 2)

    1. 公共时钟 i:变成了 2。
    2. 进入房间:创建 Block Scope C
    3. 执行 let x = i
      • x = 2
    4. 闭包生成
      • 捕获 Scope C 里的 x
      • 手里攥着 x=2 的照片。

    循环结束了。

    • 公共变量 i:变成了 3。如果这时候有人打印 i,那就是 3。
    • 刚才那三个闭包(定时器回调),根本不关心 i 是多少。

    当 0ms 之后,定时器触发:

    1. 回调 1:拿出 Scope A 里的 x - 打印 0
    2. 回调 2:拿出 Scope B 里的 x - 打印 1
    3. 回调 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。

    于是,它悄悄开启自己的魔法-迭代的作用域

    所以

    1. 物理上i 确实只有一个,在 Header Scope 里,不断 ++ 变成 0, 1, 2, 3。
    2. 逻辑上:每次进入大括号,V8 都会偷偷创建一个 影子作用域
    3. 复印:在这个影子作用域里,V8 会把此刻的 i 的值,赋值给一个新的隐藏变量 伪代码里我们叫它 _k
    4. 捕获:循环体里的闭包,实际上捕获的不是那个一直在变的 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 < 3i++
    • 关联:这里的 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

    returnbreakcontinue 的解析逻辑都很直白:“吃掉关键字 --检查分号”。

    ParseReturnStatement 有一个巨大的坑,叫做 ASI (自动分号插入)

    看这段

    return
    true;
    

    解析器读到 return 后,它的动作是这样的:

    1. 偷看:偷看下一个 Token。
    2. 发现:哎哟,是一个 换行符 (LineTerminator)
    3. 判定:根据 JS 语法规则,return 后面不能跟换行符。既然你换行了,我就当你写完了。
    4. 插入:V8 强行在这里插入一个分号 ;
    5. 结果:代码变成了 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/

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询