众所周知,闭包是js非常难的一个难点,但是我不认同,闭包其实很好理解,看完这篇文章,我相信你可以攻克这一难点。文章有点长,请大家多花点耐心。
为了讲闭包,我们还需要引入调用栈,以及作用域链这两个概念
调用栈、作用域链
我们先看一段代码
js
function foo(){
console.log('hello')
foo()
}
foo()
这个代码的意思就是我们调用foo的时候会打印一个hello,并且之后再进行调用自己,再打印hello,再调用自己,一直下去。似乎是陷入一个死循环,我们来看输出
js
hello
hello
.....//此处省略
hello
RangeError: Maximum call stack size exceeded
这里居然还会报错,call stack 其实就是调用栈的意思,这段代码报错说的是调用栈爆栈了,空间不足!
调用栈其实就是用来存放执行上下文的一种数据结构 并且他有一个很重要的特点,大家一定要记住!:当一个执行上下文执行完毕之后,它的执行上下文就会出栈 栈的空间是有限的,如果一直不进行出栈,那么这个栈就会被挤爆掉,所以上面那个例子的输出结果我们可以理解了。
我们再来看一段代码
js
var a = 2
function add(){
var b = 10
return a + b
}
add()
如何确定add执行上下文的外层环境呢,我们只需要看它在哪里声明即可,add函数声明在全局作用域中,所以它的外层作用域就是全局执行上下文(或全局作用域),所以它的outer指向了全局执行上下文。
每个执行上下文都会有个outer指针,outer指针的初始值为null,这个指针指向的是该执行上下文的外层上下文,v8在查找变量的过程中,顺着执行上下文中的outer指向查找一整根链,这种链状关系就叫作用域链
下面给出作图帮助理解

