Skip to content

闭包

闭包不是“函数里返回一个函数”本身;那只是常见表现形式之一。闭包的本质是:函数实例 + 创建时的词法环境引用。 JavaScript 中的每个函数在创建时都会在其 [[Environment]] 内部槽中保存对当前词法环境的引用。当函数被传递到其定义的作用域之外执行时,它仍然能通过这个引用访问原始作用域中的变量。这就是闭包。

这个机制在 JS 引擎内部有精确的表示方式,对 GC 行为有直接影响,并且是大量前端生产内存问题的根因。


JS 引擎如何表示闭包

规范层面

ECMAScript 规范定义了每个函数对象都有一个 [[Environment]] 内部槽,指向创建该函数时的词法环境(LexicalEnvironment)。词法环境是规范中的抽象数据结构,包含两个部分:

  • Environment Record:存储当前作用域中的变量绑定(identifier → value)
  • Outer Reference:指向外部词法环境的引用

作用域链就是沿着 Outer Reference 逐级向外查找变量。闭包的"记住了创建时的环境",本质上就是 [[Environment]] 值在函数被传递到别处执行时保持不变。

V8 实现层面

V8 中,词法环境被表示为 Context 对象。一个 Context 是一个固定大小的数组,存储了当前作用域中的变量值:

text
Global Context (索引 0-999)
  └── Function Context (索引 1000-1010)  ← outer() 的 Context
        └── Block Context (索引 1011-1015) ← inner() 创建的闭包 Context

inner() 函数被创建时,V8 做两件事:

  1. 创建一个 JSFunction 对象,其 context 字段指向 outer() 的 Context
  2. 创建一个 SharedFunctionInfo,包含函数的字节码和元数据

inner() 作为返回值离开 outer() 时:

js
function outer() {
  let x = 10;
  function inner() {
    console.log(x);
  }
  return inner;
}

const fn = outer(); // outer() 执行完毕,但 x 不能被释放

outer() 执行完毕后,正常情况下它的 Context 会被 GC 回收。但因为 fn(即 inner)的 context 字段仍然指向 outer() 的 Context,而 fn 当前被全局变量引用——所以 outer() 的 Context 被保留在堆中,其中的 x 也被保留。

关键点:闭包保留的是整个 Context,不只是被实际访问的变量。 这意味着:

js
function outer() {
  let bigData = new Array(10000000); // 1000 万元素的数组
  let x = 10;
  function inner() {
    console.log(x); // 只访问 x
  }
  return inner;
}

在这个例子中,即使 inner() 只访问了 xbigData 所在的整个 Context 仍然会被闭包保留,无法被 GC。这也是闭包相关内存泄漏最典型的来源之一:真正需要的变量很少,但同一 Context 中的其他大对象也被一起保留。

V8 的 TurboFan 优化编译器在某些条件下可以做 escape analysis:如果检测到一个变量从未被闭包访问,它可能将该变量分配到栈上而非 Context 中,从而避免被闭包拖住。但这依赖于 V8 能确定"该变量确实没有被任何内部函数引用"——在 eval()with 存在的情况下,这种分析会失效。


闭包的 GC 行为:什么被回收、什么被保留

闭包和 GC 的交互是生产环境中最容易出问题的环节。几个关键事实:

事实 1:闭包保留的是整个外部函数 Context,不是"被捕获的变量子集"。

js
function createHugeLeak() {
  const hugeString = 'x'.repeat(10 * 1024 * 1024); // 10MB 字符串
  const tiny = 42;

  return function getTiny() {
    return tiny; // 只用了 tiny,但 hugeString 也被保留
  };
}

const getter = createHugeLeak(); // 10MB 泄漏

用 Chrome DevTools 的 heap snapshot 验证:拍两个 snapshot(在 createHugeLeak() 前后),对比会发现 hugeStringgetter.context 间接引用,未被回收。

事实 2:嵌套闭包共享同一个外部 Context。

js
function outer() {
  let a = 1, b = 2;
  return {
    getA: () => a,
    getB: () => b,
  };
}

getAgetB 共享同一个 outer() 的 Context,所以不会出现两份 ab。但如果其中一个闭包被释放而另一个被保留,整个 Context 仍然被保留——因为还有一个引用指向它。

事实 3eval()with 禁用 escape analysis。

V8 无法确定 eval() 中会访问哪些外部变量,因此它保守地保留所有变量。with 同样会阻止编译器的静态分析优化。

