第七章:闭包的使用
目前,我们主要介绍了词法作用域的来龙去脉,以及它如何影响程序中变量的组织和使用。
我们的注意力再次从抽象的角度转移到历史上令人生畏的「闭包」话题上。别担心!你并不需要一个高级计算机科学学位来理解它。我们在本书中的总体目标不仅仅是理解作用域,而是在我们的程序结构中更有效地使用它;闭包是这一目标的核心。
回顾第 6 章的主要结论:最小暴露 (POLE) 鼓励我们使用块(和函数)作用域来限制变量的作用域暴露。这有助于保持代码的可理解性和可维护性,并有助于避免许更多的作用域陷阱(如命名冲突等)。
闭包基于这种方法:对于我们需要长期使用的变量,我们可以将它们封装起来(更小的作用域),而不是将它们放在更大的外部作用域中,但仍保留函数内部的访问权限,以便更广泛地使用。函数通过闭包记住这些被引用的作用域变量。
我们在上一章中已经看到过这种闭包的示例(第 6 章中的 factorial(..)
),几乎可以肯定,你已经在自己的程序中使用过它了。如果你写过一个回调函数,它可以访问自身作用域之外的变量…你猜怎么着?这就是闭包。
闭包是编程领域有史以来最重要的语言特点之一,它是包括函数式编程 (FP) 、模块甚至一些面向类的设计在内的主要编程范式的基础。要掌握 JS 并在整个代码中有效利用许多重要的设计模式,就必须熟悉闭包。
在本章中,需要讨论和编写大量代码,才能对闭包有全面的了解。请务必花点时间,确保在进入下一章节之前对每一部分都能得心应手。
来看看闭包
闭包原本是一个数学概念,来自 lambda 微积分。但我不会列出数学公式或使用一堆符号和术语来定义它。
相反,我将从实用的角度出发。首先,我们将从定义闭包开始,根据我们在程序的不同行为中可以观测到何如来定义闭包,而不是如果闭包在 JS 中不存在。不过,在本章的后面部分,我们将从另一个角度来看待闭包。
闭包是函数的一种行为,也只能是函数的一种行为。如果你处理的不是函数,闭包就不适用。对象不具有闭包性,类也不具有闭包性(尽管其函数/方法可能具有闭包性)。只有函数才有闭包。
要观测到闭包,函数必须被调用,具体地说,它必须在与最初定义它的作用域链不同的分支中被调用。在定义的同一作用域中执行的函数,无论是否可能闭包,都不会表现出任何可观测到的不同行为;根据观测的角度和定义,这不是闭包。
让我们来看一些代码,并用相关的作用域气泡颜色加以注释(见第 2 章):
// 外部/全局作用域:红色(1)
function lookupStudent(studentID) {
// 函数作用域:蓝色(2)
var students = [
{
id: 14, name: "Kyle" },
{
id: 73, name: "Suzy" },
{
id: 112, name: "Frank" },
{
id: 6, name: "Sarah" },
];
return function greetStudent(greeting) {
// 函数作用域:绿色(3)
var student = students.find((student) => student.id == studentID);
return `${
greeting}, ${
student.name}!`;
};
}
var chosenStudents = [lookupStudent(6), lookupStudent(112)];
// 访问函数名:
chosenStudents[0].name;
// greetStudent
chosenStudents[0]("Hello");
// Hello, Sarah!
chosenStudents[1]("Howdy");
// Howdy, Frank!
这段代码首先要注意的是,lookupStudent(..)
外层函数创建并返回一个名为greetStudent(..)
的内层函数。lookupStudent(..)
被调用了两次,产生了两个独立的内部函数 greetStudent(..)
实例,这两个实例都被保存到 chosenStudents
数组中。
我们通过检查保存在 chosenStudents[0]
中的返回函数的 .name
属性来验证,它确实是内部 greetStudent(..)
的实例。
每次调用 lookupStudent(..)
结束后,它的所有内部变量似乎都会被丢弃和 GC(垃圾回收)。内部函数似乎是唯一被返回和保留的东西。但我们可以从这里开始观测行为的不同之处。
虽然 greetStudent(..)
收到一个名为 greeting
的参数,但它也同时引用了 students
和 studentID
这两个来自 lookupStudent(..)
的外层作用域的标识符。从内部函数到外层作用域变量的每一次引用都被称为闭包 (closure)。用学术术语来说,greetStudent(..)
的每个实例都封闭了外层变量 students
和 studentID
。
那么,在具体的、可观测到的意义上,这些闭包在这里有什么作用呢?
闭包允许 greetStudent(..)
继续访问这些外层变量,即使在外层作用域结束后(当每次调用 lookupStudent(..)
完成时)。students
和 studentID
的实例没有被 GC,而是留在了内存中。以后再调用 greetStudent(..)
函数实例时,这些变量仍然存在,并保持其当前值。
如果 JS 函数没有闭包,那么每次完成lookupStudent(..)
调用后都会立即缩小其作用域并 GC students
和 studentID
变量。之后,当我们调用其中一个 greetStudent(..)
函数时,会发生什么呢?
如果 greetStudent(..)
试图访问它认为是蓝色(2) 的弹珠,但该弹珠实际上并不存在(不再存在),合理的假设是我们应该得到一个 ReferenceError
不是吗?
但我们并没有得到错误信息。事实上,chosenStudents[0]("Hello")
的执行是有效的,并返回了信息 “Hello, Sarah!”,这意味着它仍然能够访问 students
和 studentID
变量。这就是对闭包的直接观测!
明显的闭包
事实上,我们在之前的讨论中忽略了一个小细节,我猜很多读者都没有注意到!
由于 =>
箭头函数的语法非常简洁,我们很容易忘记它们仍然创建了一个作用域(正如第 3 章「箭头函数」中所断言的)。student => student.id == studentID
箭头函数在 greetStudent(..)
函数作用域内创建了另一个作用域气泡。
根据第 2 章中彩色水桶和气泡的比喻,如果我们要为这段代码创建一个彩色图,在最内层的嵌套层中有第四个作用域,因此我们需要第四种颜色;也许我们会为该作用域选择橙色(4) :
var student = students.find(
(student) =>
// 函数作用域:橙色(4)
student.id == studentID,
);
蓝色(2) studentID
引用实际上是在橙色(4) 作用域内,而不是在 greetStudent(..)
的绿色(3) 作用域内;而且,箭头函数的 student
参数是橙色(4),对绿色(3) student
有遮蔽。
这样做的后果是,作为数组的 find(..)
方法的回调函数传递的箭头函数必须保持 studentID
的闭包,而不是 greetStudent(..)
保持该闭包。这并不是什么大问题,因为一切仍按预期运行。重要的是,即使是很小的箭头函数也可以参与闭包。
增加闭包
让我们来看看一个经常被引用的经典案例:
function adder(num1) {
return function addTo(num2) {
return num1 + num2;
};
}
var add10To = adder(10);
var add42To = adder(42);
add10To(15); // 25
add42To(9); // 51
内部 addTo(..)
函数的每个实例都记住了自己的 num1
变量(值分别为 10
和 42
),因此这些 num1
并不会因为 adder(..)
的结束而消失。当我们以后调用这些内部的 addTo(..)
实例之一时,例如调用 add10To(15)
时,其中的 num1
变量仍然存在,并且仍然保持原来的 10
值。因此,该操作能够执行 10 + 15
并返回答案 25
。
在上一段中,有一个重要的细节可能很容易被忽略,所以让我们来强化一下:闭包与函数的实例相关联,而不是与函数的单个词法定义相关联。在前面的代码段中,adder(..)
内部只定义了一个内部addTo(..)
函数,因此这似乎意味着只有一个闭包。
但实际上,外层的 adder(..)
函数每运行一次,就会创建一个新的内部 addTo(..)
函数实例,而每个新实例都有一个新的闭包。因此,每个内部函数实例(在我们的程序中标记为 add10To(..)
和 add42To(..)
)在执行 adder(..)
时都有自己的作用域环境实例闭包。
尽管闭包是基于编译时处理的词法作用域,但闭包却是函数实例的运行时特征。
实时链接,而非快照
在前面的两个例子中,我们都是从闭包中的变量读取值。这让人感觉闭包可能是某个特定时刻值的快照。事实上,这是一个常见的误解。
闭包实际上是一个实时链接,保留了对完整变量本身的访问。我们不仅限于读取一个值,还可以更新(重新赋值)被封闭的变量!通过封闭函数中的变量,只要程序中存在函数引用,我们就可以继续使用该变量(读取和写入),也可以在任何地方调用该函数。这就是为什么闭包是一种强大的技术,被广泛应用于编程的各个领域!
图 4 描述了函数实例和作用域链接:

如图 4 所示,对 adder(..)
的每次调用都会创建一个新的包含 num1
变量的蓝色 (2) 作用域,以及一个新的作为绿色 (3) 作用域的 addTo(..)
函数实例。请注意,函数实例(addTo10(..)
和 addTo42(..)
)存在于红色 (1) 作用域中并被调用。
现在,我们来看看一个更新封闭变量的例子:
function makeCounter() {
var count = 0;
return function getCurrent() {
count = count + 1;
return count;
};
}
var hits = makeCounter();
// 然后
hits(); // 1
// 然后
hits(); // 2
hits(); // 3
count
变量被内部的 getCurrent()
函数封闭,该函数会保留该变量,而不是将其用于 GC。hits()
函数会调用访问并更新该变量,每次都会返回一个递增的计数。
虽然闭包的外层作用域通常来自一个函数,但实际上这并不是必需的;外层作用域中只需有一个内部函数即可:
var hits;
{
// 外层作用域(但不是函数)
let count = 0;
hits = function getCurrent() {
count