理解闭包其实没你想象中那么难

221 阅读21分钟

闭包到底是什么个玩意儿

正如标题所述,JavaScript 闭包对我来说一直是个谜。我阅读了多篇文章,我在我的工作中使用了闭包,有时甚至在不知不觉中使用了闭包。

最近,朋友给我以一种方式解释了闭包,听完后的我恍然大悟。接下来我将尝试用这种方法来解释闭包在本文中。

在我们开始解释之前,有一些概念在你理解闭包之前是很重要的。其中之一就是执行上下文。

当我们启动程序时,我们从全局执行上下文开始。一些变量在全局执行上下文中被声明。我们将这些变量称为全局变量。

当程序调用一个函数时,会发生什么?以下是一些步骤:

  1. JavaScript 创建一个新的执行上下文,即局部执行上下文。
  2. 该局部执行上下文将具有自己的一组变量,这些变量将局限于该执行上下文。
  3. 新的执行上下文被放入执行堆栈中。可以将执行堆栈视为一种机制,用于跟踪程序在执行中的位置。

函数何时结束?当函数遇到 return 语句或遇到闭合大括号 } 时,函数结束。当函数结束时,会发生以下情况:

  1. 局部执行上下文从执行堆栈中弹出。
  2. 函数将返回值发送回调用上下文。调用上下文是调用此函数的执行上下文,它可以是全局执行上下文或其他局部执行上下文。调用执行上下文负责处理返回值。返回的值可以是对象、数组、函数、布尔值或其他任何类型。如果函数没有返回语句,则返回 undefined。
  3. 局部执行上下文被销毁。这一点非常重要。被销毁了。在局部执行上下文中声明的所有变量都被清除。它们不再可用。这就是为什么它们被称为局部变量的原因。
一个非常简单的例子

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

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

为了真正理解 JavaScript 引擎的工作原理,让我们详细分解这段代码。

  • 第1行,我们在全局执行上下文中声明一个新变量 a,并将其赋值为数字 3。
  • 接下来变得有点复杂。第2到5行实际上是一起的。这里发生了什么?我们在全局执行上下文中声明一个名为 addTwo 的新变量。我们要给它赋什么值?一个函数定义。在大括号 { } 之间的内容被赋给 addTwo。函数内部的代码不会被解析、执行,只是存储在变量中以备将来使用。
  • 现在我们来到第6行。看起来很简单,但这里有很多内容需要理解。首先,在全局执行上下文中声明一个新变量并标记为 b。一旦变量被声明,它的值就是 undefined
  • 接下来,在第6行,我们看到一个赋值操作符。我们准备给变量 b 分配一个新值。接下来我们看到一个函数被调用。当你看到一个变量后面跟着圆括号 (...省略代码) 时,那是函数被调用的信号。稍后会明白,每个函数都会返回一些东西(值、对象或 undefined)。函数返回的任何内容都将被赋给变量 b
  • 但首先,我们需要调用标记为 addTwo 的函数。JavaScript 将在其全局执行上下文内存中查找名为 addTwoaddTwo 的变量 -- 在第2步(或第2-5行)中被定义。而且变量 addTwo 包含一个函数定义。注意,变量 a 作为参数传递给了函数。JavaScript 在其全局执行上下文内存中搜索变量 a,找到了它,并发现它的值为 3,然后将数字 3 作为参数传递给函数。准备执行函数。
  • 现在执行上下文将切换。创建一个新的局部执行上下文,我们将其命名为“addTwo 执行上下文”。执行上下文被推入调用堆栈中。在局部执行上下文中首先要做什么呢? 你可能会说,“在局部执行上下文中声明一个新变量 ret”。不过这不是正确答案。正确的答案是,我们需要先查看函数的参数。在局部执行上下文中声明一个新变量 x。由于值 3 作为参数传递,变量 x 被赋值为数字 3
  • 下一步是:在局部执行上下文中声明一个新变量 ret。其值设置为 undefined(第3行)。
  • 第3行,需要执行一个加法操作。首先我们需要变量 x 的值。JavaScript 首先在局部执行上下文中查找变量 x,找到了它,其值为 3。第二个操作数是数字 2。加法的结果(5)被赋给变量 ret
  • 第4行。我们返回变量 ret 的内容。再次在局部执行上下文中进行查找。ret 包含值 5。函数返回数字 5,并结束函数的执行。
  • 第4-5行。函数结束。局部执行上下文被销毁。变量 xret 被清除。执行堆栈中弹出上下文,并将返回值返回给调用上下文。在这种情况下,调用上下文是全局执行上下文,因为函数 addTwo 是从全局执行上下文中调用的。
  • 现在我们从第4步继续。返回的值(数字 5)被赋给变量 b。我们仍然在程序的第6行。
  • 我不会详细解释,但在第7行,变量 b 的内容被打印到控制台。在我们的示例中是数字 5。

