网上有各种对闭包的比较靠谱的解释,这里说主流的两种:
1. 闭包是能读取其他函数内部变量的函数,本质上是函数内部和函数外部连接的桥梁。
2. 函数和其周围状态(词法环境)的引用捆绑在一起,形成闭包。
这里第一个说法相对比较好理解,但是第二个说法会比较晦涩,需要有一定的理论基础。
在做具体的分析之前,有几个概念需要提前弄明白:作用域,作用域链,执行上下文,环境变量。最好也能够明白执行栈和任务队列,容易放在一起考察。
环境变量:
全局变量和局部变量,分别声明在全局作用域和函数/块作用域中。
全局变量是任何地方都能访问的变量。声明全局变量代价很高,一是因为全局变量声明周期很长,二是因为变量暴露在外面很容易被修改。所以为了尽可能的减少全局变量在代码中的应用,闭包便可以用来让存储这些原本危险的变量,这点在之后的分析中说明。
局部变量储存在函数内部,或者块内部,无法被其作用域外部访问到。相对来说很安全。
作用域:
作用域有两种工作模型,词法作用域(静态)和动态作用域。JS采用的是词法作用域,意义为,函数的作用域在函数声明时便决定了。JS中主要是全局作用域和函数作用域,现在还有块作用域,ES6中的let和const声明就是块作用域的体现。
词法作用域的理解:
let a = 1;
function print(){
console.log(a);
}
function doPrint(){
let a = 2;
print();
}
doPrint(); // 1
在运行这段代码的时候,具体过程如下:
1. 首先一个变量和两个函数分别被放进内存中,作为全局变量和全局函数,然后执行doPrint函数
2. doPrint内部形成局部作用域,赋值a = 2,执行print函数
3. print函数运行,发出让控制台打印a的命令,然而print内的局部作用域并没有a,怎么办呢?需要向外查找。根据词法作用域的规则,我们应该忽略掉print运行时的作用域,转而去查找定义print函数时的作用域,向外查找,便找到全局变量a = 1,执行打印,得到结果为1
作用域链:
我们可以通过查找变量的过程来理解作用域链。像上面的例子,在print函数中的a变量,是在当前的上下文的变量对象中找不到的,此时会在父级的执行上下文中继续查找变量,这里正好是全局对象,也是作用域链的顶端。这种由多个执行上下文的变量对象形成的链表就叫做作用域链。
作用域链的特点:
1. 外部的作用域不可访问内部作用域中的变量
2. 内部的作用域可以访问作用域链上外部作用域中的变量
执行上下文:
当函数执行时,在执行栈中创建一个叫做执行上下文的内部对象,定义函数执行时的环境。
类型:全局执行上下文,函数执行上下文,eval函数执行上下文(较少见)
执行栈:
模型类似于数据结构中的栈结构,用于存储代码执行期间所有的执行上下文,首次运行JS代码时,就会创建一个全局执行上下文,并push到执行栈中,每当有函数运行的时候,便将这个函数执行上下文push到执行栈,如果函数中还有另外一个函数运行,那么以此类推。直到最内部的函数执行完毕,最内部的那个函数执行上下文pop弹出,返回上一层的执行上下文,继续运行代码,运行完毕后,当前的执行上下文继续弹出,以此类推,直到返回至全局上下文。
这里举个例子:
let a = 1;
function doPrint(){
let a = 2;
print();
console.log(a)
}
function print(){
console.log(a);
}
doPrint();
这里稍微对上面的例子进行一点修改,你觉得结果会输出什么呢?
下面是分析:
1. 先执行doPrint函数,将当前的函数执行上下文push到执行栈中,里面有个局部变量a = 2,保存到变量对象中。
2. 执行print函数,将这个函数的执行上下文push到执行栈中,然后打印a,寻找变量a,因为a不在当前的函数作用域中,所以我们也叫它自由变量,因此根据作用域链的关系,会向外查找,也就是全局作用域中,发现了全局变量a = 1,因此我们第一个输出的是1。
3. print函数执行完毕,当前执行上下文弹出栈,回到doPrint的函数执行上下文,继续执行,也是一个打印指令,输出a,寻找变量a,这次比较幸运,当前的函数作用域中就有a变量,所以第二个输出的是2。
4. doPrint函数执行完毕,当前执行上下文出栈,在全局执行上下文中往下继续执行代码。
所以结果就是先输出1,再输出2,怎么样,你答对了吗?
闭包:
最后来重新看闭包,梳理一下刚才的概念,回到最初说的两个主流的定义:
闭包是能读取其他函数内部变量的函数,本质上是函数内部和函数外部连接的桥梁。
我们看一个新例子:
let foo = function() {
const a = 1;
let bar = function() {
console.log(a);
}
return bar;
}
let res = foo();
res();
这里我们先定义了一个函数foo,函数内部定义了一个变量a和一个函数bar,返回函数bar。那么运行foo这个函数的结果,实际上返回的是一个函数,那res这里也是一个函数,再运行res,这样的一个过程。
然后我们分析一下这个运行过程:
1. 全局上下文中foo和res都存放在堆内存中(假设地址为AAA和BBB),变量对象(VO)中仅存放引用地址。
1. 运行res()函数,发现需要先运行foo()函数,开启foo函数的执行上下文(注意这里有两个括号是因为foo函数本身返回的也是一个函数,所以还需要一个括号运行)。
2. 开启foo的执行上下文之后,得到一个私有变量a = 1,和一个函数bar存放在另外一个堆内存中(假设地址为CCC),储存在新的活动对象(AO)中,代码继续执行,return bar这个函数,函数执行完毕。可是此时我们能弹出当前的这个执行上下文吗?我们发现,return bar的这个结果,实际上是foo(),也就是函数res的位置也存放在CCC这个地址。所以foo这个上下文中的部分内容(bar的堆内存地址是CCC)被外部的全局执行上下文占用(res占用),因此无法弹出,形成了一个闭包(closure)。
3. 然后在全局上下文中运行的foo()()现在就变成了bar(),bar这个函数,本身在全局上下文中是没有的,全局上下文中只有foo和res的引用地址,但由于res的引用地址是引用闭包里面的bar,因此在全局上下文中的代码执行,是有权访问foo的执行上下文中的内容,也就是bar这个函数。
4. 运行bar函数,打印a,当前作用域找不到,向外,找到了foo里的a = 1,于是输出1。
完整的分析过程就结束了。我们看到这个例子中,外部有权访问内部变量,也就是桥梁。
接着我们看闭包的另外一个主流说法
函数和其周围状态(词法环境)的引用捆绑在一起,形成闭包。
虽然还是听起来非常抽象,但是经过第一个例子的解释,更容易理解了。这里的函数就是例子里的bar函数。且bar函数和其词法环境的引用捆绑(res的引用地址最后指向的是bar的引用地址),形成闭包,并导致bar所在的执行上下文无法弹出,可以通过外面的res函数访问。
闭包的作用与问题:
所以闭包到底有什么用呢?函数内部与外部的桥梁,到底有什么用呢?
闭包的主要应用是避免全局变量的污染,也是之前在环境变量中提到的问题。多人开发中,为了避免全局变量重复,所以我们会尽量利用闭包,来将一些全局变量私有化,去尽可能的减少全局变量的使用。
闭包的问题在于,无法弹出的执行上下文,会一直占用内存,因此,滥用闭包的话会非常占用空间。解决方案是,在退出函数时,将不必要的变量全部删除,设置为null。