作用域链闭包原理
# 04.作用域链与闭包深度
📍 上接第 03 篇《类型隐式转换精算》。值是什么类型搞清楚了。本文回答:当代码执行时,JS 引擎如何「记住」这些值——作用域链怎么查?闭包在 V8 里怎么存?循环里的 var 和 let 为什么表现完全不同?一个闭包从创建到被 GC 回收,中间经历了什么?
# 目录介绍
- 1. 案例与疑问引入
- 2. 架构全景概览
- 3. 执行上下文集
- 4. 词法与变量环境
- 5. TDZ暂时死区
- 6. 作用域链机制
- [6.1 [OuterE
- 6.2 RHS 查找 v
- 6.3 with 和 e
- 7. 闭包物理实现
- 8. 闭包六大陷阱
- 9. 闭包六大模式
- 10. 综合案例串讲
# 1. 案例与疑问引入
# 1.1 一个事件监听器让
先看一段在生产环境真实出现过的代码——一个数据可视化看板的"图表交互管理器",上线三天后内存从 80MB 涨到 450MB,用户抱怨页面越来越卡:
// chart-manager.js —— 图表交互管理器(故障版本)
class ChartManager {
constructor(chartData) {
this.data = chartData; // ← 200MB 的原始数据(含 100 万条数据点)
this.charts = [];
}
createChart(container) {
// 创建图表实例
const chart = new ChartInstance(container, this.data);
// 绑定交互事件——用户点击图表上的数据点
container.addEventListener('click', function handleClick(e) {
// 这个闭包引用了外层作用域的所有变量!
const clickedPoint = chart.findPoint(e.clientX, e.clientY);
console.log('Clicked:', clickedPoint);
// handleClick 虽然没直接引用 this.data,
// 但它的 Context 引用了整个 createChart 的词法环境
// → 而那个词法环境里有 chart(引用了 this.data)
// → 闭包链:handleClick → createChart 的 Context → chart → this.data(200MB!)
});
this.charts.push(chart);
return chart;
}
destroyChart(chart) {
const idx = this.charts.indexOf(chart);
if (idx !== -1) {
this.charts.splice(idx, 1);
chart.destroy();
// ⚠️ 忘记了 container.removeEventListener?
// → handleClick 闭包仍然被 DOM 的 listener 引用
// → 闭包链锁住了 createChart 的整个词法环境
// → 200MB 的 this.data 永远不会被 GC 回收!
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
现象:
- 用户频繁"添加图表→删除图表",每次删除后内存不降
- 5 次循环后 RSS 从 80MB → 450MB → 浏览器卡死
- Chrome 任务管理器:该 Tab 的内存占用持续爬升
destroyChart被正确调用——图表destroy()也执行了——但内存没释放
# 1.2 顺藤摸到根因
带着这条线往下挖,在 Chrome DevTools 中做堆快照对比:
- 假设 1:是不是
chart.destroy()没释放内存?——单独测试:创建一个ChartInstance→ 调用destroy()→ 内存确实降了。destroy本身没问题。 - 假设 2:是不是
this.charts数组还持有引用?——splice确实移除了,堆快照里也没有ChartInstance的存活对象。 - 假设 3:那为什么内存不降?——堆快照中搜索
chartData→ 发现它仍存活(Retained byhandleClick→Context→createChart词法环境)。关键:container.addEventListener注册的handleClick闭包没有被移除。虽然chart被destroy了,但 DOM 的 listener 仍然引用着handleClick,而handleClick的 Context 引用了包含chart和this.data的整个词法环境。 - 假设 4:为什么
handleClick没引用this.data却锁住了它?——因为 JS 的作用域是词法环境级别的,不是"按需引用"的。handleClick的[[OuterEnv]]指向createChart的词法环境,这个环境里包含所有在createChart中声明的变量——包括chart(它引用了this.data)。GC 不能只回收this.data而不回收词法环境——因为环境是一个整体。
# 1.3 我们要回答什么
这段代码里至少藏着 6 个原理点:
① 执行上下文(EC)和词法环境在 V8 里分别是什么物理结构?ECS 栈在哪里? → 第 2~3 章
② 为什么 let/const 存在"词法环境"而 var 存在"变量环境"?分两个环境是不是多余? → 第 4 章
③ TDZ 的 TheHole 哨兵值在 V8 的字节码层是怎么工作的? → 第 5 章
④ [[OuterEnv]] 为什么在函数定义时锁定?这怎么保证了词法作用域? → 第 6 章
⑤ 闭包的 Context 对象在堆上是如何分配的?JSFunction::context 指针到底指向哪? → 第 7 章
⑥ 为什么"缩小闭包的捕获半径"能防止本章案例中的 200MB 泄漏?GC 怎么知道该回收谁? → 第 8 章
2
3
4
5
6
本篇路线:
架构总图(第 2 章)
↓
三种 EC + ECS 栈(第 3 章)──→ 解开"函数调用时 V8 在内存中建了什么"
↓
词法环境 vs 变量环境(第 4 章)──→ 解开"为什么 let 和 var 不能混在一个环境里"
↓
TDZ / TheHole(第 5 章)──→ 解开"let x = x 为什么抛错而不是 undefined"
↓
作用域链 / [[OuterEnv]](第 6 章)──→ 解开"变量查找沿着哪条链走"
↓
闭包的 V8 实现(第 7 章)──→ 解开"闭包不是魔法——是一个堆上指针"
↓
六大陷阱 + 六模式(第 8~9 章)──→ 从"坑"到"用"
↓
综合案例(第 10 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:上接类型系统篇(第 03 篇)、下接 this 规则篇(第 05 篇)。第 01 篇讲了 V8 的执行管线(解析→编译→执行),第 02 篇讲了垃圾回收。本篇回答:当 V8 执行到一段代码时,它在内存中建立了什么数据结构来追踪变量、这些数据结构怎么构成"能从内层访问外层的链"、以及闭包为什么能在这个结构上自然存在。读完本篇后,你对"闭包"不再觉得神秘——它就是 V8 堆上的一个指针链。
# 2. 架构全景概览
# 2.1 执行上下文详解
┌──────────────────────────────────────────────────────────────┐
│ JS 引擎执行模型(ECMA-262 §9) │
├──────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────┐ │
│ │ 执行上下文栈(Execution Context Stack)│ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ 全局执行上下文 (GEC) │ │ ← 栈底,程序运行 ∣
│ │ │ └ 全局词法环境 (Global Lexical Env)│ │ 即创建、永不销毁│
│ │ └────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ 函数执行上下文 (FEC) │ │ ← 调用 fn() 时压栈│
│ │ │ └ 局部词法环境 (Local Lexical Env) │ │ 返回时出栈 ∣
│ │ └────────────────────────────────────┘ │ │
│ │ ┌────────────────────────────────────┐ │ │
│ │ │ 嵌套函数执行上下文 │ │ ← 递归/回调时压栈 │
│ │ └────────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ │
│ 每个执行上下文内部包含(ECMA-262 §9.4): │
│ ┌─────────────────────────────────────────┐ │
│ │ 词法环境 (LexicalEnvironment) │ │
│ │ ├ 环境记录 (EnvironmentRecord) │ ← let/const 存这里│
│ │ │ └ { count: <uninitialized>, ... } │ │
│ │ └ [[OuterEnv]] ─────→ 外层词法环境 │ ← 构成作用域链 │
│ │ │ │
│ │ 变量环境 (VariableEnvironment) │ │
│ │ └ 环境记录 (EnvironmentRecord) │ ← var 存这里 │
│ │ │ │
│ │ this 绑定 │ ← 函数的 this 值 │
│ │ [[PrivateEnvironment]] │ ← class 私有字段 │
│ └─────────────────────────────────────────┘ │
│ │
│ ⚠️ 规范层 vs V8 实现层的关键差异: │
│ ECMA-262 规范描述"环境记录"在栈上——但 V8 实际把它分配在堆上 │
│ (Context 对象)。这是因为规范描述抽象的"行为",而 V8 需要实现 │
│ "闭包捕获变量后变量不能随栈销毁"——只有堆对象能做到。 │
│ │
└──────────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# 2.2 为什么需要三种执
疑惑:为什么不能把所有变量塞进一个"大环境"里?为什么进入函数就要新建一个执行上下文?
论证:
三种执行上下文不是设计者的偏好——它们是 JS 代码三种不同"入口模式"的必然产物:
| 上下文类型 | 触发时机 | this 值 | 环境记录类型 | 何时销毁 |
|---|---|---|---|---|
| 全局(GEC) | 脚本首次执行 | 全局对象(window/globalThis) | 混合型:对象环境记录 + 声明式环境记录 | 程序退出 |
| 函数(FEC) | 每次函数调用 | 按 this 绑定规则(第 05 篇) | 声明式环境记录 | 函数返回后(除非被闭包引用) |
| eval | eval() 调用 | 继承调用者的 this | 取决于 strict mode | eval 执行完毕 |
关键差异在于"环境记录"的类型:
- 全局上下文的混合型环境记录——这是 JS 兼容 Web 平台的历史产物。
var声明的变量为什么挂到window上?因为全局上下文的环境记录有一半是"对象环境记录"——它的底层是一个真正的 JS 对象(浏览器中就是window),var变量被直接绑定为这个对象的属性。而let/const变量被存在另一半"声明式环境记录"中——不挂 window。 - 函数上下文的纯声明式环境记录——函数内声明的一切(var/let/const)都不会变成外部对象的属性。它们只存在于这个函数执行上下文内部的环境记录中,外部无法直接引用。
结论:三种执行上下文不是冗余设计——每种对应一个"变量的可见性和生命周期范围"。全局上下文 = 程序全程可见;函数上下文 = 仅在调用期间可见(除非闭包延长);eval 上下文 = 运行时可注入。如果我强行把它们合为一个,要么所有变量都变成全局变量(安全灾难),要么每个变量都要在语法层面标记"可见范围"(变成 C++ 的 public/private,失去了 JS "轻量对象"的特质)。
# 3. 执行上下文集
# 3.1 全局执行上下文
Global Execution Context (GEC):
┌─────────────────────────────────────────────────┐
│ Global Lexical Environment │
│ ├ 声明式环境记录(Declarative ER): │
│ │ let x = 1; const y = 2; class Foo {} │
│ │ 这些变量存在于环境记录中,不会变成 window 属性 │
│ │ │
│ └ 对象环境记录(Object ER): │
│ var z = 3; function bar() {} │
│ 这些变量被绑定到全局对象 window / globalThis │
│ → window.z === 3 ✅ │
│ → window.x === undefined ❌(let 不挂全局) │
│ │
│ [[OuterEnv]] → null(全局是最外层,没有外层) │
│ │
│ this → window / globalThis(浏览器/Node) │
└─────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
验证:
// 浏览器 Console 中:
var a = 1;
let b = 2;
console.log(window.a); // → 1 ← var 挂在 window 上
console.log(window.b); // → undefined ← let 在声明式环境记录中
2
3
4
5
# 3.2 函数执行上下文
Function Execution Context (FEC):
┌─────────────────────────────────────────────────┐
│ Function Lexical Environment │
│ ├ 环境记录: │
│ │ arguments 对象(非箭头函数) │
│ │ 参数: a, b, c(从调用方传入) │
│ │ 函数体内声明的 let/const 变量 │
│ │ 内部函数声明(会被整个提升) │
│ │ │
│ └ [[OuterEnv]] → 定义该函数时的外部词法环境 │
│ ← 注意:不是"调用时"的环境,是"定义时"的环境! │
│ ← 这是词法作用域的物理基础——也是闭包的根基 │
│ │
│ this → 按调用方式决定(new/显式/隐式/默认) │
└─────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.3 eval 执行上
eval 的特殊性在于它可以动态注入变量到调用者的作用域:
function test() {
const x = 1;
eval('var y = 2'); // ← y 被注入 test 的变量环境!
eval('const z = 3'); // ← z 被注入 test 的词法环境
console.log(y); // → 2
console.log(z); // → 3
}
test();
console.log(typeof y); // → 'undefined'(y 在 test 内、不是全局)
2
3
4
5
6
7
8
9
严格模式下 eval 创建独立环境——不污染调用者:
function testStrict() {
'use strict';
const x = 1;
eval('var y = 2');
console.log(typeof y); // → 'undefined'(严格模式:eval 有独立环境)
}
2
3
4
5
6
V8 对 eval 的极端反应:因为 eval 的字符串参数可以是任意运行时拼接的数据,V8 的 TurboFan 编译器在编译时无法静态分析该函数内的变量布局。结果:凡包含 eval 的函数,TurboFan 直接拒绝优化——它连 Sparkplug 基线编译都会跳过,退回 Ignition 解释执行。单个 eval 调用可能让你整个模块的热函数都跑在解释器上。
# 3.4 执行上下文栈的压
ECS 是一个 LIFO 栈——追踪 JS 正在执行哪层代码:
function outer() {
const a = 1;
function inner() {
const b = 2;
console.log(a + b);
}
inner();
}
outer();
// ECS 的生命周期:
//
// 时刻 0(程序启动):
// ECS: [ GEC ] ← 全局上下文在栈底
//
// 时刻 1(调用 outer()):
// ECS: [ GEC, FEC_outer ] ← outer 的 FEC 压栈
//
// 时刻 2(调用 inner()):
// ECS: [ GEC, FEC_outer, FEC_inner ] ← inner 的 FEC 压栈
// console.log(a + b) → 通过作用域链查到 a 和 b
//
// 时刻 3(inner 返回):
// ECS: [ GEC, FEC_outer ] ← inner 的 FEC 出栈
//
// 时刻 4(outer 返回):
// ECS: [ GEC ] ← outer 的 FEC 出栈
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关键——出栈 ≠ 销毁:FEC 出栈时,ECS 释放了执行上下文的栈帧。但环境记录中对应的 V8 Context 对象(见 §7.1)是分配在堆上的——如果没有任何闭包引用它,GC 会在下一个周期回收;如果有闭包引用了它(如 inner 被 return 出去了),Context 被标记为"可达"→不被回收→变量继续存活。这就是"栈上执行、堆上存活"。
这个栈堆解耦是 JS 闭包区别于 C/C++ 局部变量的根本原因——在 C++ 中返回一个栈上局部变量的地址是 UB,在 JS 中返回闭包是惯用法。
# 4. 词法与变量环境
# 4.1 let/const
疑惑:ES6 之前只有一个环境记录——为什么要把 let/const 和 var 分到两个环境?
论证:
ES6 引入块作用域(block scope)时面临一个矛盾:var 的行为不能变(向后兼容——var 必须函数作用域提升),但 let/const 必须遵守块作用域。一个环境记录无法同时满足"这个变量在这个 if 块内不可见,但在整个函数内可见"——因为环境记录本身没有"块边界"的概念。
于是 V8 的方案是分层:
┌─────────────────────────────────────────┐
│ 词法环境(Lexical Environment) │
│ ┌─ 块作用域 #1:{ let a = 1 } │ ← 子环境记录
│ │ [[OuterEnv]] → 指向函数级环境 │
│ ├─ 块作用域 #2:{ let b = 2 } │
│ │ [[OuterEnv]] → 指向函数级环境 │
│ ├─ ... │
│ └─ 函数体的 let/const 声明 │ ← 函数级环境记录
│ 每一个"块"都可能有一个子环境记录 │
│ 通过 [[OuterEnv]] 串成作用域链 │
│ │
│ 变量环境(Variable Environment) │
│ └─ var 声明的变量 │
│ 整个函数只有一个变量环境记录 │
│ var 变量直接存在这里面 │
│ 不受任何"块"的约束 │
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
验证行为差异:
function demo() {
{
var x = 1; // 存在函数级的变量环境中(整个函数可见)
let y = 2; // 存在当前块的词法环境子记录中(仅此块内可见)
}
console.log(x); // → 1 ← var 不受块限制
console.log(y); // → ReferenceError: y is not defined
}
2
3
4
5
6
7
8
# 4.2 为什么分两个环境
两种变量有两个互斥的生命周期规则:
| var | let/const | |
|---|---|---|
| 作用域 | 函数作用域 | 块作用域 |
| 提升 | 创建 + 初始化为 undefined | 创建但不初始化(TDZ,值为 TheHole) |
| 运行时环境存储 | 变量环境(整个函数只有一个) | 词法环境(各块可以有自己的子记录) |
| 同名声明 | 允许(后声明的覆盖前面) | 不允许(同一作用域内 SyntaxError) |
两种变量不应该存在同一个数据结构中——因为在同一个块中它们被提升到不同的"高度"、有不同的初始化时机。分环境不是 bug,是让两种语义在同一函数体内和平共处的唯一方案。
# 5. TDZ暂时死区
# 5.1 TheHole
疑惑:let x = x 为什么会抛 ReferenceError?为什么不是 x = undefined = x(死循环赋值自己)?
论证——规范规定了 let 的创建和初始化是分两步的:
let x = x 的执行过程(ECMA-262 §14.3.1 + V8 实现视角):
步骤 A(创建阶段——进入作用域时):
→ 词法环境记录中创建一个绑定 "x"
→ 绑定值 = <uninitialized>(V8 用 TheHole 常量表示)
→ 此时 x 处于 TDZ
步骤 B(执行阶段——执行 let x = x):
→ 先求值右侧的 x ———— ⚡ ReferenceError!
→ 因为 x 的绑定虽然"已创建",但值为 TheHole
→ V8 的 LdaTheHoleCheck 字节码检测到 TheHole → 抛异常
→ 永远不会走到"把右侧的值赋给左侧"这一步
2
3
4
5
6
7
8
9
10
11
12
V8 源码对应——src/ast/variables.cc 和 src/interpreter/interpreter-generator.cc:
; let x = x 生成的 Ignition 字节码(简化):
; 假设 x 在 Context 的 slot 3 中
LdaTheHoleCheck [3] ; 从 Context slot 3 读取绑定值
; 如果值是 TheHole → 抛 ReferenceError
; 否则 → 把值加载到累加器
Star r0 ; 把累加器的值存入寄存器 r0
Ldar r0 ; 从 r0 加载回累加器(准备赋回 x)
StaCurrentContextSlot [3] ; 把累加器的值写入 Context slot 3
2
3
4
5
6
7
8
9
# 5.2 var 提升 v
// var:创建 + 初始化为 undefined(两步都在进入作用域时完成)
console.log(a); // → undefined(不是 ReferenceError!因为 a 被初始化为 undefined)
var a = 1;
// let:创建 + 不初始化(进入作用域时只创建绑定,值为 TheHole)
console.log(b); // → ReferenceError: Cannot access 'b' before initialization
let b = 2;
2
3
4
5
6
7
var 的生命周期(函数作用域内):
┌────────────────────────────────────────────┐
│ 进入作用域:创建绑定 + 初始化为 undefined │
│ ... (在 var 声明行之前,a 已经可访问 = undefined)│
│ var a = 1; ← 初始化表达式执行(赋值 1) │
└────────────────────────────────────────────┘
let 的生命周期(块作用域内):
┌────────────────────────────────────────────┐
│ 进入作用域:创建绑定 + 值为 TheHole │
│ ... (TDZ 区间:访问即抛 ReferenceError) │
│ let b = 2; ← 初始化表达式执行(赋值 2) │
└────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
# 5.3 TDZ 的隐蔽触发
TDZ 不仅影响 let/const,还在三个隐蔽角落触发:
// 隐蔽 1:class 声明的 TDZ——和 let 行为一致
const c = new MyClass(); // → ReferenceError: Cannot access 'MyClass' before initialization
class MyClass {}
// 隐蔽 2:函数参数默认值的 TDZ——从左到右求值导致
function f(x = y, y = 1) { return x + y; }
f(); // → ReferenceError: Cannot access 'y' before initialization
// 原因:参数列表从左到右求值。求值 x = y 时,y 的绑定已创建但值是 TheHole
// 隐蔽 3:super 之前的 this(派生类构造函数中)
class Child extends Parent {
constructor() {
console.log(this); // → ReferenceError: Must call super constructor first
// 在 super() 之前,this 还没有被创建(this 处于"派生类构造函数的 TDZ")
super();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 6. 作用域链机制
# 6.1 [[OuterE
疑惑:当代码写 console.log(x)——引擎怎么知道 x 存在哪个作用域里?它有没有一个"查找路径"?
论证:每个词法环境有一个 [[OuterEnv]] 指针——指向定义该环境时所在的外层词法环境。这串指针构成单向链表:
作用域链 = 从内到外的 [[OuterEnv]] 指针链:
┌─────────────────┐
│ innermost 环境 │ ← 当前执行的函数
│ 有 x? → 没有 │
│ [[OuterEnv]] ───┼──→ ┌─────────────────┐
└─────────────────┘ │ outer 函数环境 │
│ 有 x? → 没有 │
│ [[OuterEnv]] ───┼──→ ┌─────────────────┐
└─────────────────┘ │ 全局环境 │
│ 有 x? → 有! │
│ 返回 x 的值 │
└─────────────────┘
查找过程(标识符解析——ECMA-262 §9.1.2):
1) 从当前执行上下文的词法环境开始
2) 在环境记录中查 x(调用 HasBinding(N))
3) 找到 → 返回 HasBinding 为 true 的绑定的值
4) 没找到 → 沿 [[OuterEnv]] 指针跳到外层环境 → 重复步骤 2~4
5) 达到全局环境仍然没找到 → 返回 undefined?不——如果是 LHS 且在非严格模式,
在全局环境记录中动态创建该绑定;否则抛 ReferenceError
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
关键——[[OuterEnv]] 在定义时锁定,不在调用时决定:
const x = 'global';
function outer() {
const x = 'outer';
function inner() {
console.log(x); // → 'outer'(不是 'global'!)
// inner 的 [[OuterEnv]] → outer 的词法环境(定义时的外层)
// 不会因为"谁调用了 inner"而改变查找路径
}
return inner;
}
const fn = outer(); // fn 是 inner 的引用
fn(); // → 'outer'(词法作用域决定的)
2
3
4
5
6
7
8
9
10
11
12
# 6.2 RHS 查找 v
RHS(Right-Hand Side)查找 —— "读"变量值
例: console.log(x) → "x 的值是什么?"
return x + 1 → RHS
fn(x) → x 和 fn 都是 RHS
行为:沿作用域链查找 → 找到返回值 → 没找到抛 ReferenceError
LHS(Left-Hand Side)查找 —— "写"变量值
例: x = 1 → "我要找到一个环境记录,把它的 x 绑定的值改成 1"
x++ → LHS(找到绑定,修改值)
行为:沿作用域链查找 → 找到赋值 → 没找到?
非严格模式:在全局环境记录中创建一个新绑定(💀 隐式全局变量!)
严格模式:抛 ReferenceError
2
3
4
5
6
7
8
9
10
11
12
为什么区分 RHS/LHS 重要——它解释了 ReferenceError 和 TypeError 的边界:
// RHS 查找失败 → ReferenceError
console.log(undeclaredVar); // → ReferenceError: undeclaredVar is not defined
// RHS 成功但操作非法 → TypeError
const num = 42;
num(); // → TypeError: num is not a function(RHS 找到 num 了,但调用非法)
2
3
4
5
6
# 6.3 with 和 e
with 在执行时临时插入一个额外的词法环境到作用域链的最前端:
const obj = { x: 10, y: 20 };
function demo(val) {
with (obj) {
console.log(x); // → 10——来自 obj.x(而不是某个局部变量!)
console.log(y); // → 20
}
}
2
3
4
5
6
7
这导致 V8 的优化全链条崩溃:
- 解析阶段:编译器无法判断
x是局部变量还是obj的动态属性(obj可以是任意运行时值) - 编译阶段:TurboFan 无法为
x生成"读固定偏移"的内联代码(因为不知道偏移在哪) - 优化阶段:包含
with的函数被标注为 "not optimizable"——连 Sparkplug 基线编译都不会做 - 严格模式下
with被直接禁止——SyntaxError('with' statements are not allowed in strict mode)
eval 同理——因为它的字符串参数可以任意拼接,引擎无法在编译时知道它会把什么变量注入当前作用域。这就是"永远不要用 with 和 eval"的底层原因——不是语法丑,是它们让 V8 的整套优化管线集体辞职。
# 7. 闭包物理实现
# 7.1 V8 的 Con
疑惑:ECMA-262 说"环境记录"——但它在 V8 中真实的物理结构是什么?是栈上的 struct 还是堆上的对象?
论证——规范说"环境记录",V8 用 Context 实现:
┌──────────────────────────────────────────────────────┐
│ V8 的 Context 对象(堆上分配) │
├──────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ │
│ │ Context 对象 │ ← 在 V8 堆上分配的 C++ 对象 │
│ │ ┌──────────────┐│ (src/objects/contexts.h) │
│ │ │ 变量槽位数组 ││ │
│ │ │ slot[0] = 1 ││ ← 每个 let/const/var 变量 │
│ │ │ slot[1] = 2 ││ 对应数组中的一个槽位 │
│ │ │ slot[2] = 3 ││ V8 编译时已确定每个变量的槽位索引│
│ │ └──────────────┘│ │
│ │ │ │
│ │ prev 指针 ───────┼──→ 外层 Context(父作用域) │
│ │ │ 即 [[OuterEnv]] 的物理实现 │
│ └──────────────────┘ │
│ │
│ 关键特性: │
│ → Context 是堆对象——受 GC 管理 │
│ → 即使 FEC 出栈,Context 只要被引用就不会被回收 │
│ → Context.prev 指针链 = 作用域链的物理形态 │
│ → 每个函数调用创建一个新的 Context(函数的"词法环境"), │
│ 但块作用域({ let x = 1 })可能被优化为一个多槽位的 │
│ 扁平 Context(而不是嵌套多个 Context 对象) │
└──────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
规范 vs V8 实现的关键差异:
- ECMA-262 描述"块作用域 = 嵌套的环境记录"(一个块一个 Environment Record)
- V8 优化:把同函数内不冲突的块作用域变量扁平化到同一个 Context 的不同槽位中——省掉 n 个独立的 Context 分配
# 7.2 JSFuncti
┌─────────────────────────────────────────┐
│ function 对象(堆上分配的 JSFunction) │
│ ┌─────────────────────────────────────┐│
│ │ shared_info → 函数的元信息(字节码等) ││
│ │ name → "inner" ││
│ │ context ─────→ 外层 Context 对象 ││ ← 闭包的物理本质!
│ │ ┌───────────────────┐ ││
│ │ │ 变量槽位: │ ││
│ │ │ slot[0] = 1 │ ││ ← 闭包"捕获"的变量
│ │ │ slot[1] = 'x' │ ││ 就在这个 Context 中
│ │ │ slot[2] = obj │ ││
│ │ │ prev → 更外层的 Context│
│ │ └───────────────────┘ ││
│ └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
关键约束:context 指向的是定义时外层函数的 Context——不是"调用时"的任何环境。这是词法作用域在 V8 中的物理体现。
# 7.3 闭包如何让本该释
function makeCounter() {
let count = 0; // ← 存在 makeCounter 的 Context[slot_0]
return function increment() { // ← 这个函数是闭包
return ++count; // 它访问了外层的 count
};
}
const counter = makeCounter();
// 此时:
// makeCounter 的 FEC 已经出栈了(ECS 上释放)
// 但是 makeCounter 的 Context 对象仍在 V8 堆上——
// 因为 counter 函数对象的 .context 字段持有了这个 Context 的引用
// → GC 可达性分析:Context 被 counter引用 → "可达" → 不被回收 → count 活着
console.log(counter()); // → 1
console.log(counter()); // → 2
// count 什么时候死?
counter = null;
// counter 不再引用函数对象 → 函数对象不可达 → 回收
// 函数对象的 .context 字段失去引用 → Context 对象不可达 → 回收
// Context 被回收 → slot[0](count)不再存在于内存中
// → 下一次 GC 的 Mark-Compact 阶段 → 内存被清扫 ✓
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 8. 闭包六大陷阱
# 8.1 循环 + var
// ❌ 经典陷阱
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// → 3 3 3 ← 所有闭包引用的是同一个变量环境中的同一个 i
// 四种解法:
// 解法 1:let——每次迭代独立块作用域(最简洁,ES6+)
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); }
// 解法 2:IIFE 创建快照——ES5 时代的主要方案
for (var i = 0; i < 3; i++) { (function(j) { setTimeout(() => console.log(j), 0); })(i); }
// 解法 3:Array.prototype.forEach——天然独立回调
[0, 1, 2].forEach(i => { setTimeout(() => console.log(i), 0); });
// 解法 4:bind 绑定参数(偏函数——不创建新闭包,用 bind 固化参数)
for (var i = 0; i < 3; i++) { setTimeout(console.log.bind(console, i), 0); }
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for + let 的底层原理——为什么 let 能"记住"每次迭代的值:
ECMA-262 §14.7.4.8 规定:for (let i = 0; i < 3; i++) 的每次迭代创建一个新的词法环境(PerIterationBindings),包含该次迭代专属的 i 绑定。上一次迭代的 i 值被拷贝到新环境的绑定中(通过 CreatePerIterationEnvironment 内部算法)。所以 3 次迭代 = 3 个独立的词法环境 = 每个闭包指向不同的环境,读到不同的 i。
# 8.2 闭包内 this
const obj = {
name: 'obj',
methods: ['a', 'b', 'c'].map(function(name) {
return function() {
console.log(this.name); // ← this 不是 obj!
// 普通函数中,this 取决于调用方式——这个匿名函数被独立调用
// → this = undefined(严格模式)/ window(非严格模式)
};
})
};
// ✅ 修复:箭头函数继承外层 this
// const obj = {
// name: 'obj',
// methods: ['a', 'b', 'c'].map(name => {
// return () => console.log(this.name);
// })
// };
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 8.3 forEach
// ❌ 期望逐个等待,实际:三个请求同时发出
async function fetchAll(urls) {
urls.forEach(async (url) => {
const data = await fetch(url); // ← await 只在回调内等待——回调之间没有"排队"
console.log(data);
});
console.log('done'); // ← 这行跑在最前!
}
// 输出顺序:done → data1 → data2 → data3(交织)
// ✅ 修复:for...of + await
async function fetchAllFixed(urls) {
for (const url of urls) {
const data = await fetch(url);
console.log(data);
}
console.log('done'); // ← 这行在最后
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 8.4 闭包导致的内存泄漏
回到第 1 章的 200MB 泄漏案例——如何用工具精确定位:
Chrome DevTools 内存泄漏排查三步法:
① Memory 面板 → 选择 "Heap snapshot" → 在页面操作前拍第一张快照
② 执行"添加图表→删除图表"循环 5 次 → 拍第二张快照
③ 在第二张快照上选择 "Comparison" 视图 → 选择第一张快照作为基准
→ 按 "Size Delta" 倒序排序 → 看到某个 Context 对象的 Retained Size 增加了 200MB
→ 展开该 Context 的 Retainers → 看到 handleClick → container.addEventListener
→ 定位到忘记 removeEventListener 的代码行
2
3
4
5
6
7
8
修复方案:
// ✅ 修复:销毁时移除事件监听
destroyChart(chart) {
const idx = this.charts.indexOf(chart);
if (idx !== -1) {
this.charts.splice(idx, 1);
// 关键——先移除 listener,解除闭包引用链
chart.container.removeEventListener('click', chart._clickHandler);
chart.destroy();
}
}
// ✅ 更好的设计——存储 handler 引用以便移除
createChart(container) {
const chart = new ChartInstance(container, this.data);
const handler = (e) => { // 箭头函数——更小的捕获半径
const pt = chart.findPoint(e.clientX, e.clientY);
console.log('Clicked:', pt);
};
container.addEventListener('click', handler);
chart._clickHandler = handler; // 保存引用
return chart;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 9. 闭包六大模式
# 9.1 模块模式 + 惰
// 模块模式:用闭包封装私有变量
const counter = (function() {
let count = 0; // ← 私有变量,外部不可直接访问
return {
increment() { return ++count; },
get value() { return count; } // count 只通过预期接口暴露
};
})();
counter.count; // → undefined(count 被闭包完全隔离)
// 惰性初始化:计算一次,缓存结果——避免重复开销
const getConfig = (() => {
let config = null; // ← 缓存在闭包中
return () => {
if (!config) {
config = JSON.parse(localStorage.getItem('appConfig'));
}
return config;
};
})();
getConfig(); // 第一次:读 localStorage、解析 JSON、缓存
getConfig(); // 第二次:直接返回缓存(0ms)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 9.2 柯里化 + pa
// 柯里化:把多参数函数变成单参数链
const multiply = a => b => a * b;
const double = multiply(2); // ← 闭包捕获 a = 2
double(5); // → 10
// memoize——带 TTL 的函数结果缓存
function memoize(fn, ttl = 60000) {
const cache = new Map(); // ← 缓存存在闭包中
return (...args) => {
const key = JSON.stringify(args);
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) return cached.value;
const value = fn(...args);
cache.set(key, { value, time: Date.now() });
return value;
};
}
const memoizedFetch = memoize(url => fetch(url).then(r => r.json()), 5000);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 9.3 回调工厂 + 防
// 防抖:高频调用 → 只在"冷却期"结束后执行一次
function debounce(fn, delay) {
let timer = null; // ← 闭包保存 timer——每次创建 debounce 时独立
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 节流:高频调用 → 每 N 毫秒最多执行一次
function throttle(fn, interval) {
let lastTime = 0; // ← 闭包保存上次执行时间
return function(...args) {
const now = Date.now();
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
const onScroll = throttle(() => console.log('scrolled'), 200);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 9.4 模块作用域——I
// ES5 时代:IIFE 模拟模块作用域
const myLib = (function() {
const privateVar = 'secret'; // ← IIFE 闭包隔离
return { publicFn: () => privateVar };
})();
// ES6+ :ESM 天然有模块作用域——所有不 export 的变量自动私有
// module.js
const privateVar = 'secret';
export const publicFn = () => privateVar;
// import 只能拿到 export 的内容——模块作用域由浏览器/Node 原生保证
2
3
4
5
6
7
8
9
10
11
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的 200MB 闭包泄漏——六个疑问现在能逐条作答:
| 疑问 | 答案 |
|---|---|
| ① 执行上下文和词法环境在 V8 里的物理结构 | 第 2~3 章:EC = 词法环境 + 变量环境 + this + PrivateEnvironment。ECS 是 LIFO 栈——函数调用压栈、返回出栈。但环境记录对应的 Context 对象分配在堆上——出栈≠销毁 |
| ② 为什么分词法环境和变量环境 | 第 4 章:var 函数作用域 vs let/const 块作用域——两类变量有不同的提升和初始化规则,同一个环境记录不能同时表达两种行为 |
| ③ TheHole 哨兵值在字节码层怎么工作 | 第 5.1 节:LdaTheHoleCheck [slot] 字节码——读取绑定值,如果是 TheHole 则抛 ReferenceError |
| ④ [[OuterEnv]] 为什么在定义时锁定 | 第 6.1 节:因为词法作用域的定义——闭包捕获的是"写在哪"的环境,不是"从哪调"的环境。这是 JS 变量查找确定性的根基 |
| ⑤ Context 对象和 JSFunction::context 的关系 | 第 7 章:Context 是堆上分配的变量槽位数组 + prev 指针。JSFunction::context 指向定义时的 Context |
| ⑥ 为什么"缩小捕获半径"能防泄漏 | 第 8.4 节:闭包捕获的是整个词法环境——即使闭包内代码只用了变量 a,词法环境中的变量 b(引用了 200MB 数据)也被锁死。移除 listener = 断开闭包引用链 = GC 可以回收整个 Context |
修复方案(按防御深度递增):
方案 A:在 destroyChart 中移除事件监听(最小改动)
方案 B:把数据从 this.data 改为按需加载(不常驻内存——减少"被锁死"的数据量)
方案 C:使用 WeakRef(ES2021)避免闭包锁死——但仅适用于"数据可被重新生成"的场景
# 10.2 一个闭包的诞生与
时刻 0:定义闭包
function outer(init) {
let count = init; // ← 存在 outer 的 Context[slot_0]
return function inner() { // ← inner.context → outer 的 Context
return ++count; // 访问 Context[slot_0]
};
}
const counter = outer(0);
// outer 的 FEC 入栈 → 执行 → 创建 Context(堆上)
// outer 返回 inner → inner 的 JSFunction::context 指向 outer 的 Context
// outer 的 FEC 出栈(ECS 释放栈帧)——但 Context 仍在堆上(被 inner 引用)
时刻 1:每次调用 counter()
counter(); // → 1
// 创建 counter 的 FEC → 入栈
// 执行 ++count:读 counter.context → Context → slot[0] = 0 → +1 → 写回 slot[0]
// FEC 出栈(ECS 释放)——但 outer 的 Context 不受影响(由 counter.context 持有)
时刻 2:counter 失去最后一个引用
counter = null;
// JSFunction 对象不可达 → GC 标记为白色(待回收)
// JSFunction.context 字段被释放 → outer 的 Context 失去最后一个引用
// → 下一次新生代 Scavenge 或老生代 Mark-Compact → 回收
时刻 3:GC 完成回收
→ outer 的 Context 对象被清扫 → slot[0](count 变量)不再存在于内存中
→ 整个闭包链完全从堆上消失
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 10.3 设计哲学回扣
哲学一·「定义时决定、调用时无关」——词法作用域是 JS 的隐性宪法
[[OuterEnv]] 在定义时锁定而不是调用时决定——这看似是一个"引擎实现细节",实则定义了 JS 最重要的隐性契约:一段代码看得见什么变量,由它写在哪个位置决定,不由运行时的调用栈决定。这个契约让代码的行为具有确定性——你不必用大脑模拟整个调用链来猜测某个变量从哪来。with 和 eval 之所以被废弃,正是因为它们打破了这个契约——让作用域从"编译时固定"变成了"运行时可变"。
哲学二·「栈上执行、堆上存活」——执行上下文和环境记录的生命周期解耦是闭包的物理前提
ECS 栈 LIFO、函数返回即出栈——但出栈不等同于销毁。因为 Context 对象分配在堆上,受 GC 管理。一个函数的执行上下文可以"死"(出栈),但它的环境可以"活"(被闭包引用时不被 GC 回收)。这种栈堆解耦让 JS 闭包成为可能——在 C/C++ 中返回一个栈上的局部变量地址是 UB,在 JS 中返回闭包是惯用法。JS 用 GC 的"延迟回收",换来了函数"返回后仍能记住状态"的能力。
哲学三·「闭包不是魔法——是指针+GC」的实现诚实性
很多教程把闭包描述成"函数记住了它的出生环境"。这听起来像一种超能力。实际上:闭包就是一个堆上的函数对象,内部存了一个指向外层 Context 的指针。变量在 counter() 中"持续存在"并不是因为它们被冻结在某个神秘空间里——而是因为那个 Context 对象一直没有被 GC 回收。理解这一点后,闭包的所有"怪异"行为(如循环中 var 的陷阱、200MB 泄漏)都可以用"哪个闭包指向哪个 Context"这一个原理解释。
哲学四·「TDZ 是设计者从"允许undefined"的草原到"明确错误"的城墙——一种 fail-fast 的哲学实践」
ES6 的设计者可以选择让 let x 在声明行之前像 var 一样返回 undefined——技术上完全可以做到。但他们选择了"抛 ReferenceError"。为什么?因为 undefined 意味着"可能是个 bug,也可能是有意为之"——歧义是 bug 的温床。TDZ 把这个歧义变成了"明确错误"——如果你在声明前访问了 let 变量,没有歧义,就是 bug。这和 C++ 专栏第 01 篇的"守护页"共享同一个设计信仰:"立刻崩溃"比"静默错误"珍贵一千倍。在 JS 这名"宽容的语言"中,TDZ 是极少数的严格守卫——它用运行时 exception 告诉开发者:"你在声明之前访问了变量——这是 bug,修它。"
# 10.4 速查表
执行上下文三类型对比:
| 类型 | 触发时机 | 环境记录类型 | this | 销毁时机 |
|---|---|---|---|---|
| 全局(GEC) | 脚本首次执行 | 混合型(Object ER + Declarative ER) | 全局对象 | 程序退出 |
| 函数(FEC) | 每次函数调用 | 声明式 | 按调用规则 | 返回后(除非闭包引用) |
| eval | eval() 调用 | 取决于 strict mode | 继承调用者 | eval 执行完毕 |
闭包六陷阱速查:
| 陷阱 | 根因 | 修复 |
|---|---|---|
| 循环 + var | 所有闭包引用同一个环境中的同一个变量 | let / IIFE / forEach / bind |
| 闭包内 this | 普通函数 this 取决于调用方式 | 箭头函数 |
| forEach + async | forEach 不等待回调完成 | for…of + await |
| 闭包泄漏(大对象) | 闭包锁死整个词法环境——即使只用了其中一个小变量 | 缩小捕获半径 / 自卸载 listener |
| 闭包内 var 提升 | var 在闭包内仍被提升到函数顶部 | 用 let/const |
| 闭包引用过期值 | Context 里的引用类型被外部修改 | 需要最新值时用 getter 或在闭包内深拷贝 |
TDZ 触发条件速查:
| 场景 | 触发? | 说明 |
|---|---|---|
let x; console.log(x)(在声明前) | ✅ | x 的绑定值 = TheHole |
var x; console.log(x)(在声明前) | ❌ | var 初始化 = undefined |
f(x=y, y=1); f() | ✅ | 参数从左到右求值——y 在 x 的默认值中被读到时是 TheHole |
new Foo(); class Foo {} | ✅ | class 和 let 一样有 TDZ |
super() 之前的 this | ✅ | 派生类构造函数——this 在 super 返回前处于 TDZ |
闭包六模式速查:
| 模式 | 闭包用途 | 典型写法 |
|---|---|---|
| 模块模式 | 封装私有变量 | (() => { let secret; return { getSecret }; })() |
| 惰性初始化 | 首次计算后缓存 | let cache; return () => cache ??= compute() |
| 柯里化 | 参数逐步固化 | const double = multiply(2) |
| memoize | 函数结果缓存(带 TTL) | memoize(fetch, 5000) |
| 防抖节流 | 调用频率控制 | debounce(onInput, 300) |
| 回调工厂 | 生成带上下文的回调 | items.map(item => () => handle(item)) |
下一步:闭包让函数记住了外部环境。但函数内部还有一个更诡的话题——this。为什么 obj.method() 和 const fn = obj.method; fn() 的 this 不一样?进入 05.this 四规则与函数组合。