JavaScript 是一种非常面向函数的语言。它给了我们很大的自由度。在 JavaScript 中,我们可以动态创建函数,可以将函数作为参数传递给另一个函数,并在完全不同的代码位置进行调用。

我们已经知道函数可以访问其外部的变量。

现在,让我们扩展知识,来看看更复杂的场景。

我们将在这探讨一下 let/const

在 JavaScript 中,有三种声明变量的方式:letconst(现代方式),var(过去留下来的方式)。

  • 在本文的示例中,我们将使用 let 声明变量。
  • const 声明的变量的行为也相同(译注:与 let 在作用域等特性上是相同的),因此,本文也涉及用 const 进行变量声明。
  • 旧的 var 与上面两个有着明显的区别,我们将在 旧时的 “var” 中详细介绍。

代码块

如果在代码块 {...} 内声明了一个变量,那么这个变量只在该代码块内可见。

例如:

  1. {
  2. // 使用在代码块外不可见的局部变量做一些工作
  3. let message = "Hello"; // 只在此代码块内可见
  4. alert(message); // Hello
  5. }
  6. alert(message); // Error: message is not defined

我们可以使用它来隔离一段代码,该段代码执行自己的任务,并使用仅属于自己的变量:

  1. {
  2. // 显示 message
  3. let message = "Hello";
  4. alert(message);
  5. }
  6. {
  7. // 显示另一个 message
  8. let message = "Goodbye";
  9. alert(message);
  10. }

这里如果没有代码块则会报错

请注意,如果我们使用 let 对已存在的变量进行重复声明,如果对应的变量没有单独的代码块,则会出现错误:

  1. // 显示 message
  2. let message = "Hello";
  3. alert(message);
  4. // 显示另一个 message
  5. let message = "Goodbye"; // Error: variable already declared
  6. alert(message);

对于 ifforwhile 等,在 {...} 中声明的变量也仅在内部可见:

  1. if (true) {
  2. let phrase = "Hello!";
  3. alert(phrase); // Hello!
  4. }
  5. alert(phrase); // Error, no such variable!

在这儿,当 if 执行完毕,则下面的 alert 将看不到 phrase,因此会出现错误。(译注:就算下面的 alert 想在 if 没执行完成时去取 phrase(虽然这种情况不可能发生)也是取不到的,因为 let 声明的变量在代码块外不可见。)

太好了,因为这就允许我们创建特定于 if 分支的块级局部变量。

对于 forwhile 循环也是如此:

  1. for (let i = 0; i < 3; i++) {
  2. // 变量 i 仅在这个 for 循环的内部可见
  3. alert(i); // 0,然后是 1,然后是 2
  4. }
  5. alert(i); // Error, no such variable

从视觉上看,let i 位于 {...} 之外。但是 for 构造在这里很特殊:在其中声明的变量被视为块的一部分。

嵌套函数

当一个函数是在另一个函数中创建的时,那么该函数就被称为“嵌套”的。

在 JavaScript 中很容易实现这一点。

我们可以使用嵌套来组织代码,比如这样:

  1. function sayHiBye(firstName, lastName) {
  2. // 辅助嵌套函数使用如下
  3. function getFullName() {
  4. return firstName + " " + lastName;
  5. }
  6. alert( "Hello, " + getFullName() );
  7. alert( "Bye, " + getFullName() );
  8. }

这里创建的 嵌套 函数 getFullName() 是为了更加方便。它可以访问外部变量,因此可以返回全名。嵌套函数在 JavaScript 中很常见。

更有意思的是,可以返回一个嵌套函数:作为一个新对象的属性或作为结果返回。之后可以在其他地方使用。不论在哪里调用,它仍然可以访问相同的外部变量。

下面的 makeCounter 创建来一个 “counter” 函数,该函数在每次调用时返回下一个数字:

  1. function makeCounter() {
  2. let count = 0;
  3. return function() {
  4. return count++;
  5. };
  6. }
  7. let counter = makeCounter();
  8. alert( counter() ); // 0
  9. alert( counter() ); // 1
  10. alert( counter() ); // 2

