编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
        • 1. 案例与疑问引入
          • 1.1 一个事件监听器让
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 执行上下文详解
          • 2.2 为什么需要三种执
        • 3. 执行上下文集
          • 3.1 全局执行上下文
          • 3.2 函数执行上下文
          • 3.3 eval 执行上
          • 3.4 执行上下文栈的压
        • 4. 词法与变量环境
          • 4.1 let/const
          • 4.2 为什么分两个环境
        • 5. TDZ暂时死区
          • 5.1 TheHole
          • 5.2 var 提升 v
          • 5.3 TDZ 的隐蔽触发
        • 6. 作用域链机制
          • 6.1 [[OuterE
          • 6.2 RHS 查找 v
          • 6.3 with 和 e
        • 7. 闭包物理实现
          • 7.1 V8 的 Con
          • 7.2 JSFuncti
          • 7.3 闭包如何让本该释
        • 8. 闭包六大陷阱
          • 8.1 循环 + var
          • 8.2 闭包内 this
          • 8.3 forEach
          • 8.4 闭包导致的内存泄漏
        • 9. 闭包六大模式
          • 9.1 模块模式 + 惰
          • 9.2 柯里化 + pa
          • 9.3 回调工厂 + 防
          • 9.4 模块作用域——I
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个闭包的诞生与
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

作用域链闭包原理

# 04.作用域链与闭包深度

📍 上接第 03 篇《类型隐式转换精算》。值是什么类型搞清楚了。本文回答:当代码执行时,JS 引擎如何「记住」这些值——作用域链怎么查?闭包在 V8 里怎么存?循环里的 var 和 let 为什么表现完全不同?一个闭包从创建到被 GC 回收,中间经历了什么?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 一个事件监听器让
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 执行上下文详解
    • 2.2 为什么需要三种执
  • 3. 执行上下文集
    • 3.1 全局执行上下文
    • 3.2 函数执行上下文
    • 3.3 eval 执行上
    • 3.4 执行上下文栈的压
  • 4. 词法与变量环境
    • 4.1 let/const
    • 4.2 为什么分两个环境
  • 5. TDZ暂时死区
    • 5.1 TheHole
    • 5.2 var 提升 v
    • 5.3 TDZ 的隐蔽触发
  • 6. 作用域链机制
    • [6.1 [OuterE
    • 6.2 RHS 查找 v
    • 6.3 with 和 e
  • 7. 闭包物理实现
    • 7.1 V8 的 Con
    • 7.2 JSFuncti
    • 7.3 闭包如何让本该释
  • 8. 闭包六大陷阱
    • 8.1 循环 + var
    • 8.2 闭包内 this
    • 8.3 forEach
    • 8.4 闭包导致的内存泄漏
  • 9. 闭包六大模式
    • 9.1 模块模式 + 惰
    • 9.2 柯里化 + pa
    • 9.3 回调工厂 + 防
    • 9.4 模块作用域——I
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个闭包的诞生与
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 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 回收!
    }
  }
}
1
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 by handleClick → 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 章
1
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 章)──→ 案例彻底剖开 + 哲学四条 + 速查表
1
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 需要实现     │
│  "闭包捕获变量后变量不能随栈销毁"——只有堆对象能做到。               │
│                                                              │
└──────────────────────────────────────────────────────────────┘
1
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)         │
└─────────────────────────────────────────────────┘
1
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 在声明式环境记录中
1
2
3
4
5

# 3.2 函数执行上下文

Function Execution Context (FEC):
┌─────────────────────────────────────────────────┐
│ Function Lexical Environment                     │
│  ├ 环境记录:                                     │
│  │   arguments 对象(非箭头函数)                   │
│  │   参数: a, b, c(从调用方传入)                  │
│  │   函数体内声明的 let/const 变量                 │
│  │   内部函数声明(会被整个提升)                    │
│  │                                              │
│  └ [[OuterEnv]] → 定义该函数时的外部词法环境        │
│      ← 注意:不是"调用时"的环境,是"定义时"的环境!   │
│      ← 这是词法作用域的物理基础——也是闭包的根基        │
│                                                 │
│ this → 按调用方式决定(new/显式/隐式/默认)          │
└─────────────────────────────────────────────────┘
1
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 内、不是全局)
1
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 有独立环境)
}
1
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 出栈
1
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 变量直接存在这里面              │
│        不受任何"块"的约束                  │
└─────────────────────────────────────────┘
1
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
}
1
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 → 抛异常
  → 永远不会走到"把右侧的值赋给左侧"这一步
1
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
1
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;
1
2
3
4
5
6
7
var 的生命周期(函数作用域内):
  ┌────────────────────────────────────────────┐
  │ 进入作用域:创建绑定 + 初始化为 undefined        │
  │  ... (在 var 声明行之前,a 已经可访问 = undefined)│
  │ var a = 1;  ← 初始化表达式执行(赋值 1)        │
  └────────────────────────────────────────────┘

let 的生命周期(块作用域内):
  ┌────────────────────────────────────────────┐
  │ 进入作用域:创建绑定 + 值为 TheHole            │
  │  ... (TDZ 区间:访问即抛 ReferenceError)     │
  │ let b = 2;  ← 初始化表达式执行(赋值 2)        │
  └────────────────────────────────────────────┘
1
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();
  }
}
1
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
1
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'(词法作用域决定的)
1
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
1
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 了,但调用非法)
1
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
  }
}
1
2
3
4
5
6
7

这导致 V8 的优化全链条崩溃:

  1. 解析阶段:编译器无法判断 x 是局部变量还是 obj 的动态属性(obj 可以是任意运行时值)
  2. 编译阶段:TurboFan 无法为 x 生成"读固定偏移"的内联代码(因为不知道偏移在哪)
  3. 优化阶段:包含 with 的函数被标注为 "not optimizable"——连 Sparkplug 基线编译都不会做
  4. 严格模式下 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 对象)             │
└──────────────────────────────────────────────────────┘
1
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│
│  │               └───────────────────┘ ││
│  └─────────────────────────────────────┘│
└─────────────────────────────────────────┘
1
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 阶段 → 内存被清扫 ✓
1
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); }
1
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);
//        })
//      };
1
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');  // ← 这行在最后
}
1
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 的代码行
1
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;
}
1
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)
1
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);
1
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);
1
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 原生保证
1
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 变量)不再存在于内存中
  → 整个闭包链完全从堆上消失
1
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 四规则与函数组合。

上次更新: 2026/06/16, 12:36:20
类型隐式转换精算
函数绑定规则组合

← 类型隐式转换精算 函数绑定规则组合→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式