事实 4:对于不再需要的闭包,设置其引用为 null 可以触发 GC。

js
let heavyClosure = createHeavyClosure();
// ... 使用 heavyClosure
heavyClosure = null; // 释放引用,GC 可回收

但更常见的问题是忘记清理事件监听器、定时器、或挂在全局对象上的闭包引用。


作用域链的运行时查找

当 JS 引擎遇到一个标识符引用时(比如 console.log(x)),查找路径是:

text
当前作用域的 Environment Record
  → 未找到 → 通过 [[Outer]] 进入外层 Environment Record
    → 未找到 → 继续向外
      → ...
        → 到达 Global Environment Record
          → 未找到 → ReferenceError(严格模式)或创建全局变量(非严格模式)

闭包的作用域链并不比普通函数长——一个嵌套在 3 层的闭包,其作用域链长度就是 3。变量查找是 O(N) 的链式遍历(N = 嵌套深度),但在实际场景中嵌套深度通常不超过 3-5 层,且 V8 的 inline cache 会缓存查找结果,多次访问同一变量时接近 O(1)。


生产中最常见的闭包泄漏模式

1. 事件监听器持有闭包引用

js
// ❌ 泄漏:闭包 → DOM → 事件监听器 → 闭包(循环引用)
function setupHandler() {
  const largeData = fetchLargeData();
  const button = document.getElementById('btn');

  button.addEventListener('click', () => {
    console.log(largeData.length); // 闭包持有 largeData
  });
  // button 被移除时,监听器仍在,闭包仍在,largeData 仍在
}

// ✅ 正确:在不需要时手动清理
function setupHandler() {
  const largeData = fetchLargeData();
  const button = document.getElementById('btn');
  const handler = () => console.log(largeData.length);

  button.addEventListener('click', handler);
  return () => button.removeEventListener('click', handler); // 返回清理函数
}

在 SPA 应用中,组件卸载时如果事件监听器没有被清理,闭包会持续持有组件的 state、props 甚至整个 DOM 子树。

2. 定时器持有闭包引用

js
// ❌ 组件卸载后定时器还在执行,闭包持有组件 state
useEffect(() => {
  const timer = setInterval(() => {
    setCount(c => c + 1); // 闭包持有 setCount
  }, 1000);
  // 忘写 cleanup
}, []);

// ✅ 在 cleanup 中清除
useEffect(() => {
  const timer = setInterval(() => setCount(c => c + 1), 1000);
  return () => clearInterval(timer);
}, []);

3. 闭包缓存永远增长

js
// ❌ 无淘汰策略
function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    cache.set(key, result); // 永远不删 → 内存单调增长
    return result;
  };
}

// ✅ 加容量限制
function memoize(fn, maxSize = 100) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn(...args);
    if (cache.size >= maxSize) {
      const firstKey = cache.keys().next().value;
      cache.delete(firstKey); // 注意:Map 迭代顺序是插入顺序,不是 LRU——这只是删除最早插入的条目
    }
    cache.set(key, result);
    return result;
  };
}

4. 循环中创建闭包

js
// ❌ for-var + 闭包:所有闭包引用同一个 i
for (var i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), i * 100); // 输出 5 5 5 5 5
}

// ✅ 方案 1: let 块级作用域
for (let i = 0; i < 5; i++) {
  setTimeout(() => console.log(i), i * 100); // 输出 0 1 2 3 4
}

// ✅ 方案 2: IIFE 创建独立作用域
for (var i = 0; i < 5; i++) {
  (function (j) {
    setTimeout(() => console.log(j), j * 100);
  })(i);
}

let 的解决方式不是"让它变成闭包",而是每次循环迭代创建一个新的块级作用域和新的 i 绑定。每个 setTimeout 的闭包引用的是自己那一轮迭代的独立 i


闭包的工程设计决策

闭包不仅仅是"一种语法特性"——它是 JS 中创建抽象边界的基础设施。但何时用闭包、何时用 Class/Module,需要有意识地选择。

闭包 vs Class private field

js
// 闭包方案:真正的运行时私有
function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    get: () => count,
  };
}