尽管很简单,但稍加变型就具有很强的实际用途,比如,用作 随机数生成器 以生成用于自动化测试的随机数值。

这是如何运作的呢?如果我们创建多个计数器,它们会是独立的吗?这里的变量是怎么回事?

理解这些内容对于掌握 JavaScript 的整体知识很有帮助,并且对于应对更复杂的场景也很有益处。因此,让我们继续深入探究。

词法环境

这是龙!

深入的技术讲解就在下面。

尽管我很想避免编程语言的一些底层细节,但是如果没有这些细节,它们就不完整,所以请准备开始学习吧!

为了使内容更清晰,这里将分步骤进行讲解。

Step 1. 变量

在 JavaScript 中,每个运行的函数,代码块 {...} 以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。

词法环境对象由两部分组成:

  1. 环境记录(Environment Record) —— 一个存储所有局部变量作为其属性(包括一些其他信息,例如 this 的值)的对象。
  2. 外部词法环境 的引用,与外部代码相关联。

一个“变量”只是 环境记录 这个特殊的内部对象的一个属性。“获取或修改变量”意味着“获取或修改词法环境的一个属性”。

举个例子,这段没有函数的简单的代码中只有一个词法环境:

闭包 - 图1

这就是所谓的与整个脚本相关联的 全局 词法环境。

在上面的图片中,矩形表示环境记录(变量存储),箭头表示外部引用。全局词法环境没有外部引用,所以箭头指向了 null

随着代码开始并继续运行,词法环境发生了变化。

这是更长的代码:

闭包 - 图2

右侧的矩形演示了执行过程中全局词法环境的变化:

  1. 当脚本开始运行,词法环境预先填充了所有声明的变量。
    • 最初,它们处于“未初始化(Uninitialized)”状态。这是一种特殊的内部状态,这意味着引擎知道变量,但是在用 let 声明前,不能引用它。几乎就像变量不存在一样。
  2. 然后 let phrase 定义出现了。它尚未被赋值,因此它的值为 undefined。从这一刻起,我们就可以使用变量了。
  3. phrase 被赋予了一个值。
  4. phrase 的值被修改。

现在看起来都挺简单的,是吧?

  • 变量是特殊内部对象的属性,与当前正在执行的(代码)块/函数/脚本有关。
  • 操作变量实际上是操作该对象的属性。

词法环境是一个规范对象

“词法环境”是一个规范对象(specification object):它仅仅是存在于 编程语言规范 中的“理论上”存在的,用于描述事物如何运作的对象。我们无法在代码中获取该对象并直接对其进行操作。

但 JavaScript 引擎同样可以优化它,比如清除未被使用的变量以节省内存和执行其他内部技巧等,但显性行为应该是和上述的无差。

Step 2. 函数声明

一个函数其实也是一个值,就像变量一样。

不同之处在于函数声明的初始化会被立即完成。

当创建了一个词法环境(Lexical Environment)时,函数声明会立即变为即用型函数(不像 let 那样直到声明处才可用)。

这就是为什么我们可以在(函数声明)的定义之前调用函数声明。

例如,这是添加一个函数时全局词法环境的初始状态:

闭包 - 图3

正常来说,这种行为仅适用于函数声明,而不适用于我们将函数分配给变量的函数表达式,例如 let say = function(name)...

Step 3. 内部和外部的词法环境

在一个函数运行时,在调用刚开始时,会自动创建一个新的词法环境以存储这个调用的局部变量和参数。

例如,对于 say("John"),它看起来像这样(当前执行位置在箭头标记的那一行上):

闭包 - 图4

在这个函数调用期间,我们有两个词法环境:内部一个(用于函数调用)和外部一个(全局):

  • 内部词法环境与 say 的当前执行相对应。它具有一个单独的属性:name,函数的参数。我们调用的是 say("John"),所以 name 的值为 "John"
  • 外部词法环境是全局词法环境。它具有 phrase 变量和函数本身。

内部词法环境引用了 outer

当代码要访问一个变量时 —— 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。

