人人都会AI编程

3.5 闭包的底层原理

更新时间:2026-07-11

在 3.4 节中我们讨论了作用域与作用域链,它们为闭包提供了土壤。闭包是 JavaScript 中最重要也最容易被误解的概念之一。理解它的底层原理,不仅能解释许多代码行为,也是写出高效、可维护 JavaScript 的基础。

3.5.1 闭包的形成条件与本质

闭包的定义
闭包是指一个函数能够记住并访问其定义时的词法作用域,即使这个函数在其词法作用域之外执行。

形成条件

  1. 函数嵌套:一个函数内部定义了另一个函数。
  2. 内部函数引用了外部函数的变量。
  3. 内部函数被返回或以其他方式传递到外部作用域执行。
function outer() {
  let count = 0;           // 外部函数的变量
  return function inner() {
    count++;               // inner 引用了 count
    console.log(count);
  };
}
const counter = outer();   // inner 被返回到外部
counter(); // 1
counter(); // 2

innerouter 执行完毕后仍然能够访问 count,这就构成了闭包。

本质:作用域链的保留
当函数创建时,它的内部属性 [[Environment]](在早期规范中称为 [[Scope]])会记录下创建该函数时的作用域链。执行 outer() 时,会产生一个包含 count 变量的函数执行上下文。inner 在创建时,其 [[Environment]] 指向这个上下文中的词法环境(包含 count)。
正常情况下,outer 执行结束后,其执行上下文会从调用栈弹出,如果没有其他引用,其词法环境会被垃圾回收。但 inner 保留了对其词法环境的引用,因此垃圾回收器不会回收这个环境,变量 count 得以存活下来。闭包本质上就是函数 + 其定义时所在词法环境的组合

3.5.2 闭包的内存表现与生命周期

内存结构
每个函数对象都有一个内部引用指向创建它的词法环境,而这个词法环境又可能包含对外部词法环境的引用(作用域链)。形成闭包后,这些词法环境对象被保留在堆内存中,不会被回收。

生命周期

  • 创建阶段:外部函数执行,内部函数被创建并捕获外部变量。
  • 存活阶段:内部函数仍被任何可达引用持有,其引用的词法环境就不会被 GC 回收。
  • 销毁阶段:当持有闭包的引用被解除(比如设置为 null),且没有其他闭包共享同一词法环境时,这部分内存最终被垃圾回收。
function createClosure() {
  let hugeData = new Array(1000000).fill('*'); // 大块内存
  return function() {
    console.log(hugeData[0]);
  };
}
const closure = createClosure();
// hugeData 不会被回收,因为闭包仍然持有引用
closure();
closure = null; // 解除引用,hugeData 可以被回收

注意
如果闭包共享同一个外部词法环境,即使某个闭包没有显式引用某个变量,该变量也可能因为被其他闭包引用而驻留。V8 等现代引擎会对闭包进行优化,只保留被引用的变量,但不应完全依赖这种优化,而应在设计时谨慎管理闭包的生命周期。

3.5.3 闭包的经典应用场景与潜在风险

经典应用场景

1. 数据私有化(模块模式)
闭包可以创建私有变量,外部只能通过暴露的方法访问,实现封装。

function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    decrement: () => --count,
    getValue: () => count
  };
}
const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.count);      // undefined,外部无法直接访问

2. 函数柯里化与偏函数
利用闭包预先保存部分参数,返回一个接收剩余参数的新函数。

function multiply(a) {
  return function(b) {
    return a * b;
  };
}
const double = multiply(2);
console.log(double(5)); // 10

3. 回调与事件处理
事件监听、定时器中的回调函数经常形成闭包,访问外层状态。

function setupButton(buttonId) {
  const btn = document.getElementById(buttonId);
  let clickCount = 0;
  btn.addEventListener('click', function() {
    clickCount++;
    console.log(`Clicked ${clickCount} times`);
  });
}

4. 循环中的闭包(常见面试题)
经典问题:循环中用 var 定义的回调共享同一个变量引用。

// 错误示例:所有回调打印的值都是循环结束时的 i
for (var i = 1; i <= 5; i++) {
  setTimeout(() => console.log(i), i * 1000);
}
// 修正方法:使用 let(块级作用域)或立即执行函数创建独立闭包
for (let i = 1; i <= 5; i++) {
  setTimeout(() => console.log(i), i * 1000);
}

潜在风险

1. 内存泄漏
如果闭包被持续持有(比如挂载在 DOM 元素上或全局对象上),其引用的外部变量无法被回收,可能导致内存占用持续增长。特别是在单页应用中,反复创建和遗忘闭包而不清理,可能引发性能问题。

2. 意外的变量共享
多个闭包共享同一个外部词法环境时,修改变量会影响所有闭包的行为,可能导致难以追踪的副作用。

function createCounters() {
  let count = 0;
  return [
    () => ++count,
    () => count = 0
  ];
}
const [inc, reset] = createCounters();
inc(); inc();
reset(); // 将共享的 count 重置为 0

3. 引用过期(过时的闭包)
在异步操作中,闭包捕获的变量值可能与当前实际状态不一致。例如请求回调中引用某个外部变量,而该变量在请求发起后已被修改,导致处理结果错误。这通常需要利用函数参数或更新的作用域来解决。

最佳实践

  • 明确闭包的生命周期,在不需要时手动解除引用(如移除事件监听、清理定时器)。
  • 在循环或异步场景下,优先使用 letconst 利用块级作用域隔离变量。
  • 如果闭包不需要访问外部状态,考虑使用纯函数替代,减少不必要的内存持有。
  • 使用开发者工具(如 Chrome Memory Profiler)分析内存快照,定位闭包引起的内存问题。

闭包既是强大的工具,也是一把双刃剑。理解其底层原理——词法作用域下的引用保持,你就能自信地驾驭它,写出既灵活又健壮的代码。