JavaScript 闭包指南

JavaScript 中的闭包是如何工作的

理解函数如何与它被创建时所在的环境保持存活连接,配有涵盖状态保存、循环陷阱与私有数据的可运行示例。

闭包不是把外层函数的值复制一份。它指的是:函数会保留对创建时所在词法环境的存活引用,因此在外层调用早已返回之后,它依然能读取并更新那个环境里的绑定。

这篇文章从一个持续保存状态的计数器开始,讲到循环中按次迭代绑定的经典陷阱,再到如何用闭包让数据真正保持私有。

一句话回答

  • 闭包在创建时就已固定:函数写在哪个词法环境里面,它就会永久保留那个环境,无论之后在哪里被调用。
  • 每次调用闭包都会通过同一个被捕获的环境解析自由变量,这正是状态会持续累积、而不是每次重置的原因。
  • 每一次单独的调用只要创建了新函数,就会得到自己独立的被捕获环境,即使两次调用运行的是完全相同的代码。

1. 被返回的函数会让外层变量保持存活

当 makeCounter 返回 increment 时,外层调用其实已经结束——但 increment 仍然持有对 makeCounter 环境的存活引用,因此 count 既不会被回收,也不会重置。

即使 makeCounter 已经返回,increment 仍能访问 count

function makeCounter() {
  let count = 0;

  return function increment() {
    count += 1;
    return count;
  };
}

const next = makeCounter();
console.log(next()); // 1
console.log(next()); // 2

2. 两次调用会创建两个互不影响的闭包

第二次调用 makeCounter 会创建一个全新的环境,拥有自己独立的 count 绑定。两次返回的函数之间没有任何关联——递增其中一个不会影响另一个。

counterA 和 counterB 各自捕获自己的 count

const counterA = makeCounter();
const counterB = makeCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 —— 它拥有自己独立的 count

3. 循环里使用 var 的经典陷阱

var 是函数作用域,而不是块级作用域,因此下面三个回调闭包捕获的其实是同一个 i。等到它们真正运行时,循环早已结束,i 已经是 3。

把 var 换成 let 就能解决这个问题:let 会在每次迭代时为 i 创建一个全新绑定,因此每个回调都会捕获自己独立的值,依次打印 0、1、2。

三个定时器都打印 3,因为它们共享同一个 i

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 0);
}
// 3
// 3
// 3

4. 用闭包让数据保持私有

balance 在这里从未被直接暴露出去——读取或修改它的唯一方式,是通过 makeAccount 返回的那些函数,因为只有它们才捕获了它所在的环境。

balance 只能通过捕获它的闭包访问到

function makeAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = makeAccount(100);
console.log(account.deposit(50)); // 150
console.log(account.getBalance()); // 150

5. 当闭包也牵涉到 this 时

闭包捕获的是变量,而不是 this——this 依然会在调用时针对实际被调用的那个函数单独解析。唯一的例外是箭头函数,它捕获 this 的方式和捕获任何其他变量完全一样。

想看两者结合在一起的完整示例,可以参考 this + 闭包可视化器;只想了解单独决定 this 的调用点规则,可以参考 this 指南。

亲自试一试追踪一次闭包捕获状态的过程逐步执行一次调用,观察外层函数返回后,闭包究竟保留了哪一个词法环境。