如果在任何地方都找不到这个变量,那么在严格模式下就会报错(在非严格模式下,为了向下兼容,给未定义的变量赋值会创建一个全局变量)。

在这个示例中,搜索过程如下:

  • 对于 name 变量,当 say 中的 alert 试图访问 name 时,会立即在内部词法环境中找到它。
  • 当它试图访问 phrase 时,然而内部没有 phrase,所以它顺着对外部词法环境的引用找到了它。

    闭包 - 图5

Step 4. 返回函数

让我们回到 makeCounter 这个例子。

  1. function makeCounter() {
  2. let count = 0;
  3. return function() {
  4. return count++;
  5. };
  6. }
  7. let counter = makeCounter();

在每次 makeCounter() 调用的开始,都会创建一个新的词法环境对象,以存储该 makeCounter 运行时的变量。

因此,我们有两层嵌套的词法环境,就像上面的示例一样:

闭包 - 图6

不同的是,在执行 makeCounter() 的过程中创建了一个仅占一行的嵌套函数:return count++。我们尚未运行它,仅创建了它。

所有的函数在“诞生”时都会记住创建它们的词法环境。从技术上讲,这里没有什么魔法:所有函数都有名为 [[Environment]] 的隐藏属性,该属性保存了对创建该函数的词法环境的引用。

闭包 - 图7

因此,counter.[[Environment]] 有对 {count: 0} 词法环境的引用。这就是函数记住它创建于何处的方式,与函数被在哪儿调用无关。[[Environment]] 引用在函数创建时被设置并永久保存。

稍后,当调用 counter() 时,会为该调用创建一个新的词法环境,并且其外部词法环境引用获取于 counter.[[Environment]]

闭包 - 图8

现在,当 counter() 中的代码查找 count 变量时,它首先搜索自己的词法环境(为空,因为那里没有局部变量),然后是外部 makeCounter() 的词法环境,并且在哪里找到就在哪里修改。

在变量所在的词法环境中更新变量。

这是执行后的状态:

闭包 - 图9

如果我们调用 counter() 多次,count 变量将在同一位置增加到 23 等。

闭包

开发者通常应该都知道“闭包”这个通用的编程术语。

闭包) 是指内部函数总是可以访问其所在的外部函数中声明的变量和参数,即使在其外部函数被返回(寿命终结)了之后。在某些编程语言中,这是不可能的,或者应该以特殊的方式编写函数来实现。但是如上所述,在 JavaScript 中,所有函数都是天生闭包的(只有一个例外,将在 “new Function” 语法 中讲到)。

也就是说:JavaScript 中的函数会自动通过隐藏的 [[Environment]] 属性记住创建它们的位置,所以它们都可以访问外部变量。

在面试时,前端开发者通常会被问到“什么是闭包?”,正确的回答应该是闭包的定义,并解释清楚为什么 JavaScript 中的所有函数都是闭包的,以及可能的关于 [[Environment]] 属性和词法环境原理的技术细节。

垃圾收集

通常,函数调用完成后,会将词法环境和其中的所有变量从内存中删除。因为现在没有任何对它们的引用了。与 JavaScript 中的任何其他对象一样,词法环境仅在可达时才会被保留在内存中。

……但是,如果有一个嵌套函数在函数结束后仍可达,则它具有引用词法环境的 [[Environment]] 属性。

在下面这个例子中,即使在函数执行完成后,词法环境仍然可达。因此,此嵌套函数仍然有效。

例如:

  1. function f() {
  2. let value = 123;
  3. return function() {
  4. alert(value);
  5. }
  6. }
  7. let g = f(); // g.[[Environment]] 存储了对相应 f() 调用的词法环境的引用

请注意,如果多次调用 f(),并且返回的函数被保存,那么所有相应的词法环境对象也会保留在内存中。下面代码中有三个这样的函数:

  1. function f() {
  2. let value = Math.random();
  3. return function() { alert(value); };
  4. }
  5. // 数组中的 3 个函数,每个都与来自对应的 f() 的词法环境相关联
  6. let arr = [f(), f(), f()];

