《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域

目录
  • 第5章 精通函数:闭包和作用域
    • 5.1 理解闭包
    • 5.2 使用闭包
      • 5.2.1 封装私有变量
      • 5.2.2 回调函数
    • 5.3 通过执行上下文来跟踪代码
    • 5.4 使用词法环境跟踪变量的作用域
      • 5.4.1 代码嵌套
      • 5.4.2 代码嵌套与词法环境
    • 5.5 理解 JavaScript 的变量类型
      • 5.5.1 变量的可变性
        • const变量
      • 5.5.2 定义变量的关键字与词法环境
        • 使用关键字var
        • 使用let与const定义具有块级作用域的变量
      • 5.5.3 在词法环境中注册标志符
        • 注册标识符的过程
        • 在函数声明之前调用函数
        • 函数重载
    • 5.6 研究闭包的工作原理
      • 5.6.1 回顾使用闭包模拟私有变量的代码
      • 5.6.2 私有变量的警告
      • 5.6.3 回顾闭包和回调函数的栗子
    • 5.7 小结
    • 5.8 练习

第5章 精通函数:闭包和作用域

本章包括以下内容:

  • 使用闭包简化代码
  • 使用执行上下文跟踪JavaScript程序的执行
  • 使用词法环境(Lexical Environment) 跟踪变量的作用域
  • 理解变量的类型
  • 探讨闭包的工作原理

通过在前几章中所学到的关于函数的知识可以看出, 闭包是
JavaScript的显著特征。 虽然许多JavaScript开发者在开发时没有理解闭
包的主要优势, 但是使用闭包, 不仅可以通过减少代码数量和复杂度来
添加高级特性, 还能实现不太可能完成的功能。 换句话说, 如果没有闭
包, 事情将变得非常复杂。 例如, 如果没有闭包, 事件处理和动画等包
含回调函数的任务, 它们的实现将变得复杂得多。 除此之外, 如果没有
闭包, 将完全不可能实现私有变量。 JavaScript语言的蓝图, 以及我们编
码的方式, 都是由闭包塑造出来的。

从传统意义上来说, 闭包是纯函数式编程语言的特性之一。 令人鼓
舞的是, 闭包也进入了主流开发语言。 因为闭包能够大大简化复杂操
作, 所以很容易在JavaScript库或其他高级代码库中看到闭包的使用。

闭包带来的问题是JavaScript的作用域是如何工作的。 为此, 我们将
探讨JavaScript的作用域规则, 需要特别注意新增的特性, 这将有助于理
解在特定场景下闭包的工作原理。 让我们立刻行动起来吧!

你知道吗?

  • 一个变量或方法有几种不同的作用域? 这些作用域分别是什么?
  • 如何定位标识符及其值?
  • 什么是可变变量? 如何在JavaScript中定义可变变量?

5.1 理解闭包

闭包允许函数访问并操作函数外部的变量。 只要变量或函数存在于
声明函数时的作用域内, 闭包即可使函数能够访问这些变量或函数。

注意

或许你已经熟悉了作用域的概念, 但是, 有时作用域指的是在程序的特定部分中标识符 的可
见性。 作用域是程序的一部分, 特定的名字绑定特定的变量。

这可能看起来很直观, 但是要记住, 所声明的函数可以在声明之后
的任何时间被调用, 甚至当该函数声明的作用域消失之后仍然可以调
用。 直接通过代码来解释这个概念是最好的。 但是在我们开始介绍如何
优雅地实现动画、 如何定义私有对象属性的具体示例之前, 我们先从清
单5.1中的简单示例开始。

清单 5.1 一个简单的闭包

const outerValue = "ninja"; // ninja:忍者  在全局作用域中定义一个变量
function outerFunction() { // 在全局作用域中声明函数
    if (outerValue === "ninja") {
        console.log("I can see the ninja.")
    }
}
outerFunction(); // 执行该函数 I can see the ninja.

在清单5.1中, 我们在同一作用域中声明了变量outerValue及外部函
数outerFunction——本例中, 是全局作用域。 然后, 执行外部函数
outerFunction。

如图5.1所示, 该函数可以“看见”并访问变量outerValue。 我们可能
已经写过上百次这样的代码, 但是却没有意识到其实我们正在创建一个
闭包!

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第1张图片
图5.1 函数找到了隐藏在外部变量中的ninja

书上的原图!

没有印象? 我想这并不奇怪。 因为外部变量outerValue和外部函数
outerFunction都是在全局作用域中声明的, 该作用域(实际上就是一个
闭包) 从未消失(只要应用处于运行状态) 。 这也不足为奇, 该函数可
以访问到外部变量, 因为它仍然在作用域内并且是可见的。

虽然闭包存在, 但是闭包的优势仍不明显。 让我们在接下来的清单
5.2的代码中加点料。

仔细研究一下内部函数innerFunction中的代码, 看看我们能否预测
会发生什么。

  • 第一个断言肯定会通过, 因为外部变量outerValue在全局作用域
    内, 并且在任何地方都可见。 但是第二个断言呢?
  • 外部函数执行后, 我们通过将内部函数的引用赋值给全局变量
    later, 再通过later调用内部函数。
  • 当内部函数执行时, 外部函数的作用域已经不存在了, 并且在通过
    later调用内部函数时, 外部函数的作用域已不可见了。
  • 所以我们可以很好地预见断言失败, 因为内部变量innerValue肯定
    是undefined, 对吧?

清单5.2 另一个闭包的例子

const outerValue = "samurai"; // 武士
let later; // 声明一个空变量, 稍后在后面的代码中使用

function outerFunction() {
    const innerValue = "ninja"; // 在函数内部声明一个值, 该值在作用域局限于函数内部, 在函数外部不允许访问

    function innerFunction() {
        if (outerValue === "samurai") {
            console.log("I can see the samurai.");
            if (innerValue === "ninja") {
                console.log("I can see the ninja.")
            } // 在outerFunction函数中声明一个内部函数, 声明该内部函数时, innerValue是在内部函数的作用域内
        }
    }
    later = innerFunction; // 将内部函数innerFunction的引用存储在变量later上, 因为later在全局作用域内, 所以我们可以对它进行调用
}

outerFunction(); // 调用outerFunction函数, 创建内部函数innerFunction,并将内部函数赋值给变量later

later(); // 通过later调用内部函数。 我们不能直接调用内部函数, 因为它的作用域(和innerValue一起) 被限制在外部函数outerFunction之内

但是, 当我们执行完测试时, 我们看到如图5.2所示的结果。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第2张图片
图5.2 尽管试图隐藏在函数体内, 但是仍然能够检测到ninja变量

这怎么可能呢? 是什么魔法使得在内部函数的作用域消失之后再执
行内部函数时, 其内部变量仍然存在呢?

当在外部函数中声明内部函数时, 不仅定义了函数的声明, 而且还
创建了一个闭包。 该闭包不仅包含了函数的声明, 还包含了在函数声明
时该作用域中的所有变量。 当最终执行内部函数时, 尽管声明时的作用
域已经消失了, 但是通过闭包, 仍然能够访问到原始作用域, 如图5.3
所示。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第3张图片
图5.3 正如保护气泡一样, 只要内部函数一直存在, 内部函数的闭包就一直保存着该函数的作 用域中的变量