可能对这个简单的代码解释的有点长,看到这里的都给你们点个赞。

接下来在介绍到闭包之前还有一点需要了解一下,那就是词法作用域

词法作用域(lexical scope)

为了更好的了解,下面我给还是出个例子。

let val1 = 2
function multiplyThis(n) {
  let ret = n * val1
  return ret
}
let multiplied = multiplyThis(6)
console.log('值:', multiplied)

这里的想法是我们有局部执行上下文中的变量和全局执行上下文中的变量。JavaScript 在查找变量时的一个复杂之处在于,如果在局部执行上下文中找不到变量,它会在调用上下文中继续查找。如果在调用上下文中仍然找不到,就会一直重复这个过程,直到在全局执行上下文中查找。如果在全局执行上下文中仍然找不到,那么该变量的值就是 undefined。接下来我会详细解释上面的例子,让你会有一个更加清晰的了解具体发生了什么。

如果你理解作用域的工作原理,可以跳过这部分内容。

  • 在全局执行上下文中声明一个新变量 val1,并将其赋值为数字 2。
  • 第2-5行。声明一个新变量 multiplyThis,并将其赋值为一个函数定义。
  • 第6行。在全局执行上下文中声明一个新变量 multiplied
  • 从全局执行上下文内存中检索变量 multiplyThis,并将其作为函数执行。将数字 6 作为参数传递。
  • (新的函数调用)=(新的执行上下文)。创建一个新的局部执行上下文。
  • 在局部执行上下文中,声明一个变量 n 并将其赋值为数字 6。
  • 第3行。在局部执行上下文中声明一个变量 ret
  • 第3行(继续)。进行乘法运算,操作数为变量 nval1 的内容。在局部执行上下文中查找变量 n。我们在第6步中声明了它。它的内容是数字 6。在局部执行上下文中查找变量 val1。局部执行上下文没有标记为 val1v 的变量。让我们查找调用上下文。调用上下文是全局执行上下文。让我们在全局执行上下文中查找 val1。这个是在第1步中定义的。值是数字 2。
  • 继续回到第三行。将两个操作数相乘,并将结果赋给变量 ret (6 * 2 = 12)。ret 的值现在是 12。
  • 返回变量 ret。局部执行上下文被销毁,其中包括变量 vv。变量 val1 不会被销毁,因为它是全局执行上下文的一部分。
  • 回到第6行。在调用上下文中,数字 12 被赋给变量 multipliedv
  • 最后,在第7行,我们在控制台中显示变量 multiplied 的值。

我们需要记住一个函数可以访问在其调用上下文中定义的变量。这种现象的正式名称是词法作用域。 希望在这个例子解释中,你对词法作用域有一个清晰的了解。

方法中返回一个方法(JavaScript中函数是一等公民)

** 怎么理解 xxx是一等公民呢? xxx 可以作为函数参数,可以作为函数返回值,也可以赋值给变量。

在前面的例子中,函数 addTwo 返回一个数字。还记得之前提到过函数可以返回任何东西。让我们看一个返回函数的函数的例子,因为这对于理解闭包是至关重要的。下面是我们要分析的例子。

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('值: ', sum)
  • 第1行。我们在全局执行上下文中声明一个变量 val,并将数字 7 赋给该变量。
  • 第2-8行。我们在全局执行上下文中声明一个名为 createAdder 的变量,并将一个函数定义赋给它。第3到7行描述了该函数定义。与之前一样,在这一点上,我们还没有进入该函数。我们只是将函数定义存储在变量 createAdder 中。
  • 第9行。我们在全局执行上下文中声明一个新变量 adder。临时地,adder 被赋值为 undefined
  • 第9行。我们看到圆括号 (),我们需要执行或调用一个函数。让我们查询全局执行上下文的内存,并查找名为 createAdder 的变量。它在第2步中创建。好的,让我们调用它。
  • 调用函数。现在我们来到第2行。创建了一个新的局部执行上下文。我们可以在新的执行上下文中创建局部变量。引擎将新的上下文添加到调用栈中。这个函数没有参数,让我们直接跳转到函数体中。
  • 第3-6行。我们有一个新的函数声明。我们在局部执行上下文中创建一个名为 addNumbers 的变量。这很重要。addNumbers 只存在于局部执行上下文中。我们将一个函数定义存储在名为 addNumbers 的局部变量中。
  • 第7行。我们返回变量 addNumbers 的内容。引擎查找名为 addNumbers 的变量,并找到了它。它是一个函数定义。很好,函数可以返回任何东西,包括函数定义。所以我们返回 addNumbers 的定义。第4行和第5行之间的任何内容都构成了函数定义。我们还从调用栈中移除了局部执行上下文。
  • 函数返回后,局部执行上下文被销毁。addNumbers 变量不再存在。函数定义仍然存在,它从函数中返回,并赋值给了 adder 变量;这是我们在步骤3中创建的变量。
  • 第10行。我们在全局执行上下文中定义了一个新变量 sumv。临时赋值为 undefined
  • 接下来我们需要执行一个函数。是哪个函数呢?就是定义在名为 adderv 的变量中的函数。我们在全局执行上下文中查找它,确实找到了。它是一个带有两个参数的函数。
  • 让我们取出这两个参数,这样我们就可以调用函数并传递正确的参数。第一个参数是变量 val,我们在步骤1中定义了它,它表示数字7,第二个参数是数字8。
  • 现在我们需要执行那个函数。函数定义在第3到5行中。创建一个新的局部执行上下文。在局部上下文中创建了两个新变量:a 和 b。它们分别被赋予了值7和8,因为这些是我们在前一步中传递给函数的参数。
  • 第4行。一个名为 ret 的新变量被声明在局部执行上下文中。
  • 第4行。执行加法运算,将变量 a 和变量 b 的内容相加。加法的结果(15)被赋给了变量 ret。
  • retv 变量从该函数中返回。局部执行上下文被销毁,从调用栈中移除,变量 abret 不再存在。 返回值被赋给了我们在第9步中定义的 sum 变量。
  • 最后我们将 sum 的值打印到控制台。