当词汇环境对象变得不可达时,它就会死去(就像其他任何对象一样)。换句话说,它仅在至少有一个嵌套函数引用它时才存在。

在下面的代码中,嵌套函数被删除后,其封闭的词法环境(以及其中的 value)也会被从内存中删除:

  1. function f() {
  2. let value = 123;
  3. return function() {
  4. alert(value);
  5. }
  6. }
  7. let g = f(); // 当 g 函数存在时,该值会被保留在内存中
  8. g = null; // ……现在内存被清理了

实际开发中的优化

正如我们所看到的,理论上当函数可达时,它外部的所有变量也都将存在。

但在实际中,JavaScript 引擎会试图优化它。它们会分析变量的使用情况,如果从代码中可以明显看出有未使用的外部变量,那么就会将其删除。

在 V8(Chrome,Opera)中的一个重要的副作用是,此类变量在调试中将不可用。

打开 Chrome 浏览器的开发者工具,并尝试运行下面的代码。

当代码执行暂停时,在控制台中输入 alert(value)

  1. function f() {
  2. let value = Math.random();
  3. function g() {
  4. debugger; // 在 Console 中:输入 alert(value); No such variable!
  5. }
  6. return g;
  7. }
  8. let g = f();
  9. g();

正如你所见的 —— No such variable! 理论上,它应该是可以访问的,但引擎把它优化掉了。

这可能会导致有趣的(如果不是那么耗时的)调试问题。其中之一 —— 我们可以看到的是一个同名的外部变量,而不是预期的变量:

  1. let value = "Surprise!";
  2. function f() {
  3. let value = "the closest value";
  4. function g() {
  5. debugger; // 在 console 中:输入 alert(value); Surprise!
  6. }
  7. return g;
  8. }
  9. let g = f();
  10. g();

V8 引擎的这个特性你真的应该知道。如果你要使用 Chrome/Opera 进行代码调试,迟早会遇到这样的问题。

这不是调试器的 bug,而是 V8 的一个特别的特性。也许以后会被修改。你始终可以通过运行本文中的示例来进行检查。

补充内容

说明

为了更清晰地讲解闭包,本文经过大幅重写,以下内容是重写时部分被优化掉的内容,译者认为还是很有学习价值的,遂保留下来供大家学习。

代码块

我们可以使用“空”的代码块将变量隔离到“局部作用域”中。

比如,在 Web 浏览器中,所有脚本都共享同一个全局环境。如果我们在一个脚本中创建一个全局变量,对于其他脚本来说它也是可用的。但是如果两个脚本有使用同一个变量并且相互覆盖,那么这会成为冲突的根源。

如果变量名是一个被广泛使用的词,并且不同脚本的作者可能彼此也不知道。

如果我们要避免这个,我们可以使用代码块来隔离整个脚本或其中一部分:

  1. {
  2. // 用局部变量完成一些不应该被外面访问的工作
  3. let message = "Hello";
  4. alert(message); // Hello
  5. }
  6. alert(message); // Error: message is not defined

这是因为代码块有其自身的词法环境,块之外(或另一个脚本内)的代码访问不到代码块内的变量。

任务

函数会选择最新的内容吗?

重要程度: 5

函数 sayHi 使用外部变量。当函数运行时,将使用哪个值?

  1. let name = "John";
  2. function sayHi() {
  3. alert("Hi, " + name);
  4. }
  5. name = "Pete";
  6. sayHi(); // 会显示什么:"John" 还是 "Pete"?

这种情况在浏览器和服务器端开发中都很常见。一个函数可能被计划在创建之后一段时间后才执行,例如在用户行为或网络请求之后。

因此,问题是:它会接收最新的修改吗?

解决方案

答案:Pete

函数将从内到外依次在对应的词法环境中寻找目标变量,它使用最新的值。

旧变量值不会保存在任何地方。当一个函数想要一个变量时,它会从自己的词法环境或外部词法环境中获取当前值。

哪些变量可用呢?

重要程度: 5

下面的 makeWorker 函数创建了另一个函数并返回该函数。可以在其他地方调用这个新函数。

