前文总结 和 运行期前置知识
这个系列文章,已经写了一少半了,现在终于到了动态执行阶段了。
我们首先需要梳理一下知识,这部分内容,相对独立,但是都算是比较重要的知识点。
预编译的说法为什么不建议使用
在我们平时看文章,看资料,甚至是看一些比较权威的文档时,预编译 这个术语非常常见。但是,在js中,预编译 是个伪术语,是一些教材教程在以前的js教学中,为了解释变量提升等一些问题,生造出来的一个词语,后来,只要是运行期以前的 甚至是在和运行期交织发生的一些动作流程,统统装进了 预编译 这个大口袋里。大部分人,也就不求甚解的接受并使用了这个说法。但是,这是一个不规范且容易引发歧义的词汇。在传统编译语言中,预处理、编译与执行通常有明确的时间边界;在现代 JavaScript 环境,这些阶段高度交织。规范(ECMAScript (ECMA-262))并不使用“预编译”一词,而是通过“执行上下文的创建阶段(creation / declaration instantiation)”来描述声明的注册与初始化。实际引擎(例如 V8)则采用惰性解析与按需编译:先做必要的解析与作用域分析,再由解释器生成字节码(如 Ignition)或在运行时将热点编译为机器码(由优化器完成)。
对于js,可以分为如下四个宏观的阶段:
词法分析:把源代码分成记号(tokens)。
语法分析(Parsing):构建抽象语法树(AST),确定静态作用域结构。
执行上下文创建阶段(Creation / Declaration Instantiation):为全局或每次函数调用登记标识符(函数声明整体被绑定;
var注册并初始化为undefined;let/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 阶段就像是建筑设计图,明确了房间的布局(作用域)和走廊的连接关系(静态作用域链)。而运行时相当于实际建造,根据设计图动态分配水泥建材(内存),并让住户(变量值)真正住进去。
闭包形成的动态实例:
JavaScript
function 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)记录可以抽象为如下结构:
JavaScript
Execution 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负责var,LexicalEnvironment负责块级声明,完美实现了旧行为与新特性的并存。var 的处理(变量环境):
var声明会在 VariableEnvironment 上被创建并立刻初始化为undefined。这就是为什么在声明前读取var变量会得到undefined(即“变量提升”)。let/const 的处理(词法环境):它们在 LexicalEnvironment 上被创建,但并不初始化。在实际执行到声明语句之前,访问这些绑定会触发 TDZ,抛出
ReferenceError。三、 上下文完整实例
我们通过一段经典代码,观察环境及闭包的情况:
JavaScript
console.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 绑定(ThisBinding)
this的值并非由执行上下文自动决定为某个固定值,而是严格由调用方式在运行时动态决定:
- 直接调用 (
fn()):非严格模式指向全局对象,严格模式为undefined。 - 方法调用 (
obj.method()):指向调用者对象(基值obj)。 - 显式绑定 (
call / apply / bind):由传入的第一个参数决定。 - 构造调用 (
new Fn()):指向内部新创建的实例对象。 - 箭头函数:没有自己的
this,它会穿透当前上下文,从创建时的外层词法环境中继承this(Lexical 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)。
- 规范定义:一种用来定义标识符和变量值映射关系的嵌套结构(包含环境记录与外部引用)。规范特意区分词法环境和变量环境,是为了在语义上兼容 ES6 块级作用域(
- 【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),引擎会尽量延迟甚至消除这种堆分配,只有在无可避免(真正创建闭包引用)时才在堆上开辟空间。
- 物理载体:V8 Heap(堆内存)中的
- 形态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(规范概念) = Context(V8 物理实现) |
| 逻辑摄影棚 | Context | 搭建在制片厂内的执行环境。提供基础道具(如当前的window/global实例)。同厂内可有多棚,互不串戏。 |
| 预制构件厂 | mksnapshot(快照机制) | 编译期打包好的引擎原生初始化对象与初始堆状态。开新棚时“拎包入住”。(注意:并不等同于把用户的运行时代码或业务脚本提前编译为机器码)。 |
| 清道夫 / 场地清理队 | GC(垃圾回收器) | 分两队:新生代突击队(Scavenge)用复制算法把还在用的道具完整搬到新片场,旧片场一键清空;老生代重型拆迁队用 Mark-Sweep 清理废弃垃圾,并用Mark-Compact(标记压缩)把还在用的别墅统一挪到地块前排,消除内存碎片。 |
| 道具仓库管理员 | Object Factory | 制片厂专属库管。负责统一创建、分配所有 JS 对象、字符串、数组等道具,确保所有出库道具严格符合定妆照标准。 |
二、 剧组班底与工作人员
| 片场比喻 | V8 底层实体 | 核心职责与表现 |
|---|---|---|
| 原著编剧与审核员 | Parser & Syntax Checker | 拆解源代码并同步查错(如括号不匹配、非法语法)。剧本不合格直接打回,导演休想开工。 |
| 导演 | BytecodeGenerator(字节码生成器) | 掌控全局的大佬。拿着 AST 原稿,决定指令走向,画出最初的分镜头脚本。 |
| 场务 | BytecodeRegisterAllocator | 抠门的空间管理大师。编译期 |