本文是深入理解JS系列的part3,我们要讨论的是javscript中的一个十分重要的概念:闭包。闭包是这门语言中的特点和难点,借助闭包我们可以实现很多高级应用。闭包是和作用域紧紧结合起来的,因此在讨论闭包之前,我们简单的介绍一下javascript的作用域。
参考书籍:《你不知道的JavaScript》
作用域
首先,什么是作用域?简单来讲,一个作用域就是一个范围,在这个范围之内声明的变量就是局部变量。局部变量只在这个范围内存在,在范围之外无法访问。
上面代码中init函数中声明的变量a,只存在于init函数的作用域中,在init函数外部访问变量a,会抛出ReferenceError错误(变量未声明)。
在ES6之前,javascript只有两种作用域
- 全局作用域
全局作用于是顶级作用域,在全局作用域中声明的对象,都是全局对象。 - 函数作用域
定义一个function(){}也就定义了一个作用域,作用域以{}包裹。
如果你使用过c++等其他语言,会认为for循环、if语句中声明的都是局部变量,这是因为这些语言本身是具有块级作用域的,被{}包裹的就是一个新的作用域。而在javascript中并非如此。
由于javascript只有全局作用域和函数作用域,即使在for循环和if语句中声明了新变量,这些变量的作用域也不限于循环体,而是函数体。实际上,js中for循环或if语句中声明的变量,与在它们外部声明的变量并无差异,同样也会出现变量提升现象。
ES6引入了第三种作用域
- 块级作用域
ES6的let和const命令,为当前所处的环境增加了块级作用域。简单来说,let或const声明的变量,在包裹它的第一层{}之外无法被访问到。
作用域可嵌套
js中作用域可嵌套,嵌套的多个作用域形成了作用域链。
首先思考一个最最基本的事实:定义在全局作用域中的变量,可以在任何位置被访问到。
上面的栗子中,变量a和函数init是定义在全局作用域中的全局对象,变量b是定义在init函数作用域中的局部变量。同时,init函数作用域嵌套在全局作用域中。
执行init函数后,控制台输出12,说明在init函数作用域内可以访问它上一级的全局作用域;而在全局作用域执行console.log(b)发现抛出ReferenceError,这说明全局作用域不可以访问到它下一级的init函数作用域。
这个最简单的栗子体现出作用域可嵌套,内层作用域可访问外层,外层作用域不可以访问内层。
接下来我们看进阶一点的栗子:
执行init函数后,控制台输出20。
上面的栗子中,全局作用域中定义了变量a和函数init,函数init的作用域中定义了变量b和函数add,函数add中定义了另一个变量a。作用域的嵌套关系:add->init->全局作用域。
由输出结果我们发现两个事实:
- add函数内可以访问到其外层作用域init中的变量b
- 最终console.log(a+b)中的a,引用的是定义在add函数作用域中的变量a,并非全局变量a
第一个事实我们基于前面的论述可以理解。第二个事实告诉我们,在引用变量时,js采取就近原则,从当前作用域开始,逐步向外层作用域查找变量,到全局作用域为止。
因此,作用域的嵌套关系就形成了一个单向的作用域链。本栗中在add函数作用域内就找到了变量a,因此就取得了这个a,不再继续沿着作用域链查找。
我们可以将js作用域的这个特点简单描述为:如果作用域 a 定义在作用域 b 中,则在作用域 a 中可以访问作用域 b的对象。
理解了这个特点之后我们几乎就明白闭包是什么回事了–因为这正是闭包产生的原因。
闭包
什么是闭包
很多文章会将上一节最后一个栗子当做闭包,但其实并不是真正的闭包,或者说没有确切使用闭包。
下面我们看看真正的闭包是什么样的:
本栗和前栗最终都是在控制台输出了20。注意到,前栗在init函数中定义add函数,并且在init函数的作用域内执行了add。而本栗并没有在init函数中执行add函数,而是将add函数做为init函数的返回值。在执行var closure = init();后,closure被赋值为add函数。然后执行closure();实际就是在执行add();。
看到这里我们是不是发现了神马奇怪的现象?没错,add函数竟然被全局作用域访问到!这完全违背了作用域链的原则。
除此之外还有更神奇的一点:在执行var closure = init();后,通常情况下它的整个作用域内部(局部变量)都会被销毁,然后再由引擎中的垃圾回收期自动回收并释放init曾使用的内存空间。然而我们在执行完这局之后,继续执行closure();,发现他依然可以访问到init函数作用域中的局部变量b。
以上两个神奇的正是闭包的特点。那么我们来对闭包下个定义:如果将一个函数被传递到其定义时的作用域之外运行,则它会持有对这个作用域的引用。这个函数和定义时所在的作用域就构成了闭包。
基于此,我们就知道为啥我说前栗不算闭包了,因为前栗并未将add传递出去,add依然在定义时所在的作用域内执行。
应用场景
- 保存状态/缓存
闭包更抽象点讲,就是带了状态的函数。他将定义时所在的作用域保存在内存中,作用域中的变量就得以保留。 - 封装私有变量
如上栗中的局部变量b,在外部我们无法通过init函数直接访问,但是可以通过闭包来访问。
注意事项
- 由于闭包不会释放作用域,大量使用闭包会导致页面性能下降,并可能导致内存泄漏。因此请适度使用闭包并及时删除无用的局部变量
- 闭包可以在定义时所在的作用域外改变局部变量的值。在应用场景2下,不要轻易改变局部变量的值。
经典的for循环题
很多人可能看过这道题或是实际遇到过这个问题:
- 请问init函数的执行结果是什么?
答案是:控制台每秒一次输出6。 - 如何实现控制台每秒一次输出1-5?
答案是:看下面讨论O(∩_∩)O
这道题涵盖的知识点包括异步、作用域和闭包。异步复习这里。
原函数的执行过程
首先分析下原函数的执行过程。
- 声明init函数的局部变量i并赋值为1.
- 开始for循环
- i<6,true
- 待同步代码执行完毕后1秒将output函数推入异步队列。
- i++,i=2
- i<6,true
- 待同步代码执行完毕后2秒将output函数推入异步队列。
- i++,i=3
- ……
- i++,i=6
- i<6,false,结束for循环
- 结束执行init但未释放
- 1秒后向异步队列推入output
- 执行output(i=6)
- … …
发现输出6的原因了吗?
- init函数中只有一个变量i,每次循环后i自加1
- 根据异步原理,for循环全部执行完后才会执行output
- output执行时,变量i已经变为6了
解题方法
为循环中的每个异步都创建一个闭包,用来保存变量i的状态
我们在for循环内定义了立即执行函数,它具有一个参数j用于保存i,每次循环都是一个新的立即执行函数。以一次循环为例,进入循环体后首先执行立即执行函数,并将当前i值赋值给立即执行函数的参数j。等到这个output函数执行时,保留对j的引用。这样就将i的状态保存到j中了。
所以这道题的解题思路是,为异步函数外部增加一个作用域,作用域中的局部变量保存当前i的值。在异步函数运行时,可以访问这个值。
用ES6还可以这么写
本质是let相当于创建一个块级作用域,并且let用于for循环内计数时,每次迭代都会重新声明,重新声明后的变量的初始值是上一次迭代结束时的值。可以理解为,let用于for循环,每次的i都不是一个i,这样异步函数执行时访问的就是各自对应的i了。
——本节完——