它是否可以从它被创建的位置或调用位置(或两者)访问外部变量?

  1. function makeWorker() {
  2. let name = "Pete";
  3. return function() {
  4. alert(name);
  5. };
  6. }
  7. let name = "John";
  8. // create a function
  9. let work = makeWorker();
  10. // call it
  11. work(); // 会显示什么?

会显示哪个值?“Pete” 还是 “John”?

解决方案

答案:Pete.

下方代码中的函数 work() 在其被创建的位置通过外部词法环境引用获取 name

闭包 - 图10

所以这里的结果是 "Pete"

但如果在 makeWorker() 中没有 let name,那么将继续向外搜索并最终找到全局变量,正如我们可以从上图中看到的那样。在这种情况下,结果将是 "John"

Counter 是独立的吗?

重要程度: 5

在这儿我们用相同的 makeCounter 函数创建了两个计数器(counters):countercounter2

它们是独立的吗?第二个 counter 会显示什么?0,12,3 还是其他?

  1. function makeCounter() {
  2. let count = 0;
  3. return function() {
  4. return count++;
  5. };
  6. }
  7. let counter = makeCounter();
  8. let counter2 = makeCounter();
  9. alert( counter() ); // 0
  10. alert( counter() ); // 1
  11. alert( counter2() ); // ?
  12. alert( counter2() ); // ?

解决方案

答案是:0,1。

函数 countercounter2 是通过 makeCounter 的不同调用创建的。

因此,它们具有独立的外部词法环境,每一个都有自己的 count

Counter 对象

重要程度: 5

这里通过构造函数创建了一个 counter 对象。

它能正常工作吗?它会显示什么呢?

  1. function Counter() {
  2. let count = 0;
  3. this.up = function() {
  4. return ++count;
  5. };
  6. this.down = function() {
  7. return --count;
  8. };
  9. }
  10. let counter = new Counter();
  11. alert( counter.up() ); // ?
  12. alert( counter.up() ); // ?
  13. alert( counter.down() ); // ?

解决方案

当然行得通。

这两个嵌套函数都是在同一个词法环境中创建的,所以它们可以共享对同一个 count 变量的访问:

  1. function Counter() {
  2. let count = 0;
  3. this.up = function() {
  4. return ++count;
  5. };
  6. this.down = function() {
  7. return --count;
  8. };
  9. }
  10. let counter = new Counter();
  11. alert( counter.up() ); // 1
  12. alert( counter.up() ); // 2
  13. alert( counter.down() ); // 1

if 内的函数

看看下面这个代码。最后一行代码的执行结果是什么?

  1. let phrase = "Hello";
  2. if (true) {
  3. let user = "John";
  4. function sayHi() {
  5. alert(`${phrase}, ${user}`);
  6. }
  7. }
  8. sayHi();

解决方案

答案:error

函数 sayHi 是在 if 内声明的,所以它只存在于 if 中。外部是没有 sayHi 的。

闭包 sum

重要程度: 4

编写一个像 sum(a)(b) = a+b 这样工作的 sum 函数。

是的,就是这种通过双括号的方式(并不是错误)。

举个例子:

  1. sum(1)(2) = 3
  2. sum(5)(-1) = 4

解决方案

为了使第二个括号有效,第一个(括号)必须返回一个函数。

就像这样:

  1. function sum(a) {
  2. return function(b) {
  3. return a + b; // 从外部词法环境获得 "a"
  4. };
  5. }
  6. alert( sum(1)(2) ); // 3
  7. alert( sum(5)(-1) ); // 4

变量可见吗?

重要程度: 4

下面这段代码的结果会是什么?

  1. let x = 1;
  2. function func() {
  3. console.log(x); // ?
  4. let x = 2;
  5. }
  6. func();

P.S. 这个任务有一个陷阱。解决方案并不明显。

解决方案

答案:error

你运行一下试试:

  1. let x = 1;
  2. function func() {
  3. console.log(x); // ReferenceError: Cannot access 'x' before initialization
  4. let x = 2;
  5. }
  6. func();

