环境记录与 Reference Record:作用域链的完整机制
作用域是"变量的可见范围",JavaScript 用词法作用域来决定它——代码写在哪里,作用域就定在哪里
JavaScript 使用词法作用域(Lexical Scoping,也叫静态作用域):变量的查找规则由代码的书写位置决定,而不是由函数的调用位置决定。这一点与动态作用域语言(如早期的 Perl、Bash)截然不同。一旦 JS 代码被解析,每个变量引用属于哪个作用域就已经固定。
词法作用域的设计目标是让代码更可预测:程序员只需要阅读函数定义的位置,就能确定它能访问哪些变量,而不需要追踪运行时的调用链。这让代码静态分析、代码补全、重构工具、打包器的 tree-shaking 等工具链能够在不执行代码的情况下理解变量的流向。ECMAScript 规范自 ES1 起就采用词法作用域,这是 Scheme 语言对 JavaScript 设计影响最深远的一点——Brendan Eich 在设计 JavaScript 时的主要参考语言之一就是 Scheme。
作用域链的物理载体是环境记录(Environment Record,ER)。每个 ER 有一个 [[OuterEnv]] 指针,指向外层的 ER,形成一条链。变量查找从最内层的 ER 开始,逐层向外,直到找到绑定或到达全局 ER 的 [[OuterEnv]](它是 null)。到达 null 还没找到,抛出 ReferenceError。这个查找过程是线性的——每个 ER 只有一个 [[OuterEnv]],形成单向链表,不是树或图。
Reference Record 是规范内部的类型,用来表示"尚未取值的引用"。foo.bar 这个表达式在规范层面产生一个 Reference Record,而不是直接产生 bar 的值。理解 Reference Record 是彻底搞懂 delete、严格模式下的赋值错误、以及 this 解析机制的关键。Reference Record 在规范里大量使用,但它从来不暴露给 JavaScript 代码——它是引擎内部的中间状态,存在于表达式求值过程中,GetValue 被调用后就消失了。
🔹 Level 1 · 你需要知道的
词法作用域:由书写位置决定
词法作用域意味着函数的变量查找范围在代码编写时就已经确定,与函数被如何调用、从哪里调用完全无关。这与直觉上的"运行时动态"思维相反,但正是这种静态特性让 JavaScript 引擎可以在编译阶段(JIT 优化时)就确定变量的位置,生成高效的机器码。如果 JavaScript 使用动态作用域,引擎就必须在运行时遍历调用栈来查找变量,这会严重影响性能且难以优化。
const x = 'global';
function outer() {
const x = 'outer';
function inner() {
console.log(x); // 'outer',不是 'global'
// inner 的作用域链:inner ER → outer ER → Global ER
// x 在 outer ER 里找到了
}
inner();
}
outer();
inner 在 outer 内部定义,所以它的外层 ER 是 outer 的 ER,不是调用 inner 的地方的 ER。这就是词法作用域的核心:作用域由代码结构(词法位置)决定,不由调用栈决定。
变量查找:从内到外,找不到就报错
作用域链的查找是有序的,永远从"最近的 ER"开始,向外延伸。这个"最近"是词法意义上的——不是调用栈的深度,而是代码嵌套层级的深度。一个函数嵌套在另一个函数里,内层函数的 ER 的 [[OuterEnv]] 就是外层函数的 ER。这个关系在代码解析阶段就确定了,不会随运行时的变化而改变。
function foo() {
const a = 1;
console.log(a); // 找到:foo ER 里有 a
console.log(b); // 找不到:foo ER 没有 → Global ER 也没有 → ReferenceError
}
foo();
查找变量时,引擎从当前 ER 开始,逐层向外。找到了就返回那个绑定的值;遍历完整条链到达 null 仍未找到,就抛出 ReferenceError: b is not defined。这不是 undefined——undefined 是"变量存在但没有值",ReferenceError 是"变量根本不存在"。
变量不存在和变量没有值,是两种本质上不同的状态。undefined 是 ECMAScript 的一种合法值类型,声明但未赋值的变量(var x;)、函数没有显式 return 的返回值、不存在的对象属性访问(obj.noSuchProp),返回的都是 undefined。而 ReferenceError 表示连绑定都不存在,引擎无法找到任何名为 b 的槽位来读取值。理解这个区别,是写出正确的防御性代码的基础。
三种声明的作用域规则
ECMAScript 在历史演进中积累了三种变量声明方式,每种对应不同的作用域规则和行为语义:
var:函数作用域,在最近的函数 ER(或全局 ER)里创建绑定,跨越{}块不受影响。var是 ES1 引入的唯一声明方式,函数作用域的设计源于 Lisp 家族语言的传统。var在函数内的任何位置声明,其绑定都"提升"到函数顶部,这在语义上等同于把所有var声明写在函数第一行。let/const:块级作用域,在最近的{}块对应的 ER 里创建绑定。ES2015 引入,专门为了解决var的函数作用域导致的混乱。let/const同样有"提升"(binding 在进入块时就创建),但处于暂时性死区(TDZ)状态直到声明语句执行——第 18 章会详细讲解 TDZ。function声明:函数作用域(函数体内),提升到所在函数(或全局)顶部,且立即初始化为函数对象(不是undefined)。这是函数声明 vs 函数表达式的核心区别之一。
{
var a = 1; // 提升到函数/全局作用域
let b = 2; // 仅在这个 {} 块内可见
const c = 3; // 仅在这个 {} 块内可见
}
console.log(a); // 1 ← var 穿透了 {}
console.log(b); // ReferenceError ← let 被块限制住了
console.log(c); // ReferenceError ← const 同上
var 穿透 {} 块的行为在 ES2015 之前是 JavaScript 的核心特性,但现在被视为设计缺陷。它使得变量的实际生存周期(整个函数)和视觉上的位置(某个 if 或 for 块里)不匹配,极易导致 bug,尤其是循环变量泄漏(for 循环结束后变量仍然可访问)。
🔸 Level 2 · 它是怎么运行的
5 种环境记录的完整体系
环境记录是 ECMAScript 规范中最核心的数据结构之一,它是"作用域"这个语言概念在规范层面的物理实现。每次进入一个新的作用域(调用函数、进入 {} 块、进入模块、进入 with 语句),引擎都会创建一个新的 ER 并把它连接到当前的作用域链上。
规范定义了 5 种 ER,它们形成一个层次结构:
Environment Record(抽象基类)
├─ DeclarativeEnvironmentRecord
│ ├─ FunctionEnvironmentRecord (函数调用创建)
│ └─ ModuleEnvironmentRecord (ESM 模块顶层)
├─ ObjectEnvironmentRecord (with 语句创建)
└─ GlobalEnvironmentRecord (全局作用域,混合体)
├─ [[ObjectRecord]] → ObjectEnvironmentRecord
└─ [[DeclarativeRecord]] → DeclarativeEnvironmentRecord
DeclarativeEnvironmentRecord:最常见的 ER,存储 let/const/class/function/import 的绑定。绑定存在内部的记录里,不挂到任何对象上,不可被 delete。这类 ER 的绑定对外完全不可见——不能通过对象属性访问,也不能通过反射 API 枚举。这是 let/const 绑定比 var 绑定更私密的规范原因。
ObjectEnvironmentRecord:与一个对象关联,对象的属性就是 ER 里的绑定。with (obj) { foo } 创建的就是 ObjectEnvironmentRecord,以 obj 为绑定对象。这意味着变量查找会直接查这个对象的属性,包括继承的属性。全局作用域的 var 声明通过 GlobalEnvironmentRecord 内的 ObjectEnvironmentRecord 挂到全局对象上,这就是全局 var 变量能通过 window.x 访问的原因。
FunctionEnvironmentRecord:继承自 DeclarativeEnvironmentRecord,额外添加了以下字段,这些字段是函数调用语义的核心载体:
[[ThisValue]]:函数被调用时绑定的this,由 OrdinaryCallBindThis 算法在函数调用时设置[[ThisBindingStatus]]:三种状态,'lexical'(箭头函数,没有自己的 this)/'initialized'(this 已经绑定,可以读取)/'uninitialized'(派生类构造函数调用 super() 之前的状态)[[HomeObject]]:super关键字用于定位原型的引用,只有用class语法或对象字面量方法简写定义的函数才有此槽,普通函数这个槽为undefined[[NewTarget]]:new.target元属性的值,普通调用时为undefined,new调用时为目标构造函数
ModuleEnvironmentRecord:继承自 DeclarativeEnvironmentRecord,支持 import 绑定(不可变的引用)。ESM import 导入的绑定是只读的"实时绑定"(live binding)——导出模块修改了值,导入方看到的是新值。这不是拷贝,而是对导出模块的 ER 里那个绑定的间接引用。模块的 [[OuterEnv]] 指向 Global ER,而不是某个其他模块,模块之间不存在作用域继承关系。
GlobalEnvironmentRecord:全局作用域的混合体,不直接继承任何 ER,而是组合了 ObjectEnvironmentRecord(管理 var 和全局属性)和 DeclarativeEnvironmentRecord(管理 let/const/class)。设计上的折中:必须同时支持向后兼容的 var 全局挂载行为,和现代的 let/const 块级隔离行为,所以用两个子记录分别处理。
作用域链的完整查找过程
┌────────────────────────────────────────────────────────────────────┐
│ 变量查找流程示意(GetIdentifierReference) │
│ │
│ 当前 EC │
│ └─ LexicalEnvironment (inner ER) │
│ ├─ 在 inner ER 中查找变量 x │
│ │ ├─ 找到 → 返回 Reference Record { Base: inner ER, Name: x }│
│ │ └─ 未找到 ↓ │
│ └─ [[OuterEnv]] → outer ER │
│ ├─ 在 outer ER 中查找变量 x │
│ │ ├─ 找到 → 返回 Reference Record │
│ │ └─ 未找到 ↓ │
│ └─ [[OuterEnv]] → Global ER │
│ ├─ 先查 [[DeclarativeRecord]] │
│ │ ├─ 找到 → 返回 Reference Record │
│ │ └─ 未找到 → 查 [[ObjectRecord]](全局对象属性) │
│ │ ├─ 找到 → 返回 Reference Record │
│ │ └─ 未找到 ↓ │
│ └─ [[OuterEnv]] = null → 创建 Unresolvable Reference │
│ → GetValue 时抛出 ReferenceError │
└────────────────────────────────────────────────────────────────────┘
Reference Record:规范级别的"引用"
在大多数语言里,变量访问是一个原子操作:引擎读取变量的值,把它交给调用方。但 ECMAScript 规范在这中间插入了一个中间层——Reference Record,专门用来表示"尚未被读取的变量位置"。这个设计不是多此一举,而是规范在统一描述"读取值(GetValue)"、"写入值(PutValue)"和"删除绑定(delete)"三种操作时,需要一个公共的"引用描述符"来携带"这个操作针对哪个位置"的信息。
Reference Record 是 ECMAScript 规范定义的一种规范类型(Specification Type),它不是运行时值,是规范用来描述"某个绑定的位置"的内部结构。
一个 Reference Record 包含:
| 字段 | 类型 | 含义 |
|---|---|---|
| [[Base]] | Environment Record / Object / unresolvable |
绑定所在的环境或对象 |
| [[ReferencedName]] | String / Symbol | 绑定的名称 |
| [[Strict]] | Boolean | 是否处于严格模式 |
| [[ThisValue]] | Any / empty | 用于 super 引用 |
示例:foo.bar 这个表达式产生 Reference Record:{ Base: foo(对象), ReferencedName: 'bar', Strict: false }
GetValue(ref):从 Reference Record 取出实际值。调用 foo.bar 时,引擎对 foo.bar 执行 GetValue 得到实际的函数对象,然后调用它(并且以 foo 作为 this,因为 [[Base]] 是 foo)。
PutValue(ref, value):把值写入 Reference Record 指向的位置。foo.bar = 1 的语义是:对 foo.bar 产生的 Reference Record 调用 PutValue,将 1 写入 foo 的 bar 属性。
delete 操作符的语义依赖 Reference Record:
delete foo.bar:foo.bar产生{ Base: foo, ReferencedName: 'bar' }→ 可删(属性删除)delete bar:bar产生{ Base: EnvironmentRecord, ReferencedName: 'bar' }→ 不可删(ER 绑定)
with 语句的 ObjectEnvironmentRecord 查找顺序
with 语句是 JavaScript 历史遗留的设计之一,它试图提供一种"把对象的属性作为本地变量来用"的快捷方式,但结果是引入了更多的混乱。理解 with 的查找机制,能帮助你在维护旧代码时正确推断变量的来源,也能更深刻地理解为什么严格模式把它完全禁止了。
const x = 'global';
const obj = { x: 'obj', y: 'obj-y' };
with (obj) {
console.log(x); // 'obj' ← 先在 obj 的 ObjectER 里找到了
console.log(y); // 'obj-y' ← 同上
console.log(z); // ReferenceError ← obj 没有 z,继续向外到 Global ER 也没有
}
with (obj) 创建一个 ObjectEnvironmentRecord,以 obj 为绑定对象。这个 ER 被插入到原来的 LexicalEnvironment(with 语句所在的作用域 ER)和它的 [[OuterEnv]] 之间——这会改变当前 EC 的 LexicalEnvironment 指向,使其指向新创建的 ObjectER,ObjectER 的 [[OuterEnv]] 再指向原来的作用域。
with 危险的核心原因不仅是"查找顺序变了",更在于 obj 的属性集合是运行时动态的。同一段 with 内的代码,如果 obj 在运行过程中增加了新属性,对同一个变量名的后续访问结果就会发生变化。这让静态分析工具(ESLint、TypeScript、webpack)无法准确推断变量引用到底是局部变量还是 obj 的属性,引擎的 JIT 优化器也无法在编译时确定变量位置,必须在运行时每次都检查 obj 的属性。严格模式下 with 被完全禁止(SyntaxError),这是正确的工程决策。
🔺 Level 3 · 规范怎么定义的
规范 Section 9.1:Environment Records
ECMAScript 规范在第 9.1 节使用面向对象的方式定义环境记录:存在一个抽象基类"Environment Record",各种具体类型继承它并覆盖(override)部分方法。这种规范风格(Abstract Method + Concrete Override)贯穿整个 ECMA-262,理解它有助于阅读原始规范文本。
规范 9.1 定义了 Environment Record 作为抽象基类,所有 ER 类型共享 6 个基础方法:
| 方法 | 含义 |
|---|---|
HasBinding(N) |
是否有名为 N 的绑定 |
CreateMutableBinding(N, D) |
创建可变绑定(D=true 表示可删除) |
CreateImmutableBinding(N, S) |
创建不可变绑定(S=true 表示严格初始化检查) |
InitializeBinding(N, V) |
初始化绑定(TDZ → 已初始化) |
SetMutableBinding(N, V, S) |
设置可变绑定的值(S=true 时写只读绑定报错) |
GetBindingValue(N, S) |
获取绑定的值(TDZ 状态时报 ReferenceError) |
DeleteBinding(N) |
删除绑定(DeclarativeER 中不可删除的绑定返回 false) |
HasThisBinding() |
是否有 this 绑定(只有 FunctionER 和 GlobalER 返回 true) |
HasSuperBinding() |
是否有 super 绑定(只有 FunctionER 中的方法返回 true) |
WithBaseObject() |
返回 ObjectER 的绑定对象(用于 Reference Record 的 Base),其他 ER 返回 undefined |
DeclarativeEnvironmentRecord.CreateMutableBinding 的关键规范行为:创建的绑定标记为 initialized: false(TDZ 状态),仅在 InitializeBinding 调用后才变为已初始化。GetBindingValue 在绑定未初始化时,直接抛出 ReferenceError。
规范 Section 6.2.5:Reference Record
Reference Record 是规范里一种"幕后"数据结构,它在大量操作的中间计算过程中出现,但从不直接暴露给 JavaScript 代码。理解 Reference Record 的最大价值在于:它统一解释了 JavaScript 里看似无关的多个行为——delete 的返回值、赋值操作的工作方式、属性访问与变量访问的区别、以及 this 在方法调用中的来源。把这些行为都映射到 Reference Record 的结构和操作上,就得到了一个统一的解释框架。
规范第 6.2.5 节定义 Reference Record 的完整字段和操作:
GetValue(V) 抽象操作:
1. 如果 V 不是 Reference Record,返回 V(已经是值了)
2. 如果 V.[[Base]] 是 unresolvable → 抛出 ReferenceError
3. 如果 V.[[Base]] 是 Environment Record:
a. 调用 base.GetBindingValue(V.[[ReferencedName]], V.[[Strict]])
4. 否则(Base 是对象):
a. 调用 base.[[Get]](V.[[ReferencedName]], GetThisValue(V))
GetThisValue(V) 抽象操作:
1. 如果 V 是 Super Reference([[ThisValue]] 不是 empty)→ 返回 V.[[ThisValue]]
2. 否则返回 V.[[Base]]
这解释了 super.method() 中 this 为什么是当前对象而不是父类原型:[[ThisValue]] 存储了真正的 this,[[Base]] 存储的是 HomeObject(原型对象),两者通过 GetThisValue 区分。
GetIdentifierReference 算法
GetIdentifierReference 是作用域链查找在规范里的精确描述。当代码执行到变量引用(如 console.log(x) 中的 x)时,引擎调用这个算法从当前 LexicalEnvironment 开始,沿着 [[OuterEnv]] 链向上查找,直到找到 x 的绑定或到达链的终点(null)。这个算法的结构是一个尾递归,每次递归传入 env.[[OuterEnv]] 作为下一层 ER。
GetIdentifierReference(env, name, strict) 是变量查找的核心规范算法:
1. 如果 env 为 null → 返回 { [[Base]]: unresolvable, [[ReferencedName]]: name, [[Strict]]: strict }
2. 调用 env.HasBinding(name)
3. 如果找到:
a. 返回 { [[Base]]: env, [[ReferencedName]]: name, [[Strict]]: strict }
4. 否则:
a. outer = env.[[OuterEnv]]
b. 返回 GetIdentifierReference(outer, name, strict) ← 递归查外层
这个递归算法直接对应了"作用域链"的直觉模型。[[Base]]: unresolvable 意味着整条链都没找到;对这样的 Reference Record 调用 GetValue 会抛出 ReferenceError。
💎 Level 4 · 边界与陷阱
陷阱 1:delete 的返回值行为差异
delete 是 JavaScript 里最难预测返回值的操作符之一。它的文档说明告诉你"删除属性返回 true,其他情况返回 false",但实际上规则远比这复杂,完全取决于操作数产生的 Reference Record 类型:
// 场景 1:delete 对象属性(Base 是对象)
const obj = { x: 1 };
console.log(delete obj.x); // true ← 属性删除成功
console.log(obj.x); // undefined
// 场景 2:delete var 声明的变量(Base 是 DeclarativeER,不可删)
var a = 1;
console.log(delete a); // false ← 不可删,Declarative ER 的绑定
console.log(a); // 1 ← 变量仍然存在
// 场景 3:delete 没有声明的全局属性(挂在全局对象上,Base 是 ObjectER)
b = 2; // 非严格模式下,b 成为全局对象的属性
console.log(delete b); // true ← 全局对象属性可以删除
console.log(typeof b); // 'undefined'
// 场景 4:严格模式下 delete 变量名
'use strict';
var c = 3;
delete c; // SyntaxError: Delete of an unqualified identifier in strict mode.
规范定义:delete 操作符对 Reference Record 进行判断:
[[Base]]是 Environment Record 且严格模式 → SyntaxError(在解析阶段报错)[[Base]]是 Environment Record 且非严格模式 →false(不删除,返回 false)[[Base]]是对象 → 调用对象的[[Delete]]方法
陷阱 2:eval 内用 var 声明的变量可以被 delete
eval 是 JavaScript 里另一个历史遗留的特殊机制。在非严格模式下,eval 内的 var 声明会在调用 eval 的作用域里创建绑定,但这些绑定被标记为"可删除"(D=true),与普通的 var 声明(D=false)有一个关键区别。这导致了一个反直觉的行为:同样用 var 声明的变量,直接写的不可删除,通过 eval 写的可以删除:
// 非严格模式下,eval 内 var 声明的绑定是"可删除的"
eval('var x = 1');
console.log(x); // 1
console.log(delete x); // true ← 可以删除!
console.log(typeof x); // 'undefined'
// 对比:普通 var 声明不可删除
var y = 2;
console.log(delete y); // false
console.log(y); // 2
根因:规范规定,eval 内的 var 声明通过 CreateMutableBinding 创建时,传入 D = true(deletable = true);而普通代码中的 var 声明通过 VarDeclaredNames/InstantiateVarScopeDeclarations 流程创建时,传入 D = false。这个差异在 ES5 时代就存在,为了向后兼容一直保留至今。
陷阱 3:with 语句中变量查找的运行时动态性导致静默 bug
with 语句的 ObjectEnvironmentRecord 查找对象属性的行为,在运行时是动态的,不是静态的。这意味着同一个代码块里同一个变量名,在不同的运行时状态下可能访问到完全不同的值,而且完全不报错。这类 bug 极难排查:
function processData(data) {
with (data) {
// 假设你本来想用外层的 length 变量
// 但 data 有 length 属性时,这里访问的是 data.length
// data 没有 length 属性时,才访问外层的 length
console.log(length);
}
}
const length = 10; // 外层变量
processData({ length: 999 }); // 输出 999(data.length)
processData({}); // 输出 10(外层 length)
这个问题的本质:with 的 ObjectEnvironmentRecord 在运行时查询对象属性,对象的属性集合是动态的。同一个变量名 length,在不同的 data 对象下可能指向完全不同的值。这使得 with 语句内的代码无法静态分析,也无法被 V8 JIT 编译器优化(JIT 需要确定变量位置才能生成高效机器码)。
严格模式下 with 被完全禁止(SyntaxError),这是正确的工程决策。
陷阱 4:暂时性死区(TDZ)不只是"声明前访问"
TDZ(Temporal Dead Zone,暂时性死区)最常见的理解是"在 let/const 声明行之前访问变量会报错"。这个理解是正确的,但不完整。更隐蔽的场景是:内层作用域的 let/const TDZ 会完全遮蔽外层同名变量,让你根本无法访问外层变量,即使外层变量已经有值:
const x = 'outer';
{
// 这个块里,x 的 TDZ 从块开始到 let 声明处
// 即使外层作用域有 x,内层的 x 也不可访问
console.log(x); // ReferenceError! 不是 'outer'
let x = 'inner'; // x 的 TDZ 结束
console.log(x); // 'inner'
}
规范行为:当引擎进入一个块时,块内所有的 let/const 声明会被"提升"(创建绑定),但绑定的状态是 uninitialized(TDZ)。GetIdentifierReference 在当前 ER 找到了 x 的绑定(状态是 uninitialized),就不再向外层 ER 查找,直接对这个 uninitialized 绑定调用 GetBindingValue,抛出 ReferenceError。
外层的 x 完全被屏蔽,你甚至无法访问它,直到内层 let x 执行完毕。
陷阱 5:函数声明提升 vs 变量提升的优先级与覆盖规则
JavaScript 的提升(hoisting)机制经常被描述为"声明被移到顶部",但这只是一个简化的说法。规范实际定义的是:在代码执行之前,引擎先扫描整个函数体(或脚本/模块),收集所有的声明,创建相应的绑定。var 声明的绑定立即初始化为 undefined,function 声明的绑定立即初始化为对应的函数对象,let/const 的绑定创建但不初始化(TDZ)。这个"初始化"阶段在函数体的第一行代码执行之前就完成了,所以在任何代码行之前,这些绑定就已经存在了。
// 函数声明提升的优先级高于 var
console.log(typeof foo); // 'function'(不是 'undefined')
var foo = 1;
function foo() {}
console.log(foo); // 1(var 赋值在运行时执行)
规范规定的处理顺序(函数/脚本实例化阶段):
- 处理
function声明:创建绑定并立即初始化为函数对象 - 处理
var声明:如果绑定已存在(被function声明占用),跳过(不重复创建)
因此,执行到 var foo = 1 这一行时,foo 已经有值(函数),这一行只执行赋值 foo = 1,覆盖了函数值。
更复杂的场景——同名函数声明在块内(非严格模式,浏览器的遗留行为):
console.log(typeof f); // 'undefined'(块内函数声明的特殊提升规则)
{
console.log(typeof f); // 'function'(块内,函数已提升)
function f() {}
}
console.log(typeof f); // 'function'(块执行后,f 被"同步"到外层)
块级函数声明在非严格模式下有 Web 遗留语义:它在块内提升(类似 let),但在块执行完毕后,还会把当时的值"拷贝"到外层作用域的 var 绑定上。这是专门为了向后兼容大量依赖这一行为的旧代码而保留的特殊规则,在严格模式下块级函数声明就是普通的块级绑定,不会泄漏。
附:作用域链与性能的关系
理解作用域链不只有规范意义,还有直接的性能影响。变量查找是沿着作用域链线性向外的,查找深度越大,查找路径越长。在 V8 引擎中,访问本地变量(当前 ER 里的变量)是最快的,需要 0 次外层查找;每多查一层,就多一次 [[OuterEnv]] 指针解引用。在实际工程中,对全局变量的访问比对局部变量的访问慢——对于需要在热路径里频繁访问全局变量的场景,把全局变量缓存到局部变量里可以获得可测量的性能提升:
// 对性能敏感的代码:避免在循环里重复访问全局变量
// 低效:每次迭代都沿作用域链查找 Math
function computeSlow(data) {
for (let i = 0; i < data.length; i++) {
data[i] = Math.sqrt(data[i]) * Math.PI; // Math 是全局变量,每次都要查找
}
}
// 高效:把全局引用缓存到局部变量
function computeFast(data) {
const sqrt = Math.sqrt; // 缓存到局部变量,后续直接从当前 ER 访问
const PI = Math.PI;
for (let i = 0; i < data.length; i++) {
data[i] = sqrt(data[i]) * PI; // 访问局部变量,无需查找外层 ER
}
}
当然,现代 JIT 编译器(包括 V8 的 TurboFan)在很多场景里会自动做这类优化,实际性能差距比理论上小。但这个原则在以下情况下仍然有效:热路径里频繁访问的深层作用域变量、在 eval 或 with 存在时(JIT 优化被禁用,作用域链查找退化为解释执行)。
附:ESM 的 import 绑定是实时绑定而非值拷贝
ESM(ES Modules)的导入/导出机制是作用域链概念的一个重要延伸,理解它的实现方式能帮助你避免在模块化代码里的一类常见 bug。
// counter.js(导出模块)
export let count = 0;
export function increment() {
count++;
}
// main.js(导入模块)
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1(不是 0!import 是实时绑定,不是值拷贝)
// 对比 CommonJS(值拷贝):
// const { count, increment } = require('./counter');
// console.log(count); // 0
// increment();
// console.log(count); // 0(CommonJS 导入的是值拷贝,不反映模块内部的更新)
ESM 的 import 绑定在规范层面是 ModuleEnvironmentRecord 里的"间接绑定"——不存储值本身,而是存储"指向另一个模块的某个 ER 中某个名称"的引用。当读取 count 时,规范要求穿透到 counter.js 的模块 ER 里读取当前值。这就是为什么 increment() 之后,main.js 里的 count 立刻变为 1——它们指向同一个绑定,不是两个独立的值。这与 CommonJS 的 require(返回对象,属性是值的快照)形成鲜明对比。
小结
-
JavaScript 使用词法作用域,变量的可见范围由代码书写位置决定;变量查找从当前 ER 沿
[[OuterEnv]]链向外,到达null仍未找到则抛出ReferenceError(不是返回undefined)。 -
规范定义了 5 种环境记录:DeclarativeER(let/const)、ObjectER(with)、FunctionER(函数调用)、ModuleER(ESM)和 GlobalER(混合体);GlobalER 内部组合了 ObjectER(管理 var 和全局属性)和 DeclarativeER(管理 let/const/class)。
-
Reference Record 是规范内部类型,表示"未取值的引用",包含
[[Base]](环境记录或对象)和[[ReferencedName]](名称);GetValue 从 Reference Record 取实际值,PutValue 向其写入值;delete操作符的行为完全取决于 Reference Record 的[[Base]]类型。 -
with语句创建 ObjectEnvironmentRecord,属性查找在运行时动态进行,阻止了 JIT 优化和静态分析;严格模式完全禁止with。 -
TDZ(暂时性死区)的本质是绑定已创建但未初始化(
uninitialized状态);进入块时所有let/const声明立即创建(状态为 uninitialized),GetIdentifierReference找到后停止向外查找,对 uninitialized 绑定取值时抛出ReferenceError,导致内层 TDZ 会遮蔽外层同名变量。