来听我讲讲关于闭包的故事吧!
前段时间读红宝书,边读边写读书笔记。在读到第七章关于函数表达式部分时,我总觉得书里的引入和讲解都让人有点摸不着头脑,倒也不是因为先入为主地认为它难,就是感觉它在把所有内容混杂在一起,没有一以贯之的主线和条理,因此当时写的读书笔记也有点混乱。
这样混乱的理解对于爱钻牛角尖的我来说,完全是心里过不去的一道坎儿。在多方探寻之后,我去读了读《你不知道的JavaScript》中的相关部分,发现它引入的点竟然和我的想法不谋而合!于是反复读了几遍,感觉收获很多,我喜欢这样说人话的书!
既然有所理解,那就记下来吧。Here we go~
不说闭包
我们先从JavaScript的作用域链开始讲起。
我们知道,JavaScript中有一种叫做作用域链的机制。所谓作用域链,就是程序在执行并需要获取一个变量时,在代码中寻找该变量值的一条路径:先从当前作用域找值,无果则去最近的父作用域找值,无果再向上一层找值…直到到达范围最大的全局作用域为止。因此很容易理解下面的代码:
1 | function father() { |
内层son()
的内部没有去定义a
,但是却可以拿到外层father()
的a
。但仅有函数和变量的声明并不能log出a
的值(那是当然!),因此我们需要执行一下son()
函数。那么问题来了,怎么执行呢?
笨蛋,当然是直接调用呀!但是很快我们发现,外部无法直接调用son()
,它只能在 father()
内被访问到,所以我们可以在father()
内调用son()
,再在全局作用域上调用father()
,这样就能完美地log出a
了:
1 | function father() { |
此处son
受限于father
,只能在father
的视线范围(词法作用域)内被允许执行,并在被执行的过程中透露father
的信息,那么我们在想,有什么方法可以让son
逃脱father
的视线去被执行呢?
…brainstorming…
公布答案!这里的核心思想在于把son
给送到外面,怎么送呢?
- 把内部函数return出去
1 | function father() { |
- 把内部函数作为其他函数的参数传出去
1 | function father() { |
- 把内部函数赋值给全局变量来共享出去
1 | var outerson; |
上面所有方法都是在通过内部函数来泄露外部函数的信息,不同的是,第一个情况下,内部函数是在当前词法作用域内执行的,而后面三个情况下,内部函数则是在当前词法作用域之外执行的。
好了,上面的道理我都懂,但是闭包到底是什么呢?
闭包是什么?
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
多读几遍,不知道你会不会恍然大悟。闭包就是基于词法作用域书写代码时所产生的自然结果,我们甚至不需要为了利用它们而有意地创建闭包,我们要做的是去识别、了解闭包的工作原理以及规避一些潜在的副作用。
通常人们并不把第一种情况视为闭包,因为它没什么特殊之处,但后面几种就有意思多了,譬如可以找到它们的特性:
- 闭包可以使其外部作用域一直存活而不被垃圾回收
- 闭包使得函数可以继续访问定义时的词法作用域
常用闭包举例
闭包在代码里很常见,我们不妨来识别识别。
1 | function wait(message) { |
在这里,内部函数timer()
具有涵盖外部函数wait()
作用域的闭包,因此还保有对变量message
的引用。
执行wait()
函数时,setTimeout()
会在1s后将timer()
添加到任务队列。
当timer()
要执行时,也就是wait()
执行1s后,它的内部作用域并不会消失,timer()
依然保有wait()
作用域的闭包。
在整个过程中,词法作用域都是保持完整的,而这就是闭包。
再举一个例子:
1 | function setupBot(name, selector) { |
这也是闭包噢!内部的activator()
获取了外部setupBot()
的name
。
引用一下书里的总结吧:如果将函数当作一等公民并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者其他的异步或同步任务中,只要使用了回调函数,实际上就是在使用闭包。
常见闭包错误
闭包之所以会被觉得难懂,是因为它时常和其他东西混杂在一起,譬如延迟执行的setTimeout和立即执行的IIFE、块级作用域和函数作用域、for循环等。
立即执行函数
对于立即执行的函数来说,for循环和闭包的杂糅是不会造成歧义的,譬如以下的两种写法,都会输出相同的内容:
1 | for(var i = 0; i < 5; i++) { |
1 | for(var i = 0; i < 5; i++) { |
在第一种写法中,循环中的每次迭代都会给自己捕获一个i
的副本,因为每次的shout()
都是有参数传进去的,所以相当于shout(0)
、shout(1)
…shout(4)
。
在第二种写法中,循环中的每次迭代都会指向同一个i
,也就是外部的i
,相当于shout(i)
、shout(i)
…shout(i)
。不过由于是立即执行而非延迟执行,几个shout(i)
处在i
为不同值的不同时刻,因此也能得到和第一种写法一样的效果。
延迟执行函数
但对于延迟执行的函数来说,for循环和闭包的杂糅就让事情变得复杂了些。
举一个最为常见的例子:
1 | for(var i = 0; i < 5; i++) { |
猜猜它会怎样输出?它会以每秒一次的频率输出五次5。
首先要明确,这里循环中的每次迭代都会指向同一个i
,也就是外部的i
,不过对于立即执行和延迟执行是有区别的。setTimeout()
本身是立即执行的,所以参照上面的第二种写法,相当于setTimeout(timer, i*1000)
、setTimeout(timer, i*1000)
…setTimeout(timer, i*1000)
,几个setTimeout(timer, i*1000)
处在i
为不同值的不同时刻,相当于setTimeout(timer, 0*1000)
、setTimeout(timer, 1*1000)
…setTimeout(timer, 4*1000)
,因此它会在0s、1s…4s后分别执行timer()
,共5次。
而timer()
就是延迟执行了,延迟函数的回调会在循环结束时才执行,(即使是延迟0s,回调函数依然是在循环结束后才被执行),这个时候的i
就已经是5了,所以timer()
内部log到的就是从外面获取到的i
,也就是5了。
如果想要达到预期的每隔一秒按顺序log出0-4的效果,我们需要让每个timer()
有自己的i
的副本。做法的话,要么就直接作为参数传进去,要么就在内外作用域之间再加一层作用域并把每轮迭代的i
放进去。
- 直接传参
1 | for(var i = 0; i < 5; i++) { |
当然,把函数的定义放到外面也是一样的。
1 | function timer(arg) { |
- 加一层作用域并传参
可以使用IIFE来新增作用域:
1 | for(var i = 0; i < 5; i++) { |
在这里,每次迭代的时候,arg
都会取到i
的副本。当然,还可以把i
作为新作用域的参数传进来。
1 | for(var i = 0; i < 5; i++) { |
除了使用IIFE新增一层函数作用域,还可以使用ES6的let
来新增一层块级作用域。
1 | for(var i = 0; i < 5; i++) { |
在这里,let
声明的arg
拥有块级作用域,每次迭代都会有自己专属的块,块内的arg
也都会取到i
的副本。当然还可以对它进行改写,for循环头部的let
声明还会有一个特殊的行为,该行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每次迭代都会使用上一个迭代结束时的值来初始化这个变量。
1 | for(let i = 0; i < 5; i++) { |
*发现小问题
在控制台跑上面六段代码时,我发现直接传参
的两段代码都会直接log出0、1、2、3、4,也就是虽然输出结果对了,但是并没有延迟执行,而加一层作用域并传参
的四段代码都能正确地每间隔1s按顺序log出0、1、2、3、4。
查了下,setTimeout()
没有延迟执行的原因是:如果第一个参数直接传入一个可执行代码,那它就会立即执行。
1 | // 立即执行 |
总结
其实就我的理解来看,闭包就是函数对其外部作用域的引用,而使用闭包容易出错的原因就在于引用。我们可以控制引用的指向,但是我们不能控制引用的内容,因为同一个引用可能是被多方共享操纵的,可能会在不同阶段呈现出不同的值。因此,想要明确自己通过引用获得什么值,要做的就是厘清一系列的操作时间线。而如果想要摆脱引用,那就得自己创建一个副本,这样就可以把需要的内容给定格住了。
闭包其实有好多应用,譬如模块化就是基于闭包的。不过讲到应用,就需要另起一篇啦。
至此,故事就讲完啦,希望能有所收获!