Skip to content

我一直不理解闭包(直到有人这样跟我解释) #18

Description

@Clearives

翻译文章信息

正文

123

正如标题所述,JavaScript中的闭包对我来说一直很迷惑。我读过很多篇文章,工作中也使用过闭包,有时候我甚至不知道自己有用到闭包。

最近我参与了一个关于解释闭包的交流,最终点醒了我。下面我将采用这种方式在这篇文章中来解释闭包,让我赞扬Codesmith的优秀人才及他们的JavaScript The Hard Parts系列。

开始之前

在你理解闭包之前,有一些概念很重要。其中之一是执行上下文(execution context)。
这篇文章是对执行上下文的一个很好的入门,下面引用它:

当代码在JavaScript中运行时,它的执行环境非常重要,执行环境为以下情况的一种:
全局代码 —— 代码首次载人执行的默认环境
函数代码 —— 每次代码执行进入函数体内
(…)
(…)让我们把执行上下文当做每次代码执行的环境/作用域

换句话说,每当我们启动程序,我们从全局执行上下文开始。一些变量在全局上下文下声明,我们称之为全局变量。当程序调用函数时,会发生什么呢?主要是以下几个步骤:

  1. JavaScript创建一个新的执行上下文,一个函数执行上下文。
  2. 该执行上下文有其自己的变量集,这些变量将是该执行上下文的本地变量。
  3. 新的执行上下文进入(push)到执行栈上(execution stack),将执行栈视为一种机制来跟踪程序在执行中的位置。

函数什么时候结束?当遇到return语句或遇到结束括号时}。当函数结束时,会发生以下情况:

  1. 当前执行上下文退出执行栈(pops)。
  2. 函数将返回值发送回调用上下文,这个调用上下文是调用此函数的执行上下文,它可以是全局执行上下文或其它执行上下文,由调用执行上下文来处理该点的返回值,返回的值可以是对象,数组,函数,布尔值,任何值,如果函数没有return,将返回undefined。
  3. 当前执行上下文销毁,这很重要,我们在当前执行上下文定义的变量将被删除,他们不能再被使用,这就是我们为什么称之为局部变量。

一个很经典的例子

在我们开始讲闭包前,让我们看一下下面这段代码。这看起来非常简单,任何读这篇文章的人都可能确切知道它的作用。

let a = 3;
function addTwo(x) {
  let ret = x + 2;
  return ret;
}
let b = addTwo(a);
console.log(b);

为了理解JavaScript引擎是如何工作的,下面让我们详细分析它。

  1. 在第1行,我们在全局执行上下文中声明一个新变量a,并为其赋值为3。
  2. 接下来它变得棘手。第2到第5行在一起。这里发生了什么?我们在全局执行上下文中声明了一个名为addTwo的新变量。我们分配给它的是什么?函数定义。两个括号{}之间的任何内容都分配给addTwo。函数内部的代码不会被评估,也不会执行,只是存储到变量中以备将来使用。
  3. 现在我们来到第6行。它看起来很简单,但这里有很多需要拆解的。首先,我们在全局执行上下文中声明一个新变量,并将其标记为b。声明变量后,它的值为undefined。
  4. 接下来,仍然在第6行,我们看到一个赋值运算符。我们正准备为变量b分配一个新值。接下来我们看到一个被调用的函数。当你看到一个变量后跟圆括号(...)时,这就是调用函数的信号。说回来,每个函数都会返回一些东西(值,对象或undefined)。从函数返回的任何内容都将分配给变量b。
  5. 首先我们需要调用addTwo函数,JavaScript将在其全局执行上下文中查找名为addTwo的变量。oh,发现了,它定义在2-5行,变量addTwo是一个函数定义,a是通过参数传递到函数中去的,我们在全局环境中寻找a,发现a为3,我们将3赋值给函数的参数(argument),准备执行函数。
  6. 现在执行上下文将切换,创建一个新的执行上下文,我们将其命名为“addTwo执行上下文”。执行上下文被推送到调用堆栈。我们在当前执行上下文中做的第一件事是什么?
  7. 你可能会想说,“在当前执行上下文中声明了一个新的变量ret”。这不是答案。正确答案是,我们首先需要查看函数的参数。在当前执行上下文中声明了一个新变量x。并且由于值3作为参数传递,因此变量x被赋值为3。
  8. 下一步,当前上下文定义变量ret,并赋值为undefined。
  9. 第三行是个表达式,寻找x,我们先在本地上下文寻找,找到,为3,第二个操作输是2,表达式的结果是5,5被分配给ret。
  10. return ret,本地上下文中查找,ret为5,函数返回值为5,次数函数结束。
  11. 第4-5行。函数结束。本地执行上下文被销毁。变量x和ret被销毁。它们不再存在。上下文弹出调用堆栈,返回值返回到调用上下文。这里调用上下文是全局执行上下文。
  12. 我们将上面返回的值5分配给b,此时我们还在第6行。
  13. 第7行,变量b会在控制台打印出来,输出5。

对于一个非常简单的程序来说,这是一个非常冗长的解释,我们甚至还没有触及闭包。我相信会到达那里。但首先我们需要再绕一两次。

词法作用域

