JavaScript | 闭包

204 阅读4分钟

闭包

JavaScript 闭包无处不在,你只需要能够识别并拥抱它。

闭包的实质

定义

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即函数是在当前词法作用域之外执行

 function foo() {
   var a = 2
   
   function bar() {
     console.log(a)  // 2
   }
   
   bar()
 }
 foo()

在上述代码中,函数 bar() 具有一个覆盖 foo() 作用域的闭包(事实上覆盖了它能访问的所有作用域)。

原理

为了更加清晰地观察闭包是如何工作的,我们对代码进行一些修改。

 function foo() {
   var a = 2
   
   function bar() {
     console.log(a)
   }
   
   return bar
 }
 
 var baz = foo()
 baz() // 2 —— 这就是闭包的作用

在这个例子中,我们将函数 bar 所引用的函数对象本身当做返回值。

foo() 执行后,其返回值(也就是内部的 bar)赋值给 baz 并调用 baz(),实际上就是通过不同的标识符引用调用了内部的函数 bar()

因为引擎具有垃圾回收机制来释放不再使用的内存空间,所以通常来说,当 foo() 执行完毕后,foo() 的整个内部作用域都会被销毁。

而闭包会阻止这件事情的发生。因为 bar() 拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar()在之后的任何时间进行引用。这个引用就叫做闭包

这个函数在定义时的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义时的词法作用域。

循环和闭包

for循环是闭包的常见例子。

 for (var i = 0; i <= 5; i++) {
   setTimeout( function timer() {
     console.log(i)
   }, i*1000)
 }

预期的输出结果分别是1-5,每秒一次。

但是实际的输出结果是以每秒一次的频率输出五个6。

对上面的代码进行分析:这个循环的终止条件是 i 不再 <=5,条件首次成立的情况是6,因此输出显示的是循环结束时 i 的最终值6。

深入分析造成这种缺陷的原因,循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i 。而我们的理想情况是循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。

现在开始解决这个问题。

方法一:我们需要在循环过程中的每个迭代中创建一个闭包作用域。

 for (var i = 0; i <= 5; i++) {
   (function(j) {
     setTimeout( function timer() {
       console.log(j)
     }, j*1000)
   })(i)
 }

利用立即执行函数,我们将 i 传递进去。

在迭代内使用立即执行函数,会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代都会含有一个具有正确值的变量供我们使用。

方法二:使用块级作用域,let声明变量。

 for (let i = 0; i <= 5; i++) {
   setTimeout( function timer() {
     console.log(i)
   }, i*1000)
 }

for循环中的let声明,指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

模块中的闭包

最常见的实现模块的方法通常被称为 模块暴露,下面的代码展示其变体:

 function CoolModule() {
   var something = "cool"
   var another = [1, 2, 3]
   
   function doSomething() {
     console.log(something)
   }
   
   function doAnother() {
     console.log(another.join("!"))
   }
   
   return {
     doSomething,
     doAnother
   }
 }
 ​
 var foo = CoolModule()
 foo.doSomething() // cool
 foo.doAnother() // 1!2!3 

分析以上代码:

  1. 需要通过调用 CoolModule() 来创建一个模板实例。如果不执行外部函数,内部作用域和闭包都无法被创建。
  2. CoolModule() 返回一个对象。这个返回的对象中含有对内部函数而不是内部数据变量的引用。可以将这个对象类型的返回值看做本质上是模块的公共API
  3. doSomething()doAnother() 函数具有涵盖模块实例内部作用域的闭包。

我们可以将模块函数转换为IIFE,立即调用这个函数并将返回值直接复制给单利的模块实例标识符 foo

 var foo = (function CoolModule() {
   var something = "cool"
   var another = [1, 2, 3]
   
   function doSomething() {
     console.log(something)
   }
   
   function doAnother() {
     console.log(another.join("!"))
   }
   
   return {
     doSomething,
     doAnother
   }
 })()
 ​
 foo.doSomething()
 foo.doAnother()

参阅

《你不知道的JavaScript》

小结

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

模块有两个特征:

  1. 为创建内部作用域而调用了一个包装函数;
  2. 包装函数的返回值必须至少包括一对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。