5.2 使用闭包

现在我们已经对闭包有了一个宏观的理解, 接着来看看如何在
JavaScript应用中使用闭包。 首先我们会关注闭包的实用性和优势。 在本
章的后续部分中, 我们会再次查看同一个案例, 确认幕后究竟发生了什
么。

5.2.1 封装私有变量

许多编程语言使用私有变量, 这些私有变量是对外部隐藏的对象属
性。 这是非常有用的一种特性, 因为当通过其他代码访问这些变量时,
我们不希望对象的实现细节对用户造成过度负荷。 遗憾的是, 原生
JavaScript不支持私有变量。 但是, 通过使用闭包, 我们可以实现很接近
的、 可接受的私有变量, 示例代码如清单5.3所示。

清单5.3 使用闭包模拟私有变量

function Ninja() { // 定义Ninja构造函数
    let feints = 0; // 佯攻 在构造函数内部声明一个变量, 因为所声明的变量的作用域局限于构造函数的内部, 所以它是一个“私有”变量。 我们使用该变量统计Ninja佯攻的次数

    this.getFints = function() { // 创建用于访问计数变量feints的方法。 由于在构造函数外部的代码是无法访问feints变量的, 这是通过只读形式访问该变量的常用方法
        return feints;
    };
    this.feint = function() {
        feints++;
    }; // 为feints变量声明一个累加方法。 由于feints为私有变量, 在外部是无法累加的, 累加过程则被限制在我们提供的方法中
}

const ninja1 = new Ninja(); // 现在开始测试, 首先创建一个Ninja的实例
ninja1.feint(); // 调用feint方法, 通过该方法增加Ninja的佯攻次数

if (ninja1.feints === undefined) { // 验证我们无法直接获取该变量值
    // inaccessible 无法接近
    console.log("And the private data is inaccessible to us.")
}

if (ninja1.getFints() === 1) {
    console.log("We're able to access the internal feint count.")
}; // 虽然我们无法直接对feints变量赋值, 但是我们仍然能够通过getFeints方法操作该变量的值

const ninja2 = new Ninja();
if (ninja2.getFints() === 0) {
    console.log("The second ninja object gets its own feints variable.")
}; // 当我们通过ninja构造函数创建一个新的ninja2实例时, ninja2对象则具有自己私有的feints变量

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第4张图片

在清单5.3中, 我们创建了一个Ninja构造器。 在第3章中, 我们已经
介绍了使用函数作为构造器的概念(在第7章中我们还会进行深入介
绍) 。 现在, 知道如何使用构造器即可。 通过在函数上使用关键字new
时, 就会创建一个新的对象实例, 此时调用构造函数, 将新的对象作为
它的上下文。 所以, 函数内的this将指向新的实例化对象。

在构造器内部, 我们定义了一个变量feints用于保存状态。 由于
JavaScript的作用域规则的限制, 因此只能在构造器内部访问该变量。 为
了让作用域外部的代码能够访问该变量, 我们定义了访问该变量的方法
getFeints。 该方法可以读取私有变量, 但不能改写私有变量。 (只读访
问的方法通常称为“getter”)

function Ninja() { 
    let feints = 0; 

    this.getFints = function() { 
        return feints;
    };
    this.feint = function() {
        feints++;
    }; 
}

接下来创建增量方法feint, 用于控制私有变量的值。 在真实的应用
程序中, 该方法可能是一些业务逻辑的处理方法, 但是在本例中, 它只
增加变量feints的值。
在构造器完成了它的使命之后, 我们新建ninja1实例, 并调用ninja1
的实例方法feint:

const ninja1 = new Ninja(); 
ninja1.feint();

通过测试显示, 我们可通过闭包内部方法获取私有变量的值, 但是
不能直接访问私有变量。 这有效地阻止了读私有变量不可控的修改, 这
与真实的面向对象语言中的私有变量一样。 这种情况如图5.4所示。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第5张图片
图5.4 在构造器中隐藏变量, 使其在外部作用域中不可访问, 但是可在闭包内部进行访问
  • 通过变量ninja, 对象实例是可见的。
  • 因为feint方法在闭包内部, 因此可以访问变量feints。
  • 在闭包外部, 我们无法访问变量feints。

通过使用闭包, 可以通过方法对ninja的状态进行维护, 而不允许用
户直接访问——这是因为闭包内部的变量可以通过闭包内的方法访问,
构造器外部的代码则不能访问闭包内部的变量。

这只是初步对JavaScript面向对象编程世界的探索, 在后续的章节
中, 我们将更加深入地对JavaScript面向对象编程进行研究。 现在, 我们
重点关注闭包的另一个常见的使用方法。

5.2.2 回调函数

处理回调函数是另一种常见的使用闭包的情景。 回调函数指的是需
要在将来不确定的某一时刻异步调用的函数。 通常, 在这种回调函数
中, 我们经常需要频繁地访问外部数据。 清单5.4中显示了一个创建简
单的动画计时的示例。

清单5.4 在interval的回调函数中使用闭包

                Document        
First Box

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第6张图片

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第7张图片

特别重要的是, 在清单5.4的代码中使用了一个独立的匿名函数来
完成目标元素的动画效果, 该匿名函数作为计时器的一个参数传入计时
器。 通过闭包, 该匿名函数通过3个变量控制动画过程: elem、 tick 和
timer。 这3个变量(elem指的是DOM元素的引用, tick指的是计数器和
timer指的是计时器的引用) 用于维持整个动画的过程, 且必须能够在全
局作用域内访问到。

但是, 如果我们将这些变量从animateIt函数中移出到全局作用域,
动画仍然能够正常工作, 为什么都说不能污染全局作用域呢?

我们开始把这些变量放到全局作用域内, 然后验证示例是否能够正
常运行。 现在, 修改示例代码, 同时为两个元素设置动画, 再添加一个
具有唯一ID的元素。 在第一个动画调用之后, 再将新元素的ID传入
animateIt方法进行调用。

问题立刻就很显然了。 如果我们把变量放在全局作用域中, 那么需
要为每个动画分别设置3个变量, 否则同时用3个变量来跟踪多个不同动
画的状态, 动画的状态就会发生冲突。

通过在函数内部定义变量, 并基于闭包, 使得在计时器的回调函数
中可以访问这些变量, 每个动画都能够获得属于自己的“气泡”中的私有
变量, 如图5.5所示。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第8张图片
图5.5 通过在不同的实例中保存变量, 我们一次可以做很多事情

如果没有闭包, 一次性同时做许多事情, 例如事件绑定、 动画甚至
服务端请求等, 都将会变得非常困难。 如果你想知道关注闭包的理由,
那么这就是理由!

清单5.4中的示例是能够演示闭包的概念的特别好的例子, 也证明
了通过闭包能够写出惊人的、 简洁直观的代码。 通过将变量放置在
animateIt函数内部, 不需要非常复杂的语法, 我们就可以创建一个默认
的闭包。

