你并不了解 JavaScript:作用域与闭包 - 第二版 - 第七章:闭包的使用

第七章:闭包的使用

目前,我们主要介绍了词法作用域的来龙去脉,以及它如何影响程序中变量的组织和使用。

我们的注意力再次从抽象的角度转移到历史上令人生畏的「闭包」话题上。别担心!你并不需要一个高级计算机科学学位来理解它。我们在本书中的总体目标不仅仅是理解作用域,而是在我们的程序结构中更有效地使用它;闭包是这一目标的核心。

回顾第 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 的参数,但它也同时引用了 studentsstudentID 这两个来自 lookupStudent(..) 的外层作用域的标识符。从内部函数到外层作用域变量的每一次引用都被称为闭包 (closure)。用学术术语来说,greetStudent(..) 的每个实例都封闭了外层变量 studentsstudentID

那么,在具体的、可观测到的意义上,这些闭包在这里有什么作用呢?

闭包允许 greetStudent(..) 继续访问这些外层变量,即使在外层作用域结束后(当每次调用 lookupStudent(..) 完成时)。studentsstudentID 的实例没有被 GC,而是留在了内存中。以后再调用 greetStudent(..) 函数实例时,这些变量仍然存在,并保持其当前值。

如果 JS 函数没有闭包,那么每次完成lookupStudent(..) 调用后都会立即缩小其作用域并 GC studentsstudentID 变量。之后,当我们调用其中一个 greetStudent(..) 函数时,会发生什么呢?

如果 greetStudent(..) 试图访问它认为是蓝色(2) 的弹珠,但该弹珠实际上并不存在(不再存在),合理的假设是我们应该得到一个 ReferenceError 不是吗?

但我们并没有得到错误信息。事实上,chosenStudents[0]("Hello") 的执行是有效的,并返回了信息 “Hello, Sarah!”,这意味着它仍然能够访问 studentsstudentID 变量。这就是对闭包的直接观测!

明显的闭包

事实上,我们在之前的讨论中忽略了一个小细节,我猜很多读者都没有注意到!

由于 => 箭头函数的语法非常简洁,我们很容易忘记它们仍然创建了一个作用域(正如第 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 变量(值分别为 1042),因此这些 num1 并不会因为 adder(..) 的结束而消失。当我们以后调用这些内部的 addTo(..) 实例之一时,例如调用 add10To(15) 时,其中的 num1 变量仍然存在,并且仍然保持原来的 10 值。因此,该操作能够执行 10 + 15 并返回答案 25

在上一段中,有一个重要的细节可能很容易被忽略,所以让我们来强化一下:闭包与函数的实例相关联,而不是与函数的单个词法定义相关联。在前面的代码段中,adder(..) 内部只定义了一个内部addTo(..) 函数,因此这似乎意味着只有一个闭包。

但实际上,外层的 adder(..) 函数每运行一次,就会创建一个的内部 addTo(..) 函数实例,而每个新实例都有一个新的闭包。因此,每个内部函数实例(在我们的程序中标记为 add10To(..)add42To(..))在执行 adder(..) 时都有自己的作用域环境实例闭包。

尽管闭包是基于编译时处理的词法作用域,但闭包却是函数实例的运行时特征。

实时链接,而非快照

在前面的两个例子中,我们都是从闭包中的变量读取值。这让人感觉闭包可能是某个特定时刻值的快照。事实上,这是一个常见的误解。

闭包实际上是一个实时链接,保留了对完整变量本身的访问。我们不仅限于读取一个值,还可以更新(重新赋值)被封闭的变量!通过封闭函数中的变量,只要程序中存在函数引用,我们就可以继续使用该变量(读取和写入),也可以在任何地方调用该函数。这就是为什么闭包是一种强大的技术,被广泛应用于编程的各个领域!

图 4 描述了函数实例和作用域链接:

通过闭包与作用域相连的函数实例
图 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 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值