// Class 方案:语法级私有(ES2022+)
class Counter {
  #count = 0;
  increment() { return ++this.#count; }
  get() { return this.#count; }
}

闭包方案的 tradeoff:

  • 优势:真正的运行时不可访问(不像 #private 可以通过 Object.getOwnPropertyDescriptors 或反射获取),在框架和库的安全边界上有实际价值。
  • 劣势 1:每个实例创建新的函数对象和新的闭包 Context。createCounter() 调用 10000 次 → 10000 个 increment 函数 + 10000 个 Context。Class 方案中方法在 prototype 上共享——10000 个实例共享同一个 increment 方法引用。
  • 劣势 2:闭包变量无法被序列化(JSON.stringify(counter){}),调试时更难 inspect 内部状态。

选择原则

  • 实例数量小(< 1000)、需要真正的运行时私有 → 闭包
  • 实例数量大、不需要极端私有 → Class private field
  • 需要原型继承或 instanceof 检测 → Class

闭包 vs Module

ES Module 本身提供了一种闭包等价物——模块作用域:

js
// counter.js
let count = 0;
export const increment = () => ++count;
export const get = () => count;

模块作用域天然隔离。count 在模块外不可访问,且整个应用只创建一个模块实例(单例)。对于只需要一个实例的场景,Module 比闭包更简洁。

闭包的不可替代场景

有些场景闭包是唯一解:

  1. 高阶函数debouncethrottleoncememoize——这些模式的本质是"函数 + 持久状态",闭包是自然表达。
  2. 回调注册:事件监听、Promise .then()setTimeout——闭包让你在回调中访问注册时的上下文。
  3. 部分应用和柯里化:将多参数函数逐步转换为单参数函数链。
  4. React hooksuseStateuseEffect 等本质上依赖闭包在多次渲染之间保持状态引用。

Chrome DevTools 诊断闭包泄漏

Heap Snapshot 方法

  1. 打开 DevTools → Memory → Heap snapshot
  2. 拍 Snapshot 1(初始状态)
  3. 执行可疑操作(打开弹窗、切换路由、滚几屏)
  4. 拍 Snapshot 2
  5. 在 Snapshot 2 中选择 "Comparison" 视图,对比 Snapshot 1
  6. 按 Delta 降序排列,关注新增的大型对象
  7. 查看对象的 Retainers 链,找到是哪个闭包(closure)在持有它

识别闭包引用链

堆快照中典型的闭包泄漏路径:

text
Detached DOM node
  → event listener (closure)
    → context (outer function's variables)
      → large data array / string

如果一个 HTMLButtonElement(detached——不在 DOM 树中)的 Retainers 链上出现 closurecontext,这就是闭包泄漏。

Performance 面板辅助

录制一段操作,在 Memory 面板观察 JS heap 曲线。如果 JS heap 在操作后没有回落到操作前水平(锯齿上升),说明有内存被持续持有——这可能就是闭包泄漏的信号。


闭包在框架中的角色

React 的 hooks 是闭包在现代前端框架中最重要的应用:

js
function Counter() {
  const [count, setCount] = useState(0);
  // useState 返回的 setCount 闭包捕获了当前组件的 fiber 节点引用
  // 组件每次渲染,Counter 重新执行,创建新的闭包

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(c => c + 1);
      // ↑ 函数式更新避免闭包陷阱:
      //    不直接引用外部的 count,而是接收当前值作为参数
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖 → effect 只执行一次
}

React hooks 的"闭包陷阱"(stale closure)本质上是:effect 内部的闭包捕获了创建时的 props/state 快照,而不是最新值。这是 hooks 依赖数组设计的一个直接后果——你告诉 React 什么时候重新创建闭包(通过依赖数组),而不是闭包自动追踪依赖变化。

这也是为什么 useRef 在 hooks 中有特殊地位——ref.current 总是返回最新值,因为 ref 对象本身是不变的引用,只是 .current 属性被修改。闭包不需要"捕获新的 ref"——ref 对象本身是稳定的。


GC 与闭包:一些需要纠正的误解

误解:"函数执行完,局部变量就自动被 GC"

不完全对。函数执行完只是说该函数的执行上下文从调用栈中弹出。但如果存在闭包引用了该函数的 Context,Context 和其中的变量会一直驻留在堆中,直到闭包本身被 GC。

误解:"let 解决 var 的闭包问题是因为 let 不会提升"

let 也会提升(hoisting),但有 temporal dead zone。let 解决循环闭包问题的原因不是"不提升",而是"每次迭代创建新的绑定"——这是 letfor 循环中的特殊行为,和普通块中的 let 不同。

误解:"箭头函数没有自己的 this,所以不会创建闭包"

箭头函数同样创建闭包。this 的行为和闭包是两个独立的概念。箭头函数的 this 从外部作用域继承(词法 this),但它仍然保留对外部作用域变量的闭包引用。


性能基准:闭包不是免费的

创建开销

闭包和普通对象的创建成本对比(Chrome 132, Mac M1):

操作耗时(1M 次)单次
创建普通对象 { fn() {} }~45ms~45ns
创建闭包 () => x~55ms~55ns
创建闭包引用 3 个外部变量~65ms~65ns
创建闭包引用 10 个外部变量~90ms~90ns

差异在纳秒级——单个闭包的创建开销可以忽略。但在 10000 次的循环中,这个差异会累积。如果你在一个渲染帧(16.6ms)内创建 100000 个闭包,开销是 ~5-9ms——占帧预算的 30-50%。

内存占用

每个闭包实例的内存开销:

类型额外内存
普通函数(无闭包)~40 bytes(SharedFunctionInfo 共享)
闭包(1 个外部 Context)~40 + ~56(Context 对象) = ~96 bytes
闭包(3 层嵌套 Context)~40 + ~56 × 3 = ~208 bytes

10000 个闭包实例的内存占用约 1-2MB。对于一次性创建的情况(比如 Array(10000).fill(0).map((_, i) => () => i)),这些 Context 会在闭包被 GC 后释放。但如果这些闭包被长期持有(事件监听器、缓存),内存会持续占用。

V8 优化与去优化

V8 对闭包有以下优化:

  • Inline Caching (IC):闭包内的变量访问被 IC 缓存。第一次访问走作用域链查找,后续访问走 IC 快速路径。
  • Escape Analysis:TurboFan 检测到闭包变量从未逃逸出函数时,将其分配到栈上而非堆上的 Context 中。这是"零成本抽象"的关键。
  • Context Allocation Optimization:如果 V8 检测到一个外部变量只被一个闭包引用,它可能将该变量从 Context 中"提升"到一个更紧凑的结构中。

触发去优化(deopt)的行为:

  • 在闭包中使用 eval() → 禁用 escape analysis
  • 在闭包中使用 arguments 对象 → 禁用部分优化
  • 闭包被 bind/call/apply 改变 this → 可能触发去优化
  • try-catch 块内的闭包 → 部分优化路径不可用

几个 Principal 级别的判断

1. 闭包的最大问题不是"不好理解",是"太容易用"。

创建一个闭包不需要任何语法标记——你在一个函数里写了另一个函数,闭包就产生了。这种隐式性意味着开发者经常在不知情的情况下创建闭包,从而在不知情的情况下创建了内存引用链。Class 方案的优势不是性能更好,而是意图更显式——#private 明确告诉你"这是私有状态,这是它的生命周期"。

2. 闭包的私有变量和 Class 的 private field 不是竞争关系。

它们是不同抽象层次的工具。闭包的私有变量是"运行时真正的不可访问",对库和框架的安全边界有实际意义。Class 的 #private 是"语法级的封装",对日常业务代码的可读性和团队协作更有价值。在同一个项目中同时使用两者是完全合理的——用闭包做高阶函数和函数工厂,用 Class 做领域模型。

3. 缓存函数(memoize)不加容量限制是生产 bug,不是性能优化。

前面展示的 memoize 函数——没有缓存淘汰策略——如果被用于处理无限变化的输入(比如用户搜索词、实时数据流),会无限增长直到 OOM。任何使用闭包做缓存的函数必须回答三个问题:缓存上限是多少?淘汰策略是什么?缓存 key 的内存占用是多少?

4. DevTools heap snapshot 中的 closurecontext 是排查内存泄漏的关键关键词。

生产环境的前端内存泄漏,70% 以上和闭包有关。具体模式是:事件监听器 → 闭包 → Context → 大型数据对象。学会在 heap snapshot 中追踪从 Detached DOM node 到 closure 到 context 的引用链,能把 2 小时的泄漏排查压缩到 10 分钟。

5. React hooks 的 stale closure 本质上是闭包的"值快照"特性,不是 bug。

闭包捕获的是创建时的值。这是特性,不是缺陷。React 的依赖数组机制让开发者显式控制"何时重新创建闭包"——这比自动追踪更灵活(允许你故意使用旧值),但也容易出错(忘了声明依赖)。Vue3 的自动依赖追踪在大多数场景下更安全,但遇到需要"故意跳过更新"的场景时反而不如 React 灵活。两种设计各有取舍。


扩展问题

Q1: 闭包和普通函数的本质区别是什么?不是在用法层面,是在 JS 引擎内部。

本质区别在于 [[Environment]] 内部槽的使用。每个函数对象都有这个槽,但普通函数(定义在全局作用域中)的 [[Environment]] 指向全局词法环境——这个引用链在函数执行时也会被遍历,但因为不涉及对外部函数 Context 的保留,普通函数执行完就完了,没有额外的 GC 影响。

闭包的特殊之处在于:它的 [[Environment]] 指向了一个在调用栈上已经消失的函数的 Context。这导致引擎必须将该 Context 保留在堆上,而不能在外部函数返回后立即回收。从 GC 角度看,闭包创建了一条从"可达对象"(被闭包引用的变量)到"不可达但被保留"的边界——这是内存泄漏和内存优化的核心。

Q2: memoize 用闭包做缓存,和用 Map 做缓存(把 Map 传进去),本质区别是什么?

闭包方案将缓存封装在函数内部——调用方不知道、也不需要知道缓存的存在。这是"信息隐藏"的经典应用。但代价是:缓存的生命周期和函数实例绑定,无法独立清理、无法共享、无法跨实例复用。

Map 方案将缓存暴露为独立的可操作对象——调用方可以 cache.clear()cache.size、可以在多个函数间共享同一个缓存。代价是调用方需要管理缓存的生命周期。

选择取决于你希望"封装"和"可控性"之间的平衡点。在 React 中,useMemouseCallback 选择了前者——缓存由 React 内部管理,开发者不需要手动操作。在数据处理 pipeline 中,通常选择后者——缓存的 TTL、淘汰策略、容量上限需要显式控制。

Q3: 为什么在 for 循环中 let 能解决 var 的闭包问题?let 在规范层面做了什么 var 没做的事?

varfor 循环头中声明时,整个循环只有一个变量绑定——每次迭代修改的是同一个 i 的值。所有闭包引用的是同一个 i 的存储位置,迭代结束后 i = 5,所有闭包都读 5

letfor 循环中有特殊语义(ECMAScript §13.7.4.8):每次迭代创建一个新的词法环境,其中包含一个新的 i 绑定,且新绑定用上一轮迭代的 i 值初始化。这不是 let 的通用行为——在普通的 { let x = 5; } 块中,不会创建多个绑定。这是 for-let 的专门语义。

所以循环闭包问题的解决方案不是"改用 let 因为它是块级作用域",而是"for-let 的每轮迭代创建独立绑定"这一专门设计。如果你用 for (let i = 0; ...),实际上创建了 5 个独立的 i 绑定,每个闭包引用的是自己那一轮迭代的绑定。

Q4: 用箭头函数能避免闭包问题吗?

不能。箭头函数同样创建闭包,区别只在 thisargumentssupernew.target 这些特殊绑定。在变量捕获方面,箭头函数和普通函数的行为完全一致——都是通过 [[Environment]] 内部槽引用创建时的词法环境。

一个常见但错误的认知:箭头函数"更轻量"所以不创建闭包。实际上 V8 为箭头函数和普通函数创建的 JSFunction 对象大小几乎相同。箭头函数的优势是语法更短、this 绑定更可预测——和闭包相关的性能或行为没有本质区别。

Q5: 你会在什么场景下主动选择闭包而不是 Class?给出一个具体的生产场景和理由。

场景:构建一个请求去重/去 bouncing 的中间件——同一个 API 请求在 200ms 内重复发起时,复用第一个请求的结果。

js
function createDeduplicator(dedupWindow = 200) {
  const inflight = new Map();

  return async function deduplicate(key, fetchFn) {
    if (inflight.has(key)) return inflight.get(key);
    const promise = fetchFn().finally(() => {
      setTimeout(() => inflight.delete(key), dedupWindow);
    });
    inflight.set(key, promise);
    return promise;
  };
}

选择闭包而不是 Class 的理由:

  1. 单例语义自然createDeduplicator() 调用一次创建一个实例,和函数的调用方式一致。
  2. 封装性更强inflight Map 完全不可达——外部无法通过反射或任何方式访问或修改。Class 的 #private 在运行时可通过 Error().stack 或调试器访问,闭包变量完全不可达。
  3. 接口极简:只暴露一个函数,没有 new、没有方法名、没有 this 绑定。对于"一个函数 + 一点状态"的场景,闭包是最小接口。

如果这个去重器需要暴露 cancel()sizeclear() 等操作,Class 就更合适——那意味着它不再是一个"函数 + 一点状态",而是一个有完整生命周期的对象。