Kodokon kodokon.com

闭包与词法作用域

掌握闭包如何捕获变量,从而封装状态并避开作用域陷阱。

9 分钟 · 3 题

在 Kodokon 中打开本课

闭包并不是什么高深莫测的技巧:它是一种机制,让函数得以持续访问其定义所在的词法作用域,而不是被调用所在的作用域。引擎不会复制值:它保留着对该词法环境的活引用。因此,对同一个工厂函数的两次调用会产生两个相互独立的环境,这使得闭包成为该语言中最轻量的封装工具。

JAVASCRIPT
function createCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    reset: () => { count = 0; },
  };
}
const counterA = createCounter();
const counterB = createCounter();
counterA.increment();
counterA.increment();
console.log(counterA.increment()); // 3
console.log(counterB.increment()); // 1
私有状态:count 只能通过返回的方法来访问。

经典的现实陷阱:var 绑定到外层函数,而不是绑定到代码块。在循环内部,每个闭包因此都会捕获同一个变量。使用 let 时,每一次迭代都会创建一个全新的词法环境,所以每次捕获都是各自独立的。除此之外还有暂时性死区(TDZ):letconst 变量一进入代码块就已经存在,但如果你在其声明之前读取它,就会抛出 ReferenceError

JAVASCRIPT
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 3, 3, 3
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 0); // 0, 1, 2
}
var 共享同一个绑定;let 则为每次迭代各创建一个。

从模式的角度看,闭包是记忆化(memoization)和模块模式的基础。与类相比,闭包无需 this 或原型就能提供真正的私有性,代价是当你创建成千上万个实例时内存占用会略高:每个实例都各自携带一份自己的函数。

JAVASCRIPT
function memoize(fn) {
  const cache = new Map();
  return function (key) {
    if (cache.has(key)) return cache.get(key);
    const result = fn(key);
    cache.set(key, result);
    return result;
  };
}
const square = (n) => n * n;
const fastSquare = memoize(square);
console.log(fastSquare(9)); // computed
console.log(fastSquare(9)); // read from the cache
缓存存在于闭包内部,从外部无法看到。

知识检测

确认你已牢记本课的重点内容。

  1. for (var i = ...) 循环中,在循环体内部创建的三个 setTimeout 回调会打印出什么?
    • 0、1、2,因为每个回调都捕获了各自的值
    • 3、3、3,因为它们共享同一个 i 的绑定
    • 三次 undefined,因为 i 在循环结束后被销毁了
  2. 闭包到底从它被定义所在的环境中保留了什么?
    • 创建时各个值的一份冻结副本
    • 对被捕获变量的一个活引用
    • 只有外层函数的参数
    • 什么都没有:函数返回时作用域就被销毁了
  3. 为什么 createCounter 能保证 count 始终保持私有?
    • 因为 let 让变量变得不可变
    • 因为 count 只能通过返回的函数来访问,而只有这些函数捕获了它的作用域
    • 因为工厂函数返回后引擎会删除 count