上述示例还说明了一个重要的概念。 闭包内的函数不仅可以在创建
的时刻访问这些变量, 而且当闭包内部的函数执行时, 还可以更新这些
注意

变量的值。 闭包不是在创建的那一时刻的状态的快照, 而是一个真实的
状态封装, 只要闭包存在, 就可以对变量进行修改。
闭包与作用域是强相关的, 本章我们将会详细讨论JavaScript的作用
域规则。 但先从如何通过执行上下文跟踪JavaScript的代码开始。

5.3 通过执行上下文来跟踪代码

在JavaScript中, 代码执行的基础单元是函数。 我们时刻使用函数,
使用函数进行计算, 使用函数更新UI, 使用函数达到复用代码的目的,
使用函数让我们的代码更易于理解。 为了达到这个目标, 第一个函数可
以调用第二个函数, 第二个函数可以调用第三个函数, 以此类推。 当完
成函数调用时, 程序会回到函数调用的位置。 你想知道JavaScript引擎是
如何跟踪函数的执行并回到函数的位置的呢?

在第2章中我们提到, JavaScript代码有两种类型: 一种是全局代
码, 在所有函数外部定义; 一种是函数代码, 位于函数内部。 JavaScript
引擎执行代码时, 每一条语句都处于特定的执行上下文中。

既然具有两种类型的代码, 那么就有两种执行上下文: 全局执行上
下文和函数执行上下文。 二者最重要的差别是: 全局执行上下文只有一
个, 当JavaScript程序开始执行时就已经创建了全局上下文; 而函数执行
上下文是在每次调用函数时, 就会创建一个新的。

注意

第4章介绍了当调用函数时可通过关键字访问函数上下文。 函数执行上
下文, 虽然也称 为上下文, 但完全是不一样的概念。 执行上下文是内部的JavaScript概念, JavaScript引擎使用 执行上下文来跟踪函数的执行。

第2章介绍了JavaScript基于单线程的执行模型: 在某个特定的时刻
只能执行特定的代码。 一旦发生函数调用, 当前的执行上下文必须停止
执行, 并创建新的函数执行上下文来执行函数。 当函数执行完成后, 将
函数执行上下文销毁, 并重新回到发生调用时的执行上下文中。 所以需
要跟踪执行上下文——正在执行的上下文以及正在等待的上下文。 最简
单的跟踪方法是使用执行上下文栈(或称为调用栈) 。

注意

栈是一种基本的数据结构, 只能在栈的顶端对数据项进行插入和读取。 这种特性可类比
于自助餐厅里的一叠托盘, 你只能从托盘堆顶端拿到一个托盘, 服务员也只能将新的托盘放
在这叠托盘的顶端。

这样看起来不太好理解, 让我们先来看看清单5.5中的代码。

清单 5.5 创建执行上下文

function skulk(ninja) {
    report(ninja + " skulking"); // 一个函数调用另外一个函数
}

function report(message) {
    console.log(message); // 通过内置的console.log方法发送消息
}

skulk("kuma");

skulk("Yoshi"); // 在全局中分别调用两个函数

这段代码比较简单, 首先定义了skulk函数, skulk函数调用report函
数。 然后在全局中调用skulk函数两次: skulk("Kuma")和
skulk("Yoshi")。 通过这段基础代码, 我们可以探索执行上下文是如何创
建的, 如图5.6所示 。

图5.6 执行上下文栈的行为

当执行清单5.5中的示例代码时, 执行上下文的行为如下:

  1. 每个JavaScript程序只创建一个全局执行上下文, 并从全局执行
    上下文开始执行(在单页应用中每个页面只有一个全局执行上下文) 。
    当执行全局代码时, 全局执行上下文处于活跃状态。
  2. 首先在全局代码中定义两个函数: skulk和report, 然后调用
    skulk("Kuma")。 由于在同一个特定时刻只能执行特定代码, 所以
    JavaScript引擎停止执行全局代码, 开始执行带有Kuma参数的skulk函
    数。 创建新的函数执行上下文, 并置入执行上下文栈的顶部。
  3. skulk函数进而调用report函数。 又一次因为在同一个特定时刻只
    能执行特定代码, 所以, 暂停skulk执行上下文, 创建新的Kuma作为参
    数的report函数的执行上下文, 并置入执行上下文栈的顶部。
  4. report通过内置函数console.log(详见附录B) 打印出消息后,
    report函数执行完成, 代码又回到了skulk函数。 report执行上下文从执行
    上下文栈顶部弹出, skulk函数执行上下文重新激活, skulk函数继续执
    行。
  5. skulk函数执行完成后也发生类似的事情: skulk函数执行上下文 从栈顶端弹出, 重新激活一直在等待的全局执行上下文并恢复执行。
    JavaScript的全局代码恢复执行。