在这个例子中,我们可以观察到“不存在”的变量和“未初始化”的变量之间的特殊差异。

你可能已经在 闭包 中学过了,从程序执行进入代码块(或函数)的那一刻起,变量就开始进入“未初始化”状态。它一直保持未初始化状态,直至程序执行到相应的 let 语句。

换句话说,一个变量从技术的角度来讲是存在的,但是在 let 之前还不能使用。

下面的这段代码证实了这一点。

  1. function func() {
  2. // 引擎从函数开始就知道局部变量 x,
  3. // 但是变量 x 一直处于“未初始化”(无法使用)的状态,直到结束 let(“死区”)
  4. // 因此答案是 error
  5. console.log(x); // ReferenceError: Cannot access 'x' before initialization
  6. let x = 2;
  7. }

变量暂时无法使用的区域(从代码块的开始到 let)有时被称为“死区”。

通过函数筛选

重要程度: 5

我们有一个内建的数组方法 arr.filter(f)。它通过函数 f 过滤元素。如果它返回 true,那么该元素会被返回到结果数组中。

制造一系列“即用型”过滤器:

  • inBetween(a, b) —— 在 ab 之间或与它们相等(包括)。
  • inArray([...]) —— 包含在给定的数组中。

用法如下所示:

  • arr.filter(inBetween(3,6)) —— 只挑选范围在 3 到 6 的值。
  • arr.filter(inArray([1,2,3])) —— 只挑选与 [1,2,3] 中的元素匹配的元素。

例如:

  1. /* .. inBetween 和 inArray 的代码 */
  2. let arr = [1, 2, 3, 4, 5, 6, 7];
  3. alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6
  4. alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

打开带有测试的沙箱。

解决方案

inBetween 筛选器

inBetween 筛选器

  1. function inBetween(a, b) {
  2. return function(x) {
  3. return x >= a && x <= b;
  4. };
  5. }
  6. let arr = [1, 2, 3, 4, 5, 6, 7];
  7. alert( arr.filter(inBetween(3, 6)) ); // 3,4,5,6

inArray 筛选器

inArray 筛选器

  1. function inArray(arr) {
  2. return function(x) {
  3. return arr.includes(x);
  4. };
  5. }
  6. let arr = [1, 2, 3, 4, 5, 6, 7];
  7. alert( arr.filter(inArray([1, 2, 10])) ); // 1,2

使用沙箱的测试功能打开解决方案。

按字段排序

重要程度: 5

我们有一组要排序的对象:

  1. let users = [
  2. { name: "John", age: 20, surname: "Johnson" },
  3. { name: "Pete", age: 18, surname: "Peterson" },
  4. { name: "Ann", age: 19, surname: "Hathaway" }
  5. ];

通常的做法应该是这样的:

  1. // 通过 name (Ann, John, Pete)
  2. users.sort((a, b) => a.name > b.name ? 1 : -1);
  3. // 通过 age (Pete, Ann, John)
  4. users.sort((a, b) => a.age > b.age ? 1 : -1);

我们可以让它更加简洁吗,比如这样?

  1. users.sort(byField('name'));
  2. users.sort(byField('age'));

这样我们就只需要写 byField(fieldName),而不是写一个函数。

编写函数 byField 来实现这个需求。

解决方案

  1. let users = [
  2. { name: "John", age: 20, surname: "Johnson" },
  3. { name: "Pete", age: 18, surname: "Peterson" },
  4. { name: "Ann", age: 19, surname: "Hathaway" }
  5. ];
  6. function byField(field) {
  7. return (a, b) => a[field] > b[field] ? 1 : -1;
  8. }
  9. users.sort(byField('name'));
  10. users.forEach(user => alert(user.name)); // Ann, John, Pete
  11. users.sort(byField('age'));
  12. users.forEach(user => alert(user.name)); // Pete, Ann, John

函数大军

重要程度: 5

下列的代码创建一个 shooters 数组。

