闭包是函数式编程中特有的东西,同业也是JavaScript的特色,很多高级应用都要依靠闭包实现。以前看过很多关于闭包的解释,大多解释得比较含糊,没有说到底层的原理上或是对底层原理一笔带过,导致会有似懂非懂,似是而非的感觉。闭包还涉及到另一个重要的知识点,就是JavaScript中上下文和运行时如何找上下文的问题。这把这个问题搞清楚,才能解释闭包的问题。
上下文
当我们启动程序时,我们从全局执行上下文中开始。一些变量是在全局执行上下文中声明的。我们称之为全局变量。当程序调用一个函数时,会发生什么?有以下几个步骤:
- JavaScript创建一个新的本函数的执行上下文,我们叫作本地执行上下文。
- 这个本地执行上下文将有它自己的一组变量,这些变量将是这个执行上下文的本地变量。
- 新的执行上下文被推到到执行堆栈中。可以将执行堆栈看作是一种保存程序在其执行中的位置的容器。
函数什么时候结束?当它遇到一个return
语句或一个结束括号}
。当一个函数结束时,会发生以下情况:
- 这个函数的本地执行上下文从执行堆栈中弹出。
- 函数将返回值返回给外层的调用上下文(调用上下文是调用这个本地的执行上下文),它可以是全局执行上下文,也可以是另外一个本地的执行上下文。返回的值可以是一个对象、一个数组、一个函数、一个布尔值等等,如果函数没有
return
语句,则返回undefined
。 - 函数的本地执行上下文被销毁,销毁是很重要,这个本地执行上下文中声明的所有变量都将被删除,不再有变量。实际上就是被垃圾回收器回收了,释放内存空间。
词法作用域
任何语言的变量都有作用域,作用域机制是为了方便垃圾回收器回收的,在词法上分为两种作用域:
- 全局作用域 —— 第一次执行代码的默认环境(在浏览器中最外层就是window,即
var
声明的变量) - 函数作用域 —— 当执行流进入函数体时
fn(…)
—— 当调用方法时,我们把当前 执行上下文 认作是当前代码执行的一个环境与作用域。举个栗子,我们看一下下面的代码:
let val1 = 2
function multiplyThis(n) {
let ret = n * val1
return ret
}
let multiplied = multiplyThis(6)
console.log('example of scope:', multiplied)
为了理解JavaScript引擎是如何工作的以及理解词法作用域的一些知识。,让我们详细分析一下:
- 在全局执行上下文中声明一个新的变量
val1
,并将其赋值为2
。 - 第
2-5
行,声明一个新的变量multiplyThis
,并给它分配一个函数定义。 - 第
6
行,声明一个在全局执行上下文multiplied
新变量。 - 从全局执行上下文内存中查找变量
multiplyThis
,并将其作为函数执行,传递数字6
作为参数。 - 新函数调用(创建新执行上下文),创建一个新的
multiplyThis
函数执行上下文。 - 在
multiplyThis
执行上下文中,声明一个变量n
并将其赋值为6
。 - 第
3
行。在multiplyThis
执行上下文中,声明一个变量ret
。 - 来到的重要的一步,继续第
3
行。对两个操作数n
和val1
进行乘法运算.在multiplyThis
执行上下文中查找变量n
。我们在步骤6中声明了它,它的内容是数字6
。在multiplyThis
执行上下文中查找变量val1
。multiplyThis
执行上下文没有一个标记为val1
的变量。我们向调用上下文查找,调用上下文是全局执行上下文,在全局执行上下文中寻找val1
。哦,是的、在那儿,它在步骤1中定义,数值是2
。(在JavaScript中如果一个变量在本作用域内找不到,就会一直向外层的作用域找,直到找到为止,如果最外层也没有那就是undefined
) - 继续第
3
行。将两个操作数相乘并将其赋值给ret
变量,6 * 2 = 12,ret 现在值为12
。 - 返回
ret
变量,销毁multiplyThis
执行上下文及其变量ret
和n
。变量val1
没有被销毁,因为它是全局执行上下文的一部分。 - 回到第
6
行。在调用上下文中,数字12
赋值给multiplied
的变量。 - 最后在第
7
行,我们在控制台中打印multiplied
变量的值
在这个例子中,我们需要记住一个函数可以访问在它的调用上下文中定义的变量,这个就是词法作用域(Lexical scope)。
闭包
直接上代码
function outer() {
var a = 'hello world';
function inner() {
return a ;
}
return inner();
}
var b = outer();
// hello world
console.log(b());
在outer函数中声明了一个字符串变量a和一个函数变量inner,然后我返回了函数变量inner。然后在外面执行返回的函数。疑问:为什么输出hello world
,因为按正常理解,最后执行的b函数实际是inner函数,而inner函数是通过执行outer函数返回的,在执行完outer函数后,outer函数的上下文已经销毁了,outer函数上下文中的变量应该被回收了,为什么inner函数能访问到outer函数中的a变量
为什么会这样?
之所以能实现这种效果,是因为闭包的特性使得返回的匿名函数的作用域链一直保存着对a变量
的引用。什么意思呢,从另一个方面来解释,假设不存在闭包这个特性,那上面的代码执行效果又会变成什么样?
如果按照前面的理解,执行完outer函数后,outer函数上下文被销毁,变量被回收。那么函数变量inner应该也被回收,所以第10行
代码应该返回undefined
,当然你会反驳说因为第10行代码把inner赋值给了外层的b变量,所以相当于外层的上下文引用了inner函数,所以inner不能被回收,但外层上下文没有因为a变量,所以a应该被回收。此时引用链应该是 windows -> inner
,既然说到了这,别忘了inner中引用了 a变量,所以完整的引用链应该是 windows -> inner -> a
。所以外层上下文通过 inner 间接的引用了 a变量
。这就是闭包实现的真正原因
闭包的原理:内部函数(返回的匿名函数)的作用域链仍然保有对 外部函数的变量type的引用。垃圾回收器不会对它回收
当调用outer()
之后,内部函数执行环境的作用域链就有了包含a变量
的活动对象,垃圾回收的机制之一就是 判断一个对象是否存在被引用,如果是则不清除。而此时内部函数被全局的b变量
引用,所以在执行完b()
后,内部变量a依然存在。
闭包的滥用会导致一些副作用,比如内存溢出、调试困难等。所以要慎用,清除闭包的方法就是消除引用。在该例中,令b = null 即可清除引用。
总结
说了这么多总结一下吧,一句话:闭包的原因是因为存在引用链,阻止了垃圾回收。