下面再给个例子帮助大家深化理解
js
var a = 2
function add(b,c) {
return b + c
}
function addAll(b,c){
var d = 10
var result = add(b,c)
return a + result +d
}
addAll(3,6)
全局执行上下文:a: undefined
、function: add()
、function: addAll()
准备好之后入调用栈并且开始执行:a -> 2
、addAll()
于是开始编译addAll()
addAll
执行上下文:d: undefined
、result: undefined
、b ->3
、c->6
准备好后入调用栈并且开始执行:d -> 10
、result = add(3,6)
add执行上下文:b->3
、c->6
准备好后入调用栈并且开始执行,返回9
当9赋值给result的时候,我们的add执行上下文就已经执行完毕,当一个上下文执行完毕后会进行销毁,这就是一个解决栈满的机制。这也可以解释一直调用自己的函数的时候,它永远不会执行完毕,因此一直不销毁,再进行堆栈,所以会引发栈满,一般栈可以存放上百个上下文,不用去担心栈满。于是addAll
开始执最后的return,a + result + b 里面只有a是找不到的,于是顺着addAll
中的outer指针找,我们说了,指针的指向就是该函数的声明所在作用域,于是去全局找a,最后返回结果 2 + 9 + 10 = 21,于是addAll
进行销毁,你想的答案是否对了呢?
最后给出一道面试题目,我们来看看这里会输出什么?
js
function fn() {
var arr = []
for (var i =0;i < 5;i++){
arr.push(function () {
console.log(i);
})
}
return arr
}
var funcs = fn()
for (var j= 0; j < 5; j++) {
funcs[j]()
}
我先直接说运行结果为5个5了。
这是为什么?我们可以简短分析一下,就是i这个值存在于全局中,等到执行函数的时候,i已经为5了,5个函数都是打印i,所以是5个5。如果我想正常输出0到4呢?
第一种解决办法就是var换成let
let可以和{}结合形成一个块级作用域,因此会形成5个块级作用域,每个块级作用域都有一个i,因此5个函数声明在这里就是声明在对应的块级作用域中,也就是outer指向了对应的块级作用域,第一个函数找i时,去第一个块级作用域中找,里面的i是0,第二个函数找i时,去第二个块级作用域中找,里面的i是1,如此循环,就是0到4了。
第二种解决办法就是我们今天的重点闭包
闭包
我们通过下面这个例子帮助大家来认识下闭包
js
function foo(){
var myName = '小黑子'
let test1 = 1
let test2 = 2
var innerBar = {
getName: function(){
console.log(test1);
return myName
},
setName: function(newName){
myName= newName
}
}
return innerBar
}
var bar = foo()
bar.setName('大黑子')
bar.getName()
我们来一起逐步分析下
全局执行上下文:function: foo() bar: undefined
准备好后入栈开始执行该上下文,bar -> foo()
,于是开始编译foo
foo执行上下文:myName: undefined, test1: undefined , test2: undefined innerBar: undefined
,这里需要注意innerBar
,innerBar
是个对象,里面可以存放各种数据类型,后面我还会出一片对象的文章专门聊聊对象。该执行上下文准备好后入栈开始执行,里面的赋值我就不多赘述了,最终会返回一个innerBar
这个对象。
返回对象给bar后,正常来讲这个执行上下文是已经进行了自我销毁。bar现在就成了innerBar
这个对象,这个对象里面有两个key是函数,于是bar.setName
就是调用这个函数,这个函数最终会返回一个myName
,也就是大黑子,于是现在开始执行bar.getName
,这个函数里面需要打印test1这个变量,和返回myName
,这两个在函数体内都没有,于是查看outer,发现outer指向的是foo这个函数执行上下文,但问题来了,这个foo执行上下文在foo()赋值给bar时就已经进行了销毁,所以打印结果时报错才对。可是这道题的运行结果居然是
1
大黑子
这是为什么呢,原来我们的js执行引擎在看到函数体内还有函数并且return的时候,在执行完外部函数的进行销毁的瞬间会丢出一个包,这个包就用来存放内部函数需要用到的变量,这里就是test1
和myName
,里面的值分别为1和小黑子,丢出包也就是执行完foo,在此之后执行setName
,这个时候setName
指向的位置由原来的foo变为了foo丢下的闭包,于是寻找myName
更改其值为大黑子,最后执行getName
,输出为最终正确结果。``
整个过程非常绕,这里我还是给出一个贴图帮助大家理解
我们再来看一个更加直观的例子
js
function a(){
function b(){
var bbb = 234
console.log(aaa);
}
var aaa = 123
return b
}
var c = a()
c()
这个题目其实已经很好理解了,全局中c需要被赋给a这个函数(a赋值完给c后销毁),而a这个函数返回了b这个函数,因此c其实就是接收了b这个函数,所以说执行c这个函数等同于执行b这个函数,而b原本指向了a这个函数,此时a销毁后留下了带有b函数所需参数的闭包,所以b函数指向了这个闭包,其实因此调用c会正常打印出aaa
的值123。
好,现在我们收回到for循环那个题目,我们现在需要的是一个不改变var的情况下正常输出0到4这些数,既然要用闭包,我们肯定需要一个双层函数的结构,既然解决的是i没有赋值的问题,我们可以把i丢到闭包中,然后让console.log(i)
指向这个i,就可以了。因此我们需要在第一个for循环中新增一个外层函数,让i赋值进去,我们就需要一个形参来接收。
js
for(var i = 0; i < 5; i++){
(function a(n){
arr[i] = function(){
console.log(n);
}
})(i)
}
for(var j = 0; j < arr.length; j++){
arr[j]()
}
初看可能有点难以理解,因为这里用到了自执行函数 。为了方便大家理解,我先解释下自执行函数。其实很好理解,我们声明一个foo函数是写成function foo(){}
,调用这个函数的时候foo()即可,这个foo就是一个函数体,因此我们完全可以把foo换成一个函数体,当然这里需要再加个(),于是这就是一个自执行函数。
这个for循环中的外层函数a()声明完后立即执行,并且还把i传了进去,至于里面的内层函数我们先不管,因为里面并没有return,等执行语句,里面只是把一个函数体赋值给了arr[0]
(i=0为例),这里需要注意,当a()
函数执行完毕的时候(自执行函数),这个a的执行上下文会销毁掉,a的执行上下文里面只有i这个参数,并且i = 0,在销毁的时候它需要留一下里面的内层函数是否会用到自己的参数,发现内层函数确实需要,于是arr[0]里面的i就是指向了a[0]
留下的闭包,如此循环,每个arr
都有自己对应的a,并且a走后留下对应的闭包给到arr,因此最后执行10个arr
内部函数的时候就是依次打印0-4了。
总结
如果上面的内容你都一一看完并且能够理解到位,那么恭喜你,你已经学会了闭包。我们可以通过上面的例子对闭包进行一个总结。
在js中,根据词法环境中的规则,内部函数总是可以访问到外部函数中的变量。当内部函数被返回到外部函数之外时,即使外部函数执行完毕被销毁时,内部函数还是可以引用到外部函数,并且此时的外部函数的执行上下文改成了一个闭包。
另外补充一点,一般闭包都是架构师用得多,这样做可以让变量私有化,当然我们使用闭包不能滥用,闭包还是存放在调用栈中,滥用还是会导致内存泄漏,所谓内存泄漏就是占用了栈空间

如果觉得本文对你有帮助的话,可以给个免费的赞吗?