V8引擎 精品漫游指南--Ignition篇(下 一) 动态执行前的事情
前文总结 和 运行期前置知识这个系列文章已经写了一少半了现在终于到了动态执行阶段了。我们首先需要梳理一下知识这部分内容相对独立但是都算是比较重要的知识点。预编译的说法为什么不建议使用在我们平时看文章看资料甚至是看一些比较权威的文档时预编译 这个术语非常常见。但是在js中预编译 是个伪术语是一些教材教程在以前的js教学中为了解释变量提升等一些问题生造出来的一个词语后来只要是运行期以前的 甚至是在和运行期交织发生的一些动作流程统统装进了 预编译 这个大口袋里。大部分人也就不求甚解的接受并使用了这个说法。但是这是一个不规范且容易引发歧义的词汇。在传统编译语言中预处理、编译与执行通常有明确的时间边界在现代 JavaScript 环境这些阶段高度交织。规范ECMAScript (ECMA-262)并不使用“预编译”一词而是通过“执行上下文的创建阶段creation / declaration instantiation”来描述声明的注册与初始化。实际引擎例如 V8则采用惰性解析与按需编译先做必要的解析与作用域分析再由解释器生成字节码如 Ignition或在运行时将热点编译为机器码由优化器完成。对于js可以分为如下四个宏观的阶段词法分析把源代码分成记号tokens。语法分析Parsing构建抽象语法树AST确定静态作用域结构。执行上下文创建阶段Creation / Declaration Instantiation为全局或每次函数调用登记标识符函数声明整体被绑定var注册并初始化为undefinedlet/const注册但处于 TDZ。这一步决定了变量可见性和提升行为但不等于把所有代码预先编译成机器码。执行阶段逐条执行语句遇到函数调用重复执行上一 步。现代引擎会在此阶段对运行行为收集反馈并按需触发优化编译。全局创建阶段和函数创建阶段的区别无论是全局还是函数在代码真正执行前都会经历“创建阶段”进行变量和函数声明的提升但两者有本质区别作用域范围全局阶段影响整个程序声明的变量和函数最终挂载到全局环境浏览器中为window。函数阶段每调用一次函数生成一个完全独立的执行上下文仅对函数体内部有效互不干扰。变量遮蔽(shadowing)在函数内部如果存在与全局同名的变量函数内的局部变量会“遮蔽”全局变量。即使全局变量在早期的全局阶段已经存在函数内部在自己的创建阶段会优先登记局部标识符。四个宏观的阶段JavaScript 代码的完整生命周期分为以下四个阶段1. 词法分析Lexical Analysis目的将源代码字符串分解成一系列记号Tokens。内容识别关键字、标识符、操作符、数字、字符串、注释等最小语法单元。2. 语法分析Syntax Analysis / Parsing目的将记号序列转换成抽象语法树AST。内容检查代码结构是否符合语法规则构建反映代码静态结构的蓝图。3. 执行上下文创建阶段Creation/Instantiation Phase全局上下文创建创建全局对象Global Object。扫描全局代码将函数声明整体提升将var变量注册并初始化为undefined将let/const注册但置于“暂时性死区TDZ”。建立全局词法环境其外部引用为null。计算this绑定。函数上下文创建每次调用时触发确定外部环境引用Outer Environment Reference构建作用域链。创建局部词法环境绑定形参与实参创建arguments对象。扫描函数体处理内部的变量和函数声明规则同上。根据调用规则普通调用、方法调用、new调用等计算并保存当前函数的this值。4. 执行阶段逐条执行语句完成真实的赋值操作和表达式求值。遇到函数调用时重复步骤 3。主线程同步代码结束后进入事件循环处理异步任务。无闭包引用的上下文将被垃圾回收。静态结构AST和动态运行执行阶段的关系这是理解 JS 闭包和作用域链最核心的关键。1. 逻辑结构AST 阶段静态分析在语法分析结束后AST 已经固化了代码的静态结构Lexical Scope。作用域的层级、变量的引用关系在这个阶段已经完全确定。注意AST 仅确定作用域链的结构蓝图它不包含任何运行时值或内存绑定。这也是我们在第一部分解析篇和AST部分中反复说过无数遍的。2. 物理实现运行时阶段动态绑定具体的词法环境实例Lexical Environment是在代码执行阶段动态创建的。函数对象的创建函数声明FunctionDeclaration通常在执行上下文的创建阶段就被绑定为可调用的函数对象而函数表达式FunctionExpression则是在运行时执行到表达式处时才生成函数对象。闭包的落地虽然闭包的静态依赖关系可以从 AST 中推导出来但真正的闭包在堆内存中实际捕获并保存外部函数的词法环境是在函数被执行并返回后由运行时的执行上下文和作用域链动态构建的。AST 阶段就像是建筑设计图明确了房间的布局作用域和走廊的连接关系静态作用域链。而运行时相当于实际建造根据设计图动态分配水泥建材内存并让住户变量值真正住进去。闭包形成的动态实例JavaScriptfunction outer() { var a 10; function inner() { console.log(a); // 引用了 outer 的变量 a } return inner; } var closureFunc outer(); closureFunc();语法分析阶段AST 记录了标识符a的引用关系随后的作用域分析Scope Analysis会基于 AST 建立变量解析的静态链接。执行outer()时创建新的执行上下文和词法环境包含a。inner函数被创建时捕获当前词法环境并存入其[[Environment]]。执行closureFunc()时inner执行虽然outer的上下文已销毁但inner通过自身的[[Environment]]依然保留着对outer词法环境的物理引用真正的闭包在此刻发挥作用。词法环境和作用域链这两个概念非常容易混淆词法环境单个节点是一个存储变量和函数声明的具体环境。全局脚本开始、函数调用、进入块级作用域{}时都会实例化对应的词法环境。作用域链链式结构是由多个词法环境通过Outer Reference外部引用串联而成的查找路径。如果把作用域链比作一面“墙”那么每一个词法环境就是砌成这面墙的“砖块”。词法环境负责“存储变量”作用域链负责提供“查找路径”。这里需要特别注意前面 尤其是解析篇中 我们反复强调了 蓝图 这个说法在ast生成以后作用域已经形成这里要注意是结构的形成我们可以知道某个变量可以到哪里寻找但是这只是蓝图 并不是实例的形成。 真正的可操作的作用域/链是在执行阶段动态创建的。执行上下文的模型一、 执行上下文的抽象模型在 ECMAScript 规范中一个执行上下文Execution Context记录可以抽象为如下结构JavaScriptExecution Context Record { LexicalEnvironment: { EnvironmentRecord: { ... }, // 当前词法环境中的绑定 (let/const/function/class) Outer: reference to outer env // 外部环境引用 }, VariableEnvironment: { EnvironmentRecord: { ... }, // 专门存储 var 声明的绑定 Outer: reference to outer env }, ThisBinding: the value of this, // 当前上下文的 this 值 PrivateEnvironment: optional record // 用于类的私有字段#private }环境记录的类型与功能DeclarativeEnvironmentRecord声明性环境记录用于存放命名绑定let、const、function等并跟踪每个绑定的内部状态如是否已初始化、是否可变。let/const的 TDZ暂时性死区正是通过在绑定创建后、初始化前将该绑定底层标记为“未初始化uninitialized”来实现的。ObjectEnvironmentRecord对象环境记录将一个普通对象包装成环境记录。典型场景是全局环境将globalThis作为绑定载体或被废弃的with语句。它的查找是通过直接的对象属性访问来实现的。FunctionEnvironmentRecord函数环境记录声明性环境记录的特化版专职负责管理函数的参数、arguments对象以及处理this、super的绑定状态。二、 词法环境和变量环境的区分在函数初始执行时LexicalEnvironment 和 VariableEnvironment 通常指向同一个环境记录实例。但规范特意将它们物理分离是为了在“绑定创建阶段”区分不同声明的处理策略历史和兼容在 ES5 及之前声明以函数作用域为准var。ES6 引入了块级作用域let/const。规范通过VariableEnvironment负责varLexicalEnvironment负责块级声明完美实现了旧行为与新特性的并存。var 的处理变量环境var声明会在 VariableEnvironment 上被创建并立刻初始化为undefined。这就是为什么在声明前读取var变量会得到undefined即“变量提升”。let/const 的处理词法环境它们在 LexicalEnvironment 上被创建但并不初始化。在实际执行到声明语句之前访问这些绑定会触发 TDZ抛出ReferenceError。三、 上下文完整实例我们通过一段经典代码观察环境及闭包的情况JavaScriptconsole.log(foo); var foo 10; function outer() { let a 1; function inner() { console.log(a); } return inner; } const closureFunc outer(); closureFunc();1. 全局创建阶段foo注册到变量环境初始为undefined。outer函数对象创建其内部槽[[Environment]]闭包的环境指针指向当前的全局词法环境。注意ES6 后的全局环境是复合的包含一个“全局声明性环境”存 let/const和一个“全局对象环境”存 var 和全局函数映射到 globalThis。在 ES Modules 模式下顶层绑定则由专属的 Module Environment Record 接管不再使用 globalThis。2. 执行全局代码console.log(foo)输出undefined因为foo的var绑定已在创建阶段完成初始化。随后foo赋值为 10。3. 调用 outer() 并进入其创建阶段注册局部变量a处于 TDZ。创建inner函数对象将其[[Environment]]指向outer的词法环境。随后执行赋值a 1解除 TDZ并返回inner函数。注意此时如果在a 1之前尝试读取a会立刻触发 TDZ 报错。4. 调用 closureFunc()即 inner创建inner的执行上下文。在其自身的词法环境中找不到a顺着[[Environment]]构成的作用域链向外查找到outer环境中的a输出1。闭包的真实情况inner的[[Environment]]保存的是对outer词法环境的引用而不是当时绑定值的快照闭包捕获的是“绑定本身”。因此如果outer后续修改了a的值inner再次执行时读取到的必然是最新的修改值。这也解释了为什么在for循环中使用var创建闭包所有闭包会共享同一个循环变量绑定最终输出相同的值而使用let则会为每次迭代创建独立的绑定环境。补充内容This 绑定ThisBindingthis的值并非由执行上下文自动决定为某个固定值而是严格由调用方式在运行时动态决定直接调用 (fn())非严格模式指向全局对象严格模式为undefined。方法调用 (obj.method())指向调用者对象基值obj。显式绑定 (call / apply / bind)由传入的第一个参数决定。构造调用 (new Fn())指向内部新创建的实例对象。箭头函数没有自己的this它会穿透当前上下文从创建时的外层词法环境中继承thisLexical This。因此箭头函数无法被new也不能被bind改变指向。私有环境PrivateEnvironment这是规范专为支持类私有成员如#x引入的机制。在类定义阶段私有标识符会被登记到私有环境中。访问时引擎只在当前类的私有环境中查找对应绑定。对外表现为无法通过obj[#x]访问也不会出现在Object.keys的枚举中。优化与性能现代 JavaScript 引擎对闭包和作用域链有极强的优化例如 V8 的逃逸分析闭包本身并不总是天然低效。但需要注意如果无意中让闭包捕获了大型外部数据结构或庞大的 DOM 节点会导致这些环境记录的生命周期被强行延长阻碍垃圾回收从而造成内存泄漏。因为闭包会让被捕获的外部绑定“活得更久”所以在高性能场景需谨慎管理引用。重要总结一前面我们讲了js中预编译是个伪术语尽量不要使用。 那么除了使用规范中的术语我们在工程实现中可以使用编译期这个术语。一段源码要想跑起来只要经历了“词法分析 - 语法分析 - 生成 AST - 生成某种中间代码如字节码”的过程这个过程在计算机科学中就被标准的定义为“编译Compilation”。 既然 V8 引擎确确实实做了这些事情那把它称为“编译期”是名正言顺的。但是需要注意一是传统语言的“编译期”和“运行期”可能相隔很长的时间开发者在电脑上编译好发给用户运行。而 JS 的“编译期”和“运行期”是首尾相连、紧密贴合的。引擎通常在接收到代码后立刻进行编译随后立刻交由解释器执行。二是在现代 V8 引擎中纯粹的“编译期”通常指 Ignition 将 AST 转换为字节码的过程。但在“运行期”中TurboFan 编译器依然会在后台将热点字节码再次编译成机器码。所以 JS 的“编译”行为基本上是贯穿了运行的始终。重要总结二在前面我们讲了上下文 讲了词法环境 环境记录 等等概念很多朋友肯定会有疑问这些所谓的上下文、环境记录到底是完全虚构出来的抽象概念还是在物理内存中真实存在的结构关于这个问题或者说 关于类似的问题我们需要从两个方面来看一是规范 二是实现而这种思考方式是我们从开篇就一直贯彻使用的。规范前面列出的包含了LexicalEnvironment、Outer引用的对象结构还有环境记录还有之前的let的for循环等等等等实际上是 ECMAScript 规范定义的一种抽象机制Abstract Mechanism。 规范委员会TC39只负责制定语义上的“规则条文”他们规定了代码跑起来后变量查找必须遵循什么顺序、闭包必须保留什么数据但规范绝不干涉引擎在内存中必须使用何种底层数据结构来实现这些规则。实现V8 引擎作为极致追求性能的“实现者”通常不会在内存里一对一地去“照搬”或者new出规范中描述的那种深层次嵌套的庞大对象。相反它会使用栈帧Stack Frame、寄存器Register、堆上对象Heap Object等极其底层的机制来“实现/模拟/达到语义要求”并提供相同的行为表现。下面我们从规范层和实现层来学习一下这几个概念1. 执行上下文 (Execution Context) 和 全局执行上下文【规范层抽象级别 - 最高】规范定义一个用来跟踪代码执行进度的“抽象记录Abstract Record”或“容器”。规范赋予了它词法环境、变量环境、This绑定等语义属性这是纯粹的“规则文本”。【V8层物理表现形式与载体】函数上下文的物理表现函数调用栈帧Frame-like 结构。真实存在方式当函数被调用时V8 会在底层的调用栈Call Stack上开辟一块连续的内存空间栈帧。在 V8 内部这对应着随着版本不断演进的 C 栈帧实现如曾经的StandardFrame、JavaScriptFrame等。这块内存里压入了返回地址、参数、接收者this、以及分配给局部变量的寄存器槽位。函数一return栈帧出栈其物理状态瞬间回收。进阶关于全局执行上下文全局上下文的生命周期是跟随进程/页面的。它的物理实现并不是一个“永远压在栈底不弹出的常驻栈帧”。相反全局相关的数据全局对象 Global Object 与全局词法环境通常常驻于堆内存Heap中。浏览器标签页存活时这些堆结构就一直存在依靠堆内存来维持全局语义。2. 词法环境 (Lexical Environment) 和 变量环境 (Variable Environment)【规范层抽象级别 - 高】规范定义一种用来定义标识符和变量值映射关系的嵌套结构包含环境记录与外部引用。规范特意区分词法环境和变量环境是为了在语义上兼容 ES6 块级作用域let/const与老旧的函数级作用域var。【V8层物理表现形式与载体】物理表现引擎根本不会去创建一个名叫Environment的统一 C 对象。相反V8 会对绑定进行极其精明的按需分流非逃逸局部绑定被直接编译为栈帧上的寄存器/栈槽访问极快。逃逸闭包捕获绑定当绑定必须在当前栈帧销毁后继续存活时才会被搬到堆内存的Context结构中。进阶var 与 let/const 的精细差异在底层物理分配时虽然它们在函数内部都受“是否逃逸”规则的支配但语义表现截然不同全局的var往往直接映射为全局对象的属性Property Cell而全局的let/const则属于声明式记录且var没有 TDZ 标记。引擎通过不同的底层操作指令来严格区分这两种语义。3. 环境记录 (Environment Record)这是反差最大的一个概念。在规范里它像个哈希表但在 V8 底层它被分化成了三种截然不同的物理形态形态A完全虚无化针对 Declarative ER 中的非逃逸变量物理载体无独立运行时查找载体。化身为编译器分配的寄存器/栈槽。解释在编译/生成字节码时引擎知道变量的固定位置直接硬编码如存入寄存器r0。执行时没有运行时的字符串查找只有纯粹的内存/寄存器读写指令。形态B堆内存槽位针对 Declarative ER 中的逃逸变量/闭包物理载体V8 Heap堆内存中的Context/Slot结构。解释这是一个类似FixedArray固定数组或包含Cell引用的结构。闭包变量以固定的槽位索引Slot Index存储。访问时通过“基地址 偏移量”极速拿取而非哈希查找。进阶惰性分配V8 非常抠门内存。它不一定在 AST 解析完就立刻new出这个堆数组。通常在运行时或编译阶段借助强大的逃逸分析Escape Analysis引擎会尽量延迟甚至消除这种堆分配只有在无可避免真正创建闭包引用时才在堆上开辟空间。形态C复杂的对象/字典结构针对 Global ER / Object ER物理载体全局对象Global Object或 Property Cell。解释因为全局对象如window的属性可以被动态增删无法提前确定数组大小引擎通常使用更通用的字典结构或 Property Cell 来存放这在语义上最接近传统的哈希表。4. 外部环境引用 (Outer Reference) / 作用域链【规范层抽象级别 - 低】规范定义一个指向父级词法环境的引用指针。【V8层物理表现形式与载体】物理表现真实的内存指针/引用。真实存在方式在上述堆内存的Context结构中会保留一个指向父Context的指针通常位于特定的槽位中。当当前上下文查找未命中时引擎会沿着这些真实的物理指针按索引继续向外层查找从而在物理内存中串联起一条真正的作用域链Scope Chain。5. 函数的内部插槽[[Environment]]【规范层抽象级别 - 低】规范定义函数对象身上的一个隐藏属性保存创建该函数时的词法环境。【V8层物理表现形式与载体】物理表现C 对象内部的真实字段。真实存在方式在 V8 的实现中函数对象例如JSFunction的实例会包含一个专属的字段在源码中常见的命名如context_。这个字段保存着指向创建时词法环境堆上的Context对象的内存引用这就是闭包能够“记住”外部环境的物理铁证。6. TDZ (暂时性死区) 与 未初始化的物理实现【规范层抽象级别 - 逻辑态】规范定义let/const绑定已创建但未初始化此时访问将抛出ReferenceError。【V8层物理表现形式与载体】物理表现特殊的内部哨兵值Sentinel Value。真实存在方式为了实现 TDZ 语义V8 会在相应的内存槽位寄存器或 Context 槽中放置一个内部定义的哨兵标记例如常被称为the_hole的特殊 Tagged Value。运行机制当引擎的指令尝试读取该内存时如果发现读出的是这个特殊的哨兵值就会立刻触发ReferenceError。一旦代码执行到了真实的赋值语句真实的数据就会覆盖掉这个哨兵值TDZ 随之在物理层面上被解除。这个会吹哨子的警卫我们已经讲过无数次了。。。在前面学习字节码生成的时候我们使用了导演 场务 记录员 这个比喻随着我们的学习深入很有必要扩展一下我们的 片场宇宙 下面我们把片场宇宙的整体设定以表格的形式固定下来这个设定应该足以支撑我们的后续学习了。而且 在记忆点在准确性 等方面也是挺合适的。 这是我的原创丫保留版权。盗版会被追杀的。 嘿嘿嘿。。。一、 基建与环境片场比喻V8 底层实体核心职责与表现大老板 / 制片人Host Environment(宿主Chrome/Node.js)掌握生杀大权。负责出资建厂并在一切准备就绪后扣动Execution::Call扳机下达全场开机指令。独立制片厂Isolate进程内的独立工业园区。拥有专属土地和主线程。不能擅自串门所有跨厂通信须通过宿主提供的 IPC 桥接机制如 postMessage / embedder bridge以保证隔离策略与安全边界。拍摄场域Realm对应一套完整的全局内置对象体系。有助解释不同脚本/模块之间的原型链与全局隔离等高级语义如 iframe 之间的差异。大多数情况下Realm规范概念 ContextV8 物理实现逻辑摄影棚Context搭建在制片厂内的执行环境。提供基础道具如当前的window/global实例。同厂内可有多棚互不串戏。预制构件厂mksnapshot(快照机制)编译期打包好的引擎原生初始化对象与初始堆状态。开新棚时“拎包入住”。注意并不等同于把用户的运行时代码或业务脚本提前编译为机器码。清道夫 / 场地清理队GC(垃圾回收器)分两队新生代突击队Scavenge用复制算法把还在用的道具完整搬到新片场旧片场一键清空老生代重型拆迁队用 Mark-Sweep 清理废弃垃圾并用Mark-Compact标记压缩把还在用的别墅统一挪到地块前排消除内存碎片。道具仓库管理员Object Factory制片厂专属库管。负责统一创建、分配所有 JS 对象、字符串、数组等道具确保所有出库道具严格符合定妆照标准。二、 剧组班底与工作人员片场比喻V8 底层实体核心职责与表现原著编剧与审核员Parser Syntax Checker拆解源代码并同步查错如括号不匹配、非法语法。剧本不合格直接打回导演休想开工。导演BytecodeGenerator(字节码生成器)掌控全局的大佬。拿着 AST 原稿决定指令走向画出最初的分镜头脚本。场务BytecodeRegisterAllocator抠门的空间管理大师。编译期

相关新闻