正如预期的那样,控制台将打印出 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-8行:在全局执行上下文中创建一个名为 createCounter 的变量,并将其赋值为一个函数定义。

  • 第9行:在全局执行上下文中声明一个名为 increment 的新变量。

  • 第9行再次:我们需要调用 createCounter 函数,并将其返回值赋给 increment 变量。

  • 第1-8行:调用函数。创建新的局部执行上下文。

  • 第2行:在局部执行上下文中声明一个名为 counter 的新变量,并将数字0赋给 counter

  • 第3-6行:声明一个名为 myFunction 的新变量。该变量在局部执行上下文中声明。该变量的内容是另一个函数定义,如第4和第5行所定义的。

  • 第7行:返回 myFunction 变量的内容。局部执行上下文被删除。myFunctioncounter 不再存在。控制权返回给调用上下文。

  • 第9行:在调用上下文中,也就是全局执行上下文中,createCounter返回的值被赋给了 increment。现在变量 increment 包含了一个函数定义,这个函数定义是 createCounter 返回的。它不再被标记为myFunction,而是被标记为 increment。在全局上下文中,它被称为 increment

  • 第10行:声明一个新的变量c1

  • 第10行:查找变量 increment,它是一个函数,调用它。它包含之前返回的函数定义,如第4-5行所定义。

  • 创建一个新的执行上下文,没有参数。开始执行该函数。

  • 第4行:counter = counter + 1。在局部执行上下文中查找变量 counter。我们刚创建了这个上下文并没有声明任何局部变量。让我们在全局执行上下文中查找。这里没有标记为 counter 的变量。JavaScript会将其解释为 counter = undefined + 1,并声明一个新的局部变量 counter 并将其赋值为1,因为 undefined 可以看作是0。

  • 第5行:返回 counter 的内容,即数字1。我们销毁局部执行上下文和 counter 变量。

  • 回到第10行:返回的值(1)被赋给c1。

  • 第11行:重复步骤10-14,c2也被赋值为1。

  • 第12行:重复步骤10-14,c3也被赋值为1。

  • 第13行:将变量c1、c2和c3的内容打印到日志中。

    提示:上面解释其实是错误的

到这里,你可以尝试自己运行代码,看看会发生什么。你会发现它不会像之前的解释中所期望的那样打印出1、1和1。相反,它会打印出1、2和3。为什么会这样呢?

一些奇妙的地方在于,increment函数记住了counter的值。这是如何实现的呢?

counter是否属于全局执行环境?如果你尝试使用 console.log(counter),你会得到 undefined。所以不是这个原因。

也许是当你调用increment时,它会回到创建它的函数(createCounter)中? No no no。 变量increment包含的是函数定义,而不是它的来源。所以也不是这个原因。

那么肯定有其他机制在起作用 --- 闭包(Closure)。这下你可能就理解了吧?

当你声明一个新的函数并将其赋值给一个变量时,你存储了函数定义以及一个闭包(Closure)。

闭包包含了在函数创建时处于作用域中的所有变量。 它类似于一个背包。函数定义带着一个小背包,其中存储了创建函数定义时的所有变量。

总的来说,当你调用 createCounter 函数时,它会创建一个新的局部执行环境,并具有自己的counter变量。然而,返回的函数(myFunction)保留了对其父级作用域的访问权限,其中包括外部环境中的counter变量。