我们需要了解词法作用域的一些东西。我们来看一下下面的例子。

let val1 = 2;
function multiplyThis(n) {
  let ret = n * val1;
  return ret;
}
let multiplied = multiplyThis(6);
console.log("example of scope:", multiplied);

这里想表达的是我们在本地执行上下文中有变量,在全局执行上下文中有变量。 JavaScript的一个复杂之处在于它如何查找变量。如果它在本地执行上下文中找不到变量,它将在其调用上下文中查找它。如果没有在其调用上下文中找到它。反复地,直到它查看全局执行上下文。 (如果它没有找到它,它是undefined)。按照上面的例子,它将阐明它。如果您了解作用域的工作原理,则可以跳过此步骤。

这里我就不分析了,太长了,参考上面的就差不多知道了。

在这个例子中,我们只需要记住一个,函数可以访问在其调用上下文中定义的变量。这种情况我们称之为词法作用域。

一个函数返回另一个函数

第一个例子中函数返回的是一个数字,我们前面说过,函数可以返回任意值,下面让我们看个例子,函数返回函数,这对理解闭包很重要,下面例子我们开始分析:

let val = 7;
function createAdder() {
  function addNumbers(a, b) {
    let ret = a + b;
    return ret;
  }
  return addNumbers;
}
let adder = createAdder();
let sum = adder(val, 8);
console.log("example of function returning a function: ", sum);

分析步骤为自己参照理解,不是直接翻译:

函数执行返回前,分析同第一个例子,来到return,createAdder上下文推出执行堆栈,变量addNumbers已经消失,但是函数addNumbers还存在,它被函数返回并且分配给变量adder了。下面就是在全局上下文下操作了,所以结果输出的是15。

翻译继续:

正如预期的那样,控制台将打印15。我想在这里说明几点。首先,函数定义可以存储在变量中,函数定义对程序是不可见的,直到被调用。其次,每次调用函数时,(临时)创建本地执行上下文。当函数完成时,执行上下文消失。函数在遇到return或者}括号时完成。

最后,闭包

看看下一个代码,并试着弄清楚会发生什么。

function createCounter() {
  let counter = 0;
  const myFunction = function() {
    counter = counter + 1;
    return counter;
  };
  return myFunction;
}
const increment = createCounter();
const c1 = increment();
const c2 = increment();
const c3 = increment();
console.log("example increment", c1, c2, c3);

初步分析(非直译):

  1. 执行createCounter函数时,返回了一个函数myFunction,此时createCounter执行上下文销毁,但是myFunction依然存在,当前执行上下文指向全局。此时counte为undefined。
  2. 第一次执行increment,执行表达式counter + 1,也就是undefined + 1,所以c1为1,此时counter为1。
  3. 第二次执行increment,执行表达式counter + 1,也就是1 + 1,所以c2为2,此时counter为2。
  4. 同理,c3就是3了。所以结果输出1,2,3。

翻译继续:

不知道为何,increment函数记住了counter,它是怎么工作的呢?

是否counter存在于全局上下文中,console.log(counter),发现并没有,所以不是全局的,所以上面分析是错误的。

也许,当你调用increment时,它会以某种方式返回到它创建的函数(createCounter)?怎么会这样呢?变量increment包含函数定义,而不是它来自何处。所以那不是它。

所以必须有另一种机制。闭包。我们终于分析到了它。

下面是它的工作原理。每当声明一个新函数并将其赋值给变量时,都会存储函数定义以及闭包。闭包包含创建函数时范围内的所有变量。它类似于背包。功能定义附带一个小背包。在它的包中,它存储了创建函数定义时作用域内的所有变量。

再次分析(非直译):

  1. 执行createCounter函数时,定义了一个新的变量myFunction,这个变量在当前上下文是公开的,这个变量的内容是另一个函数的定义,同时我们还创建一个闭包并将其作为函数定义的一部分包含在内。闭包包含当前作用域的变量,这个例子里,就是变量counter,它的值为0。
  2. 第一次执行increment,执行表达式counter + 1,0 + 1,所以c1为1,此时counter为1。
  3. 第二次执行increment,执行表达式counter + 1,也就是1 + 1,所以c2为2,此时counter为2。
  4. 同理,c3就是3了。所以结果输出1,2,3。

所以现在我们了解这是如何工作的。要记住的关键是当声明一个函数时,它包含一个函数定义和一个闭包。闭包是函数创建时作用域内所有变量的集合。

您也许会问,任何函数是否都有闭包,甚至是在全局作用域内创建的函数?答案是对的。在全局作用域中创建的函数也会创建一个闭包。但由于这些函数是在全局作用域内创建的,因此它们可以访问全局作用域内的所有变量,所以闭包的概念并没有什么意义。

当函数返回一个函数时,就是让闭包的概念变得更加相关。返回的函数可以访问不在全局作用域内的变量,但它们仅存在于其闭包中。

结尾

我们要记住通过背包来打比方的方式形容闭包。当一个函数被创建并传递或从另一个函数返回时,它带有一个背包。并且在背包中是声明函数时作用域内的所有变量。

纯属个人翻译,经验不足,如有出入或问题欢迎指正,谢谢。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions