面试官:聊聊闭包

3,639 阅读16分钟

众所周知,闭包是js非常难的一个难点,但是我不认同,闭包其实很好理解,看完这篇文章,我相信你可以攻克这一难点。文章有点长,请大家多花点耐心。

为了讲闭包,我们还需要引入调用栈,以及作用域链这两个概念。

调用栈、作用域链

我们先看一段代码

while(1){
    console.log(123)
}

这个很好理解,输出会陷入死循环,永不停止。

我们再来看一段代码

function foo(){
    console.log('hello')
    foo()
}
foo()

这个代码的意思就是我们调用foo的时候会打印一个hello,并且之后再进行调用自己,再打印hello,再调用自己,一直下去。似乎也是陷入一个死循环,但是我们现在来看下这里的输出其实是跟上面有所不同。

hello
hello
.....//此处省略
hello
RangeError: Maximum call stack size exceeded

这里居然还会报错,call stack 其实就是调用栈的意思,这段代码报错说的是调用栈爆栈了,空间不足!

为什么这里用函数无限自己调用自己会产生空间不足?我们带着这个疑问走进调用栈这个概念。

调用栈其实就是用来存放执行上下文的一种数据结构并且他有一个很重要的特点,大家一定要记住!:当一个执行上下文执行完毕之后,他的执行上下文就会出栈

如何理解他的特点呢?大家还记得我之前写过一篇关于js预编译的文章吗,当时其实有讲过他的概念,并且我画了下面这张图,这张图里面有个指针其实并不完全对,这个我们待会儿讲作用域链的时候再讲。

画图_调用栈.jpg

这个调用栈就是用来存放执行上下文的,当一个执行上下文已经用完了之后我们的js执行引擎是需要进行一个出栈的动作的。栈的空间是有限的,如果一直不进行出栈,那么这个栈就会被挤爆掉,所以上面那个例子的输出结果我们可以理解了。while里面是输出语句,不会创建作用域或者说执行上下文,因此可以一直循环下去。这里又提到作用域,不熟悉的小伙伴可以去看下我的处女作和另一篇,讲的是作用域以及编译问题,这里放下链接

[](针对小白的js作用域详细介绍 - 掘金 (juejin.cn))

[](一道面试题带你全面认识js预编译底层逻辑 - 掘金 (juejin.cn))

关于这个调用栈我们又要进行绘图,这次我还是以手绘的形式给大家展示,尽量做到简洁明了。

我们先以下面这个案例进行讲解

var a = 2
function add(){
    var b = 10
    return a + b
}
add()

这个代码会输出什么,很明显,我们可以很快得出答案。结果就是12。这里我给小白嘀咕两句,想要看出这个代码输出的时候,我们可以直接复制代码到浏览器随便一个页面上右键检查然后去到console控制台上面运行,如果使用VScode得话,想要看出运行结果,最后一行代码改成console.log(add())即可。

我们现在带大家深入底层逻辑聊聊这个代码的运行过程。

大家如果清楚我前期聊过的预编译就清楚,这段代码我们会先创建一个GO对象预编译,这个对象中的执行过程如下

GO{
	a : undefined
	function :add()
}

GO对象是全局执行上下文的一部分,你可以理解成就是全局执行上下文。当全局执行上下文准备好之后,它就会入栈,并且每个栈内都会有个outer指针,outer指针的初始值为null,这个指针指向的是该执行上下文的外层上下文,现在开始执行全局执行上下文

GO{
	a : undefined -> 2
	add()
	outer: null
}

当执行到add()的时候,我们的预编译就会开始编译add函数,于是创建一个AO对象(这里看不懂的一定要看看我的js预编译那期,基础要打牢)

AO{
	b: undefined
	outer: null
}

当AO对象编译好后,或者说add函数执行上下文准备好后,add函数执行上下文会进行入栈,于是开始执行add函数执行上下文

AO{
	b: undefined -> 10
	return a + 10
	outer: null -> 全局执行上下文
}

这里我补充一下,顺带把作用域链解释清楚。outer为什么指向了全局执行上下文,outer这个东西其实就是构成了作用域链

作用域链:通过词法环境中的outer指针来确定某作用域的外层作用域,查找变量由内到外的这种链状关系。

说到词法环境,大家可能仅仅知道它就是用来存放let和const声明变量的值,其实这只是其中一个部分,它是由两个部分组成的,另外一个部分就是outer指针,官方话术为外部词法环境引用(Outer Lexical Environment Reference)

如何确定add执行上下文的外层环境呢,我们只需要看它在哪里声明即可,add函数声明在全局作用域中,所以它的外层作用域就是全局执行上下文(或全局作用域),所以它的outer指向了全局执行上下文。因此我们AO中没有a,查找就朝着outer指向的位置全局中查找,这才找到了a的值,所以最终return 12。所以我说我上面的那个图其实是不完全对,或者理解还没更加深入,它的指针并不是从上到下,而是看outer的指向,outer指向又依据你的声明位置。

下面给出作图帮助理解

1.jpg

下面再给个例子帮助大家深化理解

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: undefinedfunction: add()function: addAll()准备好之后入调用栈并且开始执行:a -> 2addAll() 于是开始编译addAll()

addAll执行上下文:d: undefinedresult: undefinedb ->3c->6 准备好后入调用栈并且开始执行:d -> 10result = add(3,6)

add执行上下文:b->3c->6 准备好后入调用栈并且开始执行,返回9

当9赋值给result的时候,我们的add执行上下文就已经执行完毕,当一个上下文执行完毕后会进行销毁,这就是一个解决栈满的机制。这也可以解释一直调用自己的函数的时候,它永远不会执行完毕,因此一直不销毁,再进行堆栈,所以会引发栈满,一般栈可以存放上百个上下文,不用去担心栈满。于是addAll开始执最后的return,a + result + b 里面只有a是找不到的,于是顺着addAll中的outer指针找,我们说了,指针的指向就是该函数的声明所在作用域,于是去全局找a,最后返回结果 2 + 9 + 10 = 21,于是addAll进行销毁。

大家应该都理解了上面的内容。好,现在给出一个例子看看大家是否理解到位

function bar(){
    console.log(myName)
}
function foo(){
    var myName = '大黑子'
    bar()
}
var myName = '小黑子'
foo()

大家认为输出会是什么呢?千万不要自己想当然,这道题没你想的那么简单。同样的,先自己做一遍,再来看我的分析与答案

全局执行上下文:myName: undefinedfunction:bar()function: foo() 准备好后入栈开始执行:myName -> '小黑子' 以及foo() 于是开始编译foo()

foo执行上下文:myName: undefined准备好后入栈开始执行:myName -> '大黑子'、bar(),于是开始编译bar()

bar执行上下文:没有变量,直接执行,打印myName,这里找不到myName于是开始求助词法环境中的outer指针,我们说了,指针指向就是自己函数声明所在的作用域,bar函数声明在全局执行上下文中,所以打印小黑子,所以执行foo函数就是执行bar函数,最终答案为小黑子。

如果你答案是大黑子肯定是直接看的外层作用域,实则不然,我们要看的是outer指针

最后给出一道题目,我们来看看这里会输出什么?

var arr = []
for(var i = 0; i <10; i++) {
    arr[i] = function() {
        console.log(i);
    }
}

for(var j = 0; j < arr.length; j++){
    arr[j]()
}

这个代码好像有点不友好,但是我们还是可以理解的。

这段代码意思就是给出一个数组,里面放10个函数,分别打印对应的i值。然后后面就是调用这个函数。按道理应该是打印0123456789。可是,我们运行后发现结果为10个10!???

这是为什么?

其实var是罪魁祸首,如果我们改成let,那么最后输出就是0123456789。这又是为什么,我们来一起自行分析下。

第一个for循环仅仅是给了10个函数声明,至于函数体我们是先不管的,等到调用函数的时候我们再来看或者说编译这个函数体,现在走到第二个for循环,函数名加一个括号就是函数的调用,因此这是在调用arr[0]arr[1]arr[9]这10个函数。我们就来看第一个,arr[0],里面只有一个执行语句,console.log(i),i不在arr[0]这个函数体内,于是我们要靠outer找到i,outer是指向全局的(因为arr[0]生声明在全局中),i现在等于多少呢,重点来了!当我们要开始执行的函数的时候,就代表前面的语句已经走完了,也就说i这个值已经从0走到了9,最后9还是小于10,i++,因此i最终的值为10!,所以我们的arr[0]打印出的值是10,由于后面所以的函数都是指向全局的i,并且执行函数的时候,i已经都是10了,所以最后的运行结果就是10个10!

上面的解释太过于仔细了,我们可以换个简短的说法,就是i这个值存在于全局中,等到执行函数的时候,i已经为10了,10个函数都是打印i,所以是10个10。

那为什么把var改成let了就可以正常输出0到9呢?

前面的文章我已经讲过,let可以和{}结合形成一个块级作用域,因此会形成10个块级作用域,每个块级作用域都有一个i,因此10个函数声明在这里就是声明在对应的块级作用域中,也就是outer指向了对应的块级作用域,第一个函数找i时,去第一个块级作用域中找,里面的i是0,第二个函数找i时,去第二个块级作用域中找,里面的i是1,如此循环,就是0到9了。