skulk函数第二次执行时, 整个过程是类似的, 只是参数变成了
Yoshi。 分别创建新的函数执行上下文skulk("Yoshi") 和report("Yoshi
skulking") , 并依次置入执行上下文栈的顶部。 每个函数执行完成时,
对应的函数上下文从执行上下文栈顶部弹出。
虽然执行上下文栈(execution context stack) 是JavaScript内部概
念, 但仍然可以通过JavaScript调试器中查看, 在JavaScript调试器中可
以看到对应的调用栈(call stack) 。 图5.7展示了Chrome开发者工具中
的调用栈。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第9张图片
图5.7 在Chrome开发者工具中查看当前执行上下文栈的状态
注意

附录B中更为详细地显示了不同浏览器的调试工具。

执行上下文除了可以跟踪应用程序的执行位置之外, 对于标识符也
是至关重要, 在静态环境中通过执行上下文可以准确定位标识符实际指
向的变量。

5.4 使用词法环境跟踪变量的作用域

词法环境(lexical environment) 是JavaScript引擎内部用来跟踪标识
符与特定变量之间的映射关系。 例如, 查看如下代码:

var ninja = "Hattori";
console.log(ninja);

当console.log语句访问ninja变量时, 会进行词法环境的查询。

注意

词法环境是JavaScript作用域的内部实现机制, 人们通常称为作用域(scopes)。

通常来说, 词法环境与特定的JavaScript代码结构关联, 既可以是一
个函数、 一段代码片段, 也可以是try-catch语句。 这些代码结构(函
数、 代码片段、 try-catch) 可以具有独立的标识符映射表。

注意

在JavaScript的ES6初版中, 词法环境只能与函数关联。 变量只存在于函数作用域中。 这 也
带来了一些混淆。 因为JavaScript是一门类C的语言, 从其他类C语言(如C++、 C#、 Java
等) 转向JavaScript的开发者通常会预期一些初级概念, 例如块级作用域。 在ES6中最终修
复 了块级作用域问题。

a

5.4.1 代码嵌套

词法环境主要基于代码嵌套, 通过代码嵌套可以实现代码结构包含
另一代码结构。 图5.8显示了多种代码嵌套类型。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第10张图片
图5.8 代码嵌套的不同类型

通过图5.8我们可以看出:

  • for循环嵌套在report函数中。
  • report函数嵌套在skulk函数中。
  • skulk函数嵌套在全局代码中。

在作用域范围内, 每次执行代码时, 代码结构都获得与之关联的词
法环境。 例如, 每次调用skulk函数, 都将创建新的函数词法环境。
此外, 需要着重强调的是, 内部代码结构可以访问外部代码结构中
定义的变量。 例如, for循环可以访问report函数、 skulk函数以及全局代
码中的变量; report函数可以访问skulk函数及全局代码中的变量; skulk
函数可以访问的额外变量但仅是全局代码中的变量。
这种访问变量的方式没有特殊之处, 我们很可能已经经常这么做
了。 但是, JavaScript引擎是如何跟踪这些变量的呢? 如何判断可访问性
呢? 这就是词法环境的作用。

5.4.2 代码嵌套与词法环境

除了跟踪局部变量、 函数声明、 函数的参数和词法环境外, 还有必
要跟踪外部(父级) 词法环境。 因为我们需要访问外部代码结构中的变
量, 如果在当前环境中无法找到某一标识符, 就会对外部环境进行查
找。 一旦查找到匹配的变量, 或是在全局环境中仍然无法查找到对应的
标识符而返回错误, 就会停止查找。 图5.9中的示例显示: 当执行report
函数时, 标识符intro、 action及ninja是如何查找的。 在图5.9的示例中,
在全局调用skulk函数, skulk函数又调用report函数。 每个执行上下文都
有一个与之关联的词法环境, 词法环境中包含了在上下文中定义的标识
符的映射表。 例如, 全局环境中具有ninja与skulk的映射表, skulk环境
中具有action与report的映射表, report环境中具有intro的映射表(图5.9
右侧) 。

在特定的执行上下文中, 我们的程序不仅直接访问词法环境中定义
的局部变量, 而且还会访问外部环境中定义的变量。 例如, report函数

体访问了在skulk函数中定义的action变量, 也访问了全局的ninja变量。
为了实现这一点, 我们需要跟踪这些外部环境。 JavaScript实现这一点得
益于函数是第一型对象的特性。

无论何时创建函数, 都会创建一个与之相关联的词法环境, 并存储
在名为[[Environment]]的内部属性上(也就是说无法直接访问或操
作) 。 两个中括号用于标志内部属性。 在图5.9的示例中, skulk函数保
存全局环境的引用, report函数保存skulk环境的引用, 这些都是函数被
创建时所在的环境。
这些环境是在函数创建时决定的, 因此除了全局环境外, report函
数还可以访问shulk环境。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第11张图片

发现没,其实就是作用域链!

图5.9 JavaScript引擎如何查找变量的值
注意

乍看之下会觉得奇怪。 为什么不直接跟踪整个执行上下文, 直接搜索与环境相匹配的标 识
符映射表呢? 从技术上来说, 在本例中是可行的。 但是, 需要记住的是, JavaScript函数
可 以作为任意对象进行传递, 定义函数时的环境与调用函数的环境往往是不同的(想一想闭
包) 。

无论何时调用函数, 都会创建一个新的执行环境, 被推入执行上下
文栈。 此外, 还会创建一个与之相关联的词法环境。 现在来看最重要的
部分: 外部环境与新建的词法环境, JavaScript引擎将调用函数的内置

[[Environment]]属性与创建函数时的环境进行关联。

在图5.9的示例中, 调用skulk函数时, 新创建的skulk环境的外部环
境变成了全局环境(因为这是创建skulk函数时的环境) 。 类似, 当调用
report函数时, 新创建的report环境的外部环境变成了skulk的环境。

现在来看一看report函数:

function report() {
    var intro = "Aha!";
    assert(intro === "Aha!", "Local");
    assert(action === "Skulking", "Outer");
    assert(action === "Muneyoshi", "Global");
}

​ 执行第一句assert语句时, 首先需要查找intro标识符的值。
JavaScript引擎首先检查当前执行上下文, 即report函数环境。 由于report
环境包含一个intro变量的引用, 因此intro标识符就查找完成。

​ 第二句assert语句需要查找action标识符。 又一次需要检查当前环境
的执行上下文。 但是report环境里没有action标识符的引用, 因此
JavaScript引擎需要查找report的外部环境: skulk环境。 幸运的是, skulk
环境包含有action标识符的引用, action标识符就查找完成。 查找ninja标
识符时的处理过程也是类似的(小提示: 可在全局环境中查找到ninja标
识符) 。

​ 现在你已经理解了标识符的基础查找规则, 让我们继续了解变量的
不同声明方式。

5.5 理解 JavaScript 的变量类型

在JavaScript中, 我们可以通过3个关键字定义变量: var、 let和
const。 这3个关键字有两点不同: 可变性, 与词法环境的关系。

注意

var关键词从一开始起就是JavaScript的一部分, 而let与const是在ES6时加进来的。 你可以
通过这个链接检测浏览器是否支持let与const:
http://mng.bz/CGJ6 and http://mng.bz/uUIT。

5.5.1 变量的可变性

如果通过变量的可变性来进行分类, 那么可以将const放在一组,
var和let放在一组。 通过const定义的变量都不可变, 也就是说通过const
声明的变量的值只能设置一次。 通过var或let声明的变量的值可以变更
任意次数。

现在, 让我们来深入了解一下通过const声明的变量是如何工作的。

const变量

通过const声明的“变量”与普通变量类似, 但在声明时需要写初始
值, 一旦声明完成之后, 其值就无法更改。 听起来它不可变, 对吧?

const变量常用于两种目的:

不需要重新赋值的特殊变量(在本书的后续章节中, 我们也会这样
使用) 。
指向一个固定的值, 例如球队人数的最大值, 可通过const变量
MAX_RONIN_COUNT来表示, 而不是仅仅通过数字234来表示。
这使得代码更加易于理解和维护。 虽然在代码里没有直接使用数字
234, 但是通过语义化的变量名MAX_RONIN_COUNT来表示,
MAX_RONIN_COUNT的值只能指定一次。

在其他情况下, 由于在程序执行过程中不允许对const变量重新赋
值, 这可以避免代码发生不必要的变更, 同时也为JavaScript引擎性能优
化提供便利。

清单5.6显示了const变量的行为。

清单5.6 const变量的行为

const firstConst = "samurai" // 武士
if (firstConst === "samurai") {
    console.log("firstConst is a samurai"); // 定义const变量, 并验证该变量已被赋值
}

try {
    firstConst = "lvhanghmm" // 试图为const变量重新赋值将抛出异常

} catch (e) {
    console.log("An exception has occurred")
}

if (firstConst === "samurai") {
    console.log("firstConst is still a samurai!")
}

const secondConst = {}; // 创建一个新的const变量, 并赋值为空对象

secondConst.weapon = "wakizashi";
if (secondConst.weapon === "wakizashi") {
    console.log("We can add new properties")
} // 我们不能再将一个全新的对象赋值给secondConst变量, 但是可以对原有变量进行修改

const thirdConst = [];
if (thirdConst.length === 0) {

    console.log("No items in our array");
}

thirdConst.push("Yoshi")

if (thirdConst.length === 1) {
    console.log("The array has changed")
} // const数组也一样

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第12张图片

这里我们首先定义了一个名为firstConst的const变量, 并赋值为
samurai, 验证该变量已经被初始化, 结果正如预期:

const firstConst = "samurai" // 武士
if (firstConst === "samurai") {
    console.log("firstConst is a samurai"); // 定义const变量, 并验证该变量已被赋值
}

接着我们试图将一个全新的值ninja赋值给firstConst变量:

try {
    firstConst = "ninja" // 试图为const变量重新赋值将抛出异常

} catch (e) {
    console.log("An exception has occurred")
}

【我有改动!】

由于firstConst变量是静态变量, 不允许重新赋值, 因此, JavaScript
引擎抛出异常。 注意到这里使用到两个之前没使用过的函数: fail与
pass。 这两个方法与assert方法类似, fail方法表示失败, pass表示执行成
功。 这里我们用来验证是否发生异常: 如果异常发生, 将执行catch中的
pass方法。 如果没有异常, 将执行fail方法, 表示发生了不该发生的事。
从图5.10中可以看出发生了异常。

书上额图!

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第13张图片
图5.10 查看const变量的行为。 当把一个全新的值赋值给const变量时, 程序会抛出异常

接下来, 我们定义另一个const变量, 并将其初始化为一个空对象:

const secondConst = {};

现在我们来讨论const变量的一个重要特性。 我们不能将一个全新的
值赋值给const变量。 但是, 我们可以修改const变量已有的对象。 例
如, 我们可以给已有对象添加属性:

const secondConst = {}; // 创建一个新的const变量, 并赋值为空对象

secondConst.weapon = "wakizashi";
if (secondConst.weapon === "wakizashi") {
    console.log("We can add new properties")
} // 我们不能再将一个全新的对象赋值给secondConst变量, 但是可以对原有变量进行修改

如果const变量指向一个数组, 我们可以增加该数组的长度:

const thirdConst = [];
if (thirdConst.length === 0) {

    console.log("No items in our array");
}

thirdConst.push("Yoshi")

if (thirdConst.length === 1) {
    console.log("The array has changed")
} // const数组也一样

这就是const变量的全部特性。 const变量并不复杂。 你只需要记住
const变量只能在声明时被初始化一次, 之后再也不允许将全新的值赋值
给const变量即可。 但是, 我们仍然可以修改const变量已经存在的值,
只是不能重写const变量。

现在已经探索了变量的可变性, 接下来继续研究不同类型的变量与
词法环境之间的关系吧 .

5.5.2 定义变量的关键字与词法环境

定义变量的3个关键字——var、 let与const, 还可以通过与词法环境
的关系将其进行分类(换句话说, 按照作用域分类) : 可以将var分为
一组, let与const分为一组。

使用关键字var

当使用关键字var时, 该变量是在距离最近的函数内部或是在全局
词法环境中定义的。 (注意: 忽略块级作用域) 这是JavaScript由来已久
的特性, 也困扰了许多从其他语言转向JavaScript的开发者。 示例见清单
5.7。

清单5.7 使用关键字var

var globalNinja = "Yoshi"; // 使用关键字var定义全局变量

function reportActivity() {
    var functionActivity = "jumping"; // 使用关键字var定义函数内部的局部变量

    for (var i = 1; i < 3; i++) {
        var forMessage = globalNinja + " " + functionActivity; // 使用关键字var在for循环中定义两个变量

        if (forMessage === "Yoshi jumping") {
            console.log("Yoshi is jumping within the for block"); // 在for循环中可以访问块级变量, 函数内的局部变量以及全局变量
        }

        if (i) {
            console.log("Current loop counter:" + i)
        }
    }

    if (i === 3 && forMessage === "Yoshi jumping") {
        console.log("Loop variables accessible outside of the loop"); // 但是在for循环外部, 仍然能访问for循环中定义的变量
    }
}

reportActivity();

if (typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined") {
    console.log("We cannot see function variables outside of a function")
} // 函数外部无法访问函数内部的局部变量

我们首先定义全局变量globalNinja, 接着定义函数reportActivity,
在该函数中使用循环并验证变量globalNinja的行为。 可以看出, 在循环
体内可以正常访问块级作用域中的变量(变量i与forMessage) 、 函数体
内的变量(functionActivity) 以及全局变量(globalNinja) 。

但是JavaScript中特殊的并使得许多从其他语言转向JavaScript的开
发者们困惑的是, 即使在块级作用域内定义的变量, 在块级作用域外仍
然能够被访问:

if (i === 3 && forMessage === "Yoshi jumping") {
    console.log("Loop variables accessible outside of the loop"); // 但是在for循环外部, 仍然能访问for循环中定义的变量
}

这源于通过var声明的变量实际上总是在距离最近的函数内或全局
词法环境中注册的, 不关注块级作用域。 图5.11描述了这一现象, 图中
展示了reportActivity函数内的for循环执行后的词法环境。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第14张图片
图5.11 通过var声明变量, 在距离最近的函数内或全局词法环境中定义(忽略块级作用域) 。 在清单5.7的示例中, 变量forMessage与i虽然是被包含在for循环中, 但实际是在reportActivity环 境中注册的(距离最近的函数环境)

这里有3种词法环境:

变量globalNinja是在全局环境中定义的(距离最近的函数内或全局
词法环境) 。
reportActivity函数创建的函数环境, 包含变量 functionActivity、 i与
forMessage, 这3个变量均通过关键字var定义的, 与它们距离最近
的是reportActivity函数。
for循环的块级作用域, 关键字var定义的变量忽略块级作用域。

这种行为看起来有些怪异, 因此, ES6中提供了两个新的声明变量
的关键字: let与const。

使用let与const定义具有块级作用域的变量

var是在距离最近的函数或全局词法环境中定义变量, 与var不同的
是, let和const更加直接。 let和const直接在最近的词法环境中定义变量
(可以是在块级作用域内、 循环内、 函数内或全局环境内) 。 我们可以
使用let和const定义块级别、 函数级别、 全局级别的变量。
让我们使用const与let重写之前的示例, 如清单5.8所示。

清单5.8 使用const与let关键字

const GLOBAL_NINJA = "Yoshi"; // 使用const定义全局变量, 全局静态变量通常用大写表示

function reportActivity() {
    const functionActivity = "jumping"; // 使用const定义函数内的局部变量

    for (let i = 1; i < 3; i++) {
        var forMessage = GLOBAL_NINJA + " " + functionActivity; // 使用let在for循环中定义两个变量

        if (forMessage === "Yoshi jumping") {
            console.log("Yoshi is jumping within the for block"); // 在for循环中可以访问块级变量, 函数内的局部变量以及全局变量
        }

        if (i) {
            console.log("Current loop counter:" + i); // 在for循环中, 我们毫无意外地可以访问块级变量、 函数变量和全局变量
        }
    }

    // if (i === 3 && forMessage === "Yoshi jumping") {
    //     console.log("Loop variables accessible outside of the loop"); // 现在, 在for循环外部无法访问for循环内的变量
    }



reportActivity();

if (typeof functionActivity === "undefined" && typeof i === "undefined" && typeof forMessage === "undefined") {
    console.log("We cannot see function variables outside of a function")
} // 函数外部无法访问函数内部的局部变量

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第15张图片

图5.12展示了reportActivity函数内的for循环执行完成之后的词法环
境。 此时我们仍然看到3个词法环境: 全局环境(函数和块级作用域之
外的全局代码) 、 reportActivity函数环境和for循环体。 但是由于我们使
用了关键字let和const, 那么变量则是在距离最近的词法环境中定义的:
变量GLOBAL_NINJA是在全局环境中定义的, 变量functionActivity是在
函数reportActivity中定义的, 变量i与forMessage是在for循环的块级作用
域中定义的。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第16张图片
图5.12 当使用let与const声明变量时, 变量是在距离最近的环境中定义的。 在本例中, 变量 forMessage与i是在for循环的块级作用域中定义的, 变量functionActivity是在函数reportActivity中 定义的, 变量GLOBAL_NINJA是在全局环境中定义的。 现在已经介绍了const与let, 大量从其他 语言转向的新JavaScript开发者们得以平静下来。 JavaScript终于支持了其他类C语言相同的规 则。 因此, 本书的后续章节中, 我们将使用const与let代替var

我们理解了词法环境中是如何保存标识符的映射表, 理解了词法环
境与程序执行的关系, 那么接下来讨论在词法环境中定义的标识符的准
确的处理过程。 这将有助于我们理解一些常见的代码问题 。

5.5.3 在词法环境中注册标志符

JavaScript作为一门编程语言, 其设计的基本原则是易用性。 这也是
不需要指定函数返回值类型、 函数参数类型、 变量类型等的主要原因。
你已经了解到JavaScript是逐行执行的。 查看如下代码:

firstRonin = "Kiyokawa";
secondRonin = "Kondo";

将Kiyokawa赋值给标识符firstRonin, 将Kondo赋值给标识符
secondRonin。 看起来没有什么特殊的地方, 对吧? 但是看一下另一个
示例:

const firstRonin = "Kiyokawa";
check(firstRonin);
function check(ronin) {
    if (ronin === "Kiyokawa") {
        console.log( "The ronin was checked! ");
    }
}

在本例中, 我们将值Kiyokawa赋给firstRonin, 然后调用check函
数, 传入参数firstRonin。 先等一下, 如果JavaScript是逐行执行的, 我
们此时可以调用check函数吗? 程序还没执行到函数check的声明, 所以
JavaScript引擎不应该认识check函数。

但是, 从图5.13可以看出, 程序运行得很顺利。 JavaScript对于在哪
儿定义函数并不挑剔。 在调用函数之前或之后声明函数均可。 程序员对
于这一点不需要大惊小怪。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第17张图片
图5.13 虽然并未到达函数的定义部分, 但是函数确实可见

注册标识符的过程

但除了易用性, 代码如何逐行执行, JavaScript引擎是如何知道
check函数存在呢? 这说明JavaScript引擎耍了小把戏, JavaScript代码的
执行事实上是分两个阶段进行的。

一旦创建了新的词法环境, 就会执行第一阶段。 在第一阶段, 没有
执行代码, 但是JavaScript引擎会访问并注册在当前词法环境中所声明的
变量和函数。 JavaScript在第一阶段完成之后开始执行第二阶段, 具体如
何执行取决于变量的类型(let、 var、 const和函数声明) 以及环境类型
(全局环境、 函数环境或块级作用域) 。

具体的处理过程如下:

  1. 如果是创建一个函数环境, 那么创建形参及函数参数的默认
    值。 如果是非函数环境, 将跳过此步骤。
  2. 如果是创建全局或函数环境, 就扫描当前代码进行函数声明
    (不会扫描其他函数的函数体) , 但是不会扫描函数表达式或箭头函
    数。 对于所找到的函数声明, 将创建函数, 并绑定到当前环境与函数名
    相同的标识符上。 若该标识符已经存在, 那么该标识符的值将被重写。
    如果是块级作用域, 将跳过此步骤。
  3. 扫描当前代码进行变量声明。 在函数或全局环境中, 找到所有
    当前函数以及其他函数之外通过var声明的变量, 并找到所有在其他函
    数或代码块之外通过let或const定义的变量。 在块级环境中, 仅查找当前
    块中通过let或const定义的变量。 对于所查找到的变量, 若该标识符不存
    在, 进行注册并将其初始化为undefined。 若该标识符已经存在, 将保留
    其值。

整个处理过程如图5.14所示。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第18张图片
图5.14 注册标识符的过程取决于环境的类型

我们正在受到这种注册规则的影响。 你会看到一些常见的JavaScript
难题, 这些难题可能导致怪异的bug, 这类bug很容易产生但是难以理
解。 让我们从为什么可以在函数声明之前调用函数开始理解吧。

在函数声明之前调用函数

JavaScript易用性的一个典型特征, 是函数的声明顺序无关紧要。 使
用过Pascal语言的开发者可能还记得该语言严格的结构要求。 在
JavaScript中, 我们可以在函数声明之前对其进行调用。 下面查看清单
5.9中的代码。

清单5.9 在函数声明之前访问函数

if (typeof fun === "function") {
    console.log("fun is a function even though its definition isn't reached yet!"); // 若函数是作为函数声明进行定义的, 则可以在函数声明之前访问函数
}

if (typeof myFunExp === "undefined") {
    console.log("But we cannot access function expressions")
}

if (typeof myArrowFunction === "undefined") { // 若函数是通过函数表达式或箭头函数进行定义的, 则不可以在函数定义之前访问函数
    console.log("Nor arrow functions")
}

function fun() {} // 作为函数声明进行定义

var myFunExp = function() {};
var myArrow = (x) => {}; // myFunExpr指向函数表达式myArrow指向箭头函数

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第19张图片

我们甚至可以在函数定义之前访问函数。 我们可以这么做的原因是
fun是通过函数声明进行定义的, 第二阶段(如本章前文所述) 表明函
数已通过函数声明进行定义, 在当前词法环境创建时已在其他代码执行
之前注册了函数标识符。 所以, 在执行函数调用之前, fun函数已经存
在。

JavaScript引擎通过这种方式为开发者提供便利, 允许我们直接使用
函数的引用, 而不需要强制指定函数的定义顺序。 在代码执行之前, 函
数已经存在了。

需要注意的是, 这种情况仅针对函数声明有效。 函数表达式与箭头
函数都不在此过程中, 而是在程序执行过程中执行定义的。 这就是不能
访问myFunExp与myArrow函数的原因。

函数重载

第二个难题是处理重载函数标识符的问题。 让我们来看另外一个示
例, 如清单5.10所示。

清单5.10 重载函数标识符

if (typeof fun === "function") {
    console.log("We access the function"); // fun指向一个函数
}

var fun = 3; // 定义变量fun并赋值为数字3

if (typeof fun === "number") { // fun指向一个数字
    console.log("Now we access the number")
}

function fun() {} // 函数声明

if (typeof fun === "number") {
    console.log("Still a number"); // fun 仍然指向数字
}

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第20张图片

在清单5.10的示例中, 声明的变量与函数均使用相同的名字fun。 如
果你执行这段代码会发现, 两个断言assert都通过了。 在第一个断言
中, 标识符fun指向一个函数; 在第二个断言中, 标识符fun指向一个数
字。

JavaScript的这种行为是由标识符注册的结果直接导致的。 在处理过
程的第2步中, 通过函数声明进行定义的函数在代码执行之前对函数进
行创建, 并赋值给对应的标识符; 在第3步, 处理变量的声明, 那些在
当前环境中未声明的变量, 将被赋值为undefined。 在清单5.10的示例
中, 在第2步——注册函数声明时, 由于标识符fun已经存在, 并未被赋
值为undefined。 这就是第1个测试fun是否是函数的断言执行通过的原
因。 之后, 执行赋值语句var fun = 3, 将数字3赋值给标识符fun。 执行
完这个赋值语句之后, fun就不再指向函数了, 而是指向数字3。

在程序的实际执行过程中, 跳过了函数声明部分, 所以函数的声明
不会影响标识符fun的值。

变量提升(variable hoisting)

如果你已阅读关于解释处理标识符的一些JavaScript博客或图书, 你可能已经遇到这个 词:
变量提升。 例如, 变量的声明提升至函数顶部, 函数的声明提升至全局代码顶部。 但是,
正如在上述实例中看见的, 并没有那么简单。 变量和函数的声明并没有实际发生 移动。
只是在代码执行之前, 先在词法环境中进行注册。 虽然描述为提升了, 并且进行了定 义,
这样更容易理解JavaScript的作用域的工作原理, 但是, 我们可以通过词法环境对整个处
理过程进行更深入地理解, 了解真正的原理。

在下一节中, 本章前面部分所探讨的概念有助于更好地理解闭包。

5.6 研究闭包的工作原理

闭包可以访问创建函数时所在作用域内的全部变量, 还介绍了几种
闭包有用的方法。 例如, 通过闭包模拟私有变量, 通过回调函数使得代
码更加优雅。

闭包与作用域密切相关。 闭包对JavaScript的作用域规则产生了直接
影响。 因此在本节中, 我们将重新访问本章开头所示的代码。 但这一
次, 执行上下文与词法环境, 有助于我们理解闭包的工作原理。

5.6.1 回顾使用闭包模拟私有变量的代码

如前文所述, 通过闭包可以模拟私有变量。 现在我们对JavaScript的
作用域规则的工作原理有了深刻的理解, 回顾一下私有变量的示例。 这
一次, 我们将关注执行上下文与词法环境。 为了更方便, 请查看清单
5.11。

清单5.11 使用闭包模拟私有变量

function Ninja() { // 定义Ninja构造函数
    let feints = 0; // 佯攻 在构造函数内部声明变量。 由于该变量的作用域在构造函数内部, 因此, feints是一个私有变量

    this.getFints = function() { // 访问feints计数的方法
        return feints;
    };
    this.feint = function() {
        feints++;
    }; // 变量feints的增值方法。 由于feints是私有变量, 因此, 无法通过其他方法改变变量feints的值, 仅限于我们所给出的方法
}

const ninja1 = new Ninja(); // 现在开始测试, 首先创建一个Ninja的实例

if (ninja1.feints === undefined) { // 验证我们无法直接访问变量feints
    // inaccessible 无法接近
    console.log("And the private data is inaccessible to us.")
}

ninja1.feint(); // 调用feint方法, 增加变量feints的值进行统计调用次数

if (ninja1.getFints() === 1) {
    console.log("We're able to access the internal feint count.")
}; // 验证已经执行了递增

const ninja2 = new Ninja();
if (ninja2.getFints() === 0) {
    console.log("The second ninja object gets its own feints variable.")
}; // 当通过构造函数创建实例ninja2时,实例ninja2具有独立的变量feints

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第21张图片

现在, 我们分析第一个Ninja对象创建完成之后程序的状态, 如图
5.15所示。 我们可以利用标识符原理来更好地理解这种情况之下闭包的
工作原理。 JavaScript构造函数是通过关键字new调用的函数。 因此, 每
次调用构造函数时, 都会创建一个新的词法环境, 该词法环境保持构造
函数内部的局部变量。 在本例中, 创建了Ninja环境, 保持对变量feints
的跟踪

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第22张图片
图5.15 在构造器内定义的对象方法的闭包内实现私有变量

此外, 无论何时创建函数, 都会保持词法环境的引用(通过内置
[[Environment]]属性) 。 在本例中, Ninja构造函数内部, 我们创建了两
个函数: getFeints与feint, 均有Ninja环境的引用, 因为Ninja环境是这两
个函数创建时所处的环境。

getFeints与feint函数是新创建的ninja的对象方法(如前文所述, 可
通过this关键字访问) 。 因此, 可以在Ninja构造函数外部访问getFeints
与feint函数, 这样实际上就创建了包含feints变量的闭包。

当再创建一个Ninja的实例, 即ninja2对象时, 将重复整个过程。 图
5.16显示了创建第二个Ninja对象之后的程序状态。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第23张图片
图5.16 每个实例创建“私有”实例变量的方法

每一个通过Ninja构造函数创建的对象实例均获得了各自的方法
(ninja1.getFeints与ninja2.getFeints是不同的) , 当调用构造函数时, 各
自的实例方法包含各自的变量。 这些“私有变量”只能通过构造函数内定
义的对象方法进行访问, 不允许直接访问。 现在让我们看看当
ninja2.getFeints方法调用时发生了什么。 图5.17显示了细节。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第24张图片
图5.17 在调用ninja2.getFeints()时, 执行环境与词法环境的状态。 创建新的getFeints环境, 该环 境具有构造函数创建ninja2对象时所在的环境。 getFeints函数可以访问“私有”feints变量

在调用ninja2.getFeints方法之前, JavaScript引擎正在执行全局代
码。 我们的程序处于全局执行上下文状态, 是执行栈里的唯一上下文。
同时, 唯一活跃的词法环境是全局环境, 与全局执行上下文关联。

当调用ninja2.getFeints()时, 我们调用的是ninja2对象的getFeints方
法。 由于每次调用函数时均会创建新的执行上下文, 因此创建了新的
getFeints执行环境并推入执行栈。 这同时引起创建新的词法环境, 词法
环境通常用于保持跟踪函数中定义的变量。 另外, getFeints词法环境包
含了getFeints函数被创建时所处的环境, 当ninja2对象构建时, Ninja环
境是活跃的。

现在让我们来了解试图获取feints变量时是如何工作的。 首先, 访
问活跃的getFeints词法环境。 因为在getFeints函数内部未定义任何变
量, 该词法环境是空的, 找不到feints变量。 接下来, 在当前词法环境
的外部环境进行查找——本例中, 当创建ninja2对象时, Ninja环境处于
活跃状态。 Ninja环境中具有feints变量的引用, 完成搜索过程, 就是那
么简单。

我们理解了在处理闭包时, 执行上下文与词法环境所扮演的角色,
那么接下来, 我们将关注“私有”变量, 为什么要保持“私有”变量的引
用。 可能你已经发现了, 这些“私有”变量并不是对象的私有属性, 但是
可以通过构造函数所创建的对象方法去访问这些变量。 让我们看看这种
方式产生的有趣的副作用。

5.6.2 私有变量的警告

JavaScript从未阻止我们将一个对象中创建的属性复制给另一个对
象。 例如, 我们可以很容易地将清单5.11中的代码重写成清单5.12所示
的样子。
清单5.12 通过函数访问私有变量, 而不是通过对象访问 JavaScript从未阻止我们将一个对象中创建的属性复制给另一个对
象。 例如, 我们可以很容易地将清单5.11中的代码重写成清单5.12所示
的样子。

清单5.12 通过函数访问私有变量, 而不是通过对象访问

function Ninja() {
    let feints = 0;

    this.getFints = function() {
        return feints;
    };
    this.feint = function() {
        feints++;
    };
}

const ninja1 = new Ninja();
ninja1.feint();

const imposter = {}; // 冒名顶替者
imposter.getFints = ninja1.getFints; // 将ninja1的对象方法getFeints赋值给对象imposter

if (imposter.getFints() === 1) {

    console.log("The imposter has access to the feints variable!")
    // 验证我们访问ninja1对象的私有变量
}

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第25张图片

清单5.12将代码进行了修改, 将ninja1的对象方法getFeints赋值给一
个新的imposter对象。 然后, 当我们通过对象impostor的getFeints方法,
可以测试是否可以访问ninja1对象的私有变量, 这样, 验证了我们是在
假装这些是“私有”变量, 如图5.18所示。

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第26张图片

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第27张图片
图5.18 尽管该函数是对象的方法, 但是我们仍可以通过该函数访问“私有”变量

本例表明了在JavaScript中没有真正的私有对象属性, 但是可以通过
闭包实现一种可接受的“私有”变量的方案。 尽管如此, 虽然不是真正的
私有变量, 但是许多开发者发现这是一种隐藏信息的有用方式。

5.6.3 回顾闭包和回调函数的栗子

让我们回顾一下简单的动画示例中, 使用计时器的回调。 这一次,
我们将对两个对象使用动画, 如清单5.13所示。

清单5.13 在计时器的回调函数中使用闭包




    
    
    
    Document
    


First Box
Second Box

在本章开始部分, 我们使用闭包来简化单页面中的多个对象动画。
现在来看看如图5.19所示的词法环境。

图5.19 通过创建多个闭包, 我们可以同时做许多事。 每当interval执行时, 回调函数重新激活 创建该回调函数时所处的环境。 每个回调函数闭包自动保存各自的变量组

每次调用animateIt函数时, 均会创建新的词法环境❶❷, 该词法环
境保存了动画所需的重要变量(elementId、 elem、 动画元素、 tick、 计
数次数、 timer、 动画计数器的ID) 。 只要至少有一个通过闭包访问这些
变量的函数存在, 这个环境就会一直保持。 在本例中, 浏览器会一直保
持setInterval的回调函数, 直到调用clearInterval方法。 随后, 当一个计
时器到期, 浏览器会调用对应的回调函数, 通过回调函数的闭包访问创
建闭包时的变量。 这样避免了手动匹配回调函数的麻烦, 并激活变量
(❸❹❺) , 极大地简化代码。

以上就是关于闭包和作用域的全部内容。 现在简要地回顾一下本章
的内容, 随后进入下一章的内容。 在下一章中, 我们将探讨两个ES6的
重要概念: generators与promises, 这对于写异步代码很有帮助。

5.7 小结

  • 通过闭包可以访问创建闭包时所处环境中的全部变量。 闭包为函数
    创建时所处的作用域中的函数和变量, 创建“安全气泡”。 通过这种
    的方式, 即使创建函数时所处的作用域已经消失, 但是函数仍然能
    够获得执行时所需的全部内容。

  • 我们可以使用闭包的这些高级功能:

    • 通过构造函数内的变量以及构造方法来模拟对象的私有属性。
    • 处理回调函数, 简化代码。
  • JavaScript引擎通过执行上下文栈(调用栈) 跟踪函数的执行。 每次
    调用函数时, 都会创建新的函数执行上下文, 并推入调用栈顶端。
    当函数执行完成后, 对应的执行上下文将从调用栈中推出。

  • JavaScript引擎通过词法环境跟踪标识符(俗称作用域) 。

  • 在JavaScript中, 我们可以定义全局级别、 函数级别甚至块级别的变
    量。

  • 可以使用关键字var、 let与const定义变量:

    • 关键字var定义距离最近的函数级变量或全局变量。
    • 关键字let与const定义距离最近级别的变量, 包括块级变量。 块
      级变量在ES6之前版本的JavaScript中是无法实现的。 此外, 通
      过关键字const允许定义只能赋值一次的变量。
  • 闭包是JavaScript作用域规则的副作用。 当函数创建时所在的作用域
    消失后, 仍然能够调用函数

5.8 练习

1. 闭包允许函数(a ) 。

​ a. 访问函数创建时所在的作用域内的变量
​ b. 访问函数调用时所在的作用域内的变量

2. 闭包是(b ) 。

​ a. 消耗代码成本
​ b. 消耗内存成本
​ c. 消耗处理成本

3. 在如下代码示例中, 指出通过闭包访问的变量:要回答这道题你要明白闭包的概念是什么?!

【 闭包指的是那些引用了另一个函数作用域中变量的函 数,通常是在嵌套函数中实现的】

function Samurai(name) {
    const weapon = "katana"; // 武士刀
    this.getWeapon = function() {
        return weapon;
    };
    this.getName = function() {
        return name;
    }
    this.message = name + " wielding a " + weapon;
    this.getMessage = function() {
        return this.message;
    }
}
const samurai = new Samurai("Hattori"); // 服部
console.log(samurai.getWeapon());
console.log(samurai.getName());
console.log(samurai.getMessage());

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第28张图片

4. 在如下代码中, 创建了几个执行上下文? 执行上下文栈的最大
长度是多少?

function perform(ninja) {
    sneak(ninja);
    infiltrate(ninja);
}
function sneak(ninja) {
    return ninja + " skulking";
}
function infiltrate(ninja) {
    return ninja + " infiltrating";
}
perfom("Kuma");

【这道题我还真的不会!】

5. 在JavaScript中, 使用哪个关键字可以创建不允许重新赋值为全
新的值的变量?

const

6. var与let的区别是什么?

【重点是有没有块级作用域】var 是没有块级作用域的!

7. 如下代码中, 在哪儿会抛出异常?为什么?

getNinja();
getSamurai(); // 在这个地方会抛出异常!
function getNinja() {
     console.log("Yoshi");
}
var getSamurai = () => "Hattori";

《JavaScript忍者秘籍》(第二版)- 第5章 -精通函数:闭包和作用域_第29张图片

你可能感兴趣的:(编程语言,java,javascript,webgl,relativelayout)