每个函数都应该输出其编号。但好像出了点问题……

  1. function makeArmy() {
  2. let shooters = [];
  3. let i = 0;
  4. while (i < 10) {
  5. let shooter = function() { // shooter 函数
  6. alert( i ); // 应该显示其编号
  7. };
  8. shooters.push(shooter);
  9. i++;
  10. }
  11. return shooters;
  12. }
  13. let army = makeArmy();
  14. army[0](); // 编号为 0 的 shooter 值为 10
  15. army[5](); // 编号为 5 的 shooter 值也是 10……
  16. // ... 所有的 shooter 的值都是 10,而不是他们的编号 0, 1, 2, 3...

为什么所有的 shooter 显示同样的值?修改代码以让代码正常工作。

打开带有测试的沙箱。

解决方案

让我们检查一下 makeArmy 内部做了什么,那么答案就显而易见了。

  1. 它创建了一个空数组 shooters

    1. let shooters = [];
  2. 在循环中,通过 shooters.push(function...) 填充它(数组)。

    每个元素都是函数,所以数组看起来是这样的:

    1. shooters = [
    2. function () { alert(i); },
    3. function () { alert(i); },
    4. function () { alert(i); },
    5. function () { alert(i); },
    6. function () { alert(i); },
    7. function () { alert(i); },
    8. function () { alert(i); },
    9. function () { alert(i); },
    10. function () { alert(i); },
    11. function () { alert(i); }
    12. ];
  3. 该数组返回自函数。

随后,army[5]() 从数组中获得元素 army[5](函数)并调用。

为什么现在所有函数显示的都一样呢?

这是因为 shooter 函数内没有局部变量 i。当调用一个这样的函数时,i 是来自于外部词法环境的。

i 的值是什么呢?

如果我们查看一下源头:

  1. function makeArmy() {
  2. ...
  3. let i = 0;
  4. while (i < 10) {
  5. let shooter = function() { // shooter 函数
  6. alert( i ); // 应该显示它自己的编号
  7. };
  8. ...
  9. }
  10. ...
  11. }

……我们可以看到它存在于当前 makeArmy() 运行相关的词法环境中。但调用 army[5]() 时,makeArmy 已经完运行完了,i 现在为结束时的值:10while 结束时)。

因此,所有的 shooter 获得的都是外部词法环境中的同一个值,即最后的 i=10

我们可以通过将变量定义移动到循环中来修复它:

  1. function makeArmy() {
  2. let shooters = [];
  3. for(let i = 0; i < 10; i++) {
  4. let shooter = function() { // shooter 函数
  5. alert( i ); // 应该显示它自己的编号
  6. };
  7. shooters.push(shooter);
  8. }
  9. return shooters;
  10. }
  11. let army = makeArmy();
  12. army[0](); // 0
  13. army[5](); // 5

现在正常工作了,因为每次执行代码块 for (let i=0...) {...} 中的代码时,都会为其创建一个新的词法环境,其中具有对应的 i 值。

所以,现在 i 值的位置更近了(译注:指赚到了更内部的词法环境)。现在它不是在 makeArmy() 的词法环境中,而是在与当前循环迭代相对应的词法环境中。这就是它为什么现在可以正常工作了。

闭包 - 图11

这里我们把 while 改写为了 for

其他技巧也是可以的,让我们了解一下,以便更好地理解这个问题:

  1. function makeArmy() {
  2. let shooters = [];
  3. let i = 0;
  4. while (i < 10) {
  5. let j = i;
  6. let shooter = function() { // shooter 函数
  7. alert( j ); // 应该显示当前的编号
  8. };
  9. shooters.push(shooter);
  10. i++;
  11. }
  12. return shooters;
  13. }
  14. let army = makeArmy();
  15. army[0](); // 0
  16. army[5](); // 5

whilefor 循环差不多,每次运行都会创建了一个新的词法环境。所以在这里我们能确保 shooter 能够获取正确的值。

我们复制 let j = i。这个操作创建了循环体局部变量 j,并将 i 的值复制给了它。原始值是按值传递的,所以实际上,我们获得了属于当前循环迭代的完全独立的 i 的副本。

使用沙箱的测试功能打开解决方案。