好,现在理解了这个代码的输出原理,我们就是希望能够输出0到9,这里的let是个方法,还有什么方法呢?

第二个解法就是用闭包的思想,这道题先放在这里,我们待会儿讲完闭包后再来分析这道题的解法

闭包

我们通过下面这个例子帮助大家来认识下闭包

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,这里需要注意innerBarinnerBar是个对象,里面可以存放各种数据类型,后面我还会出一片对象的文章专门聊聊对象。该执行上下文准备好后入栈开始执行,里面的赋值我就不多赘述了,最终会返回一个innerBar这个对象。

返回对象给bar后,正常来讲这个执行上下文是已经进行了自我销毁。bar现在就成了innerBar这个对象,这个对象里面有两个key是函数,于是bar.setName就是调用这个函数,这个函数最终会返回一个myName,也就是大黑子,于是现在开始执行bar.getName,这个函数里面需要打印test1这个变量,和返回myName,这两个在函数体内都没有,于是查看outer,发现outer指向的是foo这个函数执行上下文,但问题来了,这个foo执行上下文在foo()赋值给bar时就已经进行了销毁,所以打印结果时报错才对。可是这道题的运行结果居然是

1
大黑子

这是为什么呢,原来我们的js执行引擎在看到函数体内还有函数并且return的时候,在执行完外部函数的进行销毁的瞬间会丢出一个包,这个包就用来存放内部函数需要用到的变量,这里就是test1myName,里面的值分别为1和小黑子,丢出包也就是执行完foo,在此之后执行setName,这个时候setName指向的位置由原来的foo变为了foo丢下的闭包,于是寻找myName更改其值为大黑子,最后执行getName,输出为最终正确结果。``

整个过程非常绕,这里我还是给出一个贴图帮助大家理解

2.jpg 我们再来看一个更加直观的例子

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。

这里我犯了一个很愚蠢的错误,大家愿意看就看,不愿意看跳过这一段。这个代码我运行的时候放在浏览器上发现多打印了个undefined,然后我再把这段代码放到node中运行,并且把最后一行代码改成了console.log(c()),发现也打印了个undefined,于是我就去问了下老师。这才得知放在node运行不需要再来个console.log,因为c()里面就是一个console.log,这就相当于console.log(console.log(123)),我们可以看下这样运行会是什么

console.log(console.log(123))
输出:
123
undefined

undefined是因为里面的东西没有引号会被js执行引擎当成一个变量来执行,而这个变量并没有赋值,因此是undefined,然而我在浏览器控制台运行并没有console.log还会多出一个undefined,这原来是浏览器的一个默认行为,与代码是无关的。

好,现在我们收回到for循环那个题目,我们现在需要的是一个不改变var的情况下正常输出0到9这些数,既然要用闭包,我们肯定需要一个双层函数的结构,既然解决的是i没有赋值的问题,我们可以把i丢到闭包中,然后让console.log(i)指向这个i,就可以了。因此我们需要在第一个for循环中新增一个外层函数,让i赋值进去,我们就需要一个形参来接收。

代码如下

for(var i = 0; i < 10; i++){
    (function a(j){
        arr[i] = function(){
            console.log(j);
        }
    })(i)
}

for(var k = 0; k < arr.length; k++){
    arr[k]()
}

初看可能有点难以理解,因为这里用到了自执行函数。为了方便大家理解,我先解释下自执行函数。其实很好理解,我们声明一个foo函数是写成function foo(){},调用这个函数的时候foo()即可,这个foo就是一个函数体,因此我们完全可以把foo换成一个函数体,当然这里需要再加个(),于是这就是一个自执行函数

function foo(){

}
foo()
等同于
(function 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-9了。

其实这个题目还有另外一个种方法让它依次输出0到9,用到了定时器,有点取巧,我们这里就不讲了。

总结

如果上面的内容你都一一看完并且能够理解到位,那么恭喜你,你已经学会了闭包。我们可以通过上面的例子对闭包进行一个总结。

在js中,根据词法环境中的规则,内部函数总是可以访问到外部函数中的变量。当内部函数被返回到外部函数之外时,即使外部函数执行完毕被销毁时,内部函数还是可以引用到外部函数,并且此时的外部函数的执行上下文改成了一个闭包。

另外补充一点,一般闭包都是架构师用得多,这样做可以让变量私有化,当然我们使用闭包不能滥用,闭包还是存放在调用栈中,滥用还是会导致内存泄漏,所谓内存泄漏就是占用了栈空间


求赞.jpg

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