当调用返回的函数(increment)时,它访问并修改了其父级作用域中的counter变量。这意味着每次调用increment时,它都会增加共享的counter变量的值。因此,当你记录c1c2c3的值时,你看到的是共享的counter变量的递增值。

这种行为是闭包的一种结果,在JavaScript中,闭包允许函数保持对其父级作用域中变量的访问权限,即使父级函数已经执行完成。在这种情况下,返回的函数(increment)形成了一个闭包,它在多次调用返回的函数时共享相同的counter变量。

因此,createCounter函数并不是为每次调用创建单独的counter变量实例,而是创建了一个闭包,在多次调用返回的函数时共享同一个counter变量。

接下来我就重新解释一下上面那段代码吧。这次是正确的了

在之前的解释中,我有意遗漏了一个关键概念——闭包(Closure)。闭包是 JavaScript 中的一个重要机制,它解释了为什么我们在调用 increment 函数时能够记住 counter 的值。

当我们在 createCounter 函数中创建了 increment 函数时,increment 函数实际上捕获(capture)了 createCounter 函数作用域中的 counter 变量。它将 counter 变量保存在自己的闭包中。所以即使 createCounter 函数执行完毕后,它的执行环境被销毁,但 increment 函数仍然可以访问并使用它所捕获的 counter 变量。

每次调用 increment 函数时,它都能够访问并修改其闭包中的 counter 变量。这就解释了为什么我们在执行 increment 函数时,counter 的值会随之增加。

所以,正确的解释是,通过闭包机制,increment 函数能够保持对 createCounter 函数作用域中的 counter 变量的引用,并在每次调用时更新它。这就是为什么我们看到的结果是 1、2 和 3,而不是 1、1 和 1。

现在我们明白了这个机制的工作原理。

关键要记住的是,当一个函数被声明时,它包含了一个函数定义和一个闭包。闭包是在函数创建时,包含了该函数声明时的所有变量的集合。

你可能会问,是否任何函数都有一个闭包,即使是在全局作用域中创建的函数?答案是肯定的。在全局作用域中创建的函数也会创建一个闭包。但由于这些函数是在全局作用域中创建的,它们可以访问全局作用域中的所有变量,因此闭包的概念并不是真正相关的。

当一个函数返回一个函数时,闭包的概念就变得更加相关了。返回的函数可以访问不在全局作用域中的变量,而是存在于它自己的闭包中。

没啥存在感的闭包

有时候闭包会在你没有注意到的情况下出现。

let c = 4
function addX(x) {
  return function(n) {
     return n + x
  }
}
const addThree = addX(3)
let d = addThree(c)
console.log('example partial application', d)

我们声明了一个通用的 addX 函数,它接受一个参数(x),并返回另一个函数。

返回的函数也接受一个参数,并将其与变量 x 相加。

变量 x 是闭包的一部分。当在局部上下文中声明变量 addThree 时,它被赋予了一个函数定义和一个闭包。闭包包含变量 x

因此,当调用并执行 addThree 时,它可以访问闭包中的变量 x 和作为参数传递的变量 n,并能够返回它们的和。

在这个例子中,控制台将打印数字 7。

当我们聊到JavaScript中的闭包时,它指的是函数能够记住并访问其被定义时的父级作用域中的变量。可以将闭包想象为函数所携带的背包,其中包含它们所需的所有变量。

当一个函数返回另一个函数时,返回的函数仍然可以访问其父函数作用域中的变量。这对于实现部分应用非常有用,其中一个函数会部分应用一些参数,然后返回的函数可以在以后被调用时传入剩余的参数。

简单来说,闭包允许函数记住并使用它们被创建时的变量。它们在创建后仍然可以使用,非常适用于创建能够在执行完成后记住数据的函数。

在实际日常编码中,JavaScript闭包的常见应用情况可能会有以下几点:

  1. 封装私有变量:通过使用闭包,可以创建私有变量,这些变量对外部是不可访问的,只能通过返回的函数进行操作和访问。这有助于实现信息隐藏和封装。
  2. 模块化开发:使用闭包可以创建模块,将相关的函数、变量和逻辑组织在一起,并避免与其他部分的命名冲突。这种方式有助于实现代码的可维护性和可重用性。
  3. 延迟执行:通过使用闭包,在某些情况下可以实现延迟执行函数。例如,使用闭包可以创建一个定时器,使函数在一定时后执行,而不是立即执行。
  4. 事件处理程序:在事件处理程序中使用闭包是很常见的,因为闭包允许访问事件发生时的上下文和变量。这样可以方便地操作和处理事件相关的数据。
  5. 函数工厂:闭包可以用于创建函数工厂,即能够动态生成特定功能的函数。这对于根据不同的需求创建定制的函数非常有用。

同时需要注意的是,在使用闭包时,我们应避免创建过多的闭包,以免造成内存泄漏或性能问题。合理使用闭包可以提供更优雅和灵活的编程解决方案