通过前面‘词法作用域’、‘LHS与RHS查询’、‘函数声明与变量声明的提升’这三个文案的铺垫,现在我们来谈谈闭包。在此,并不好通过语言去描述闭包,只能通过例子去体会。
在讲解闭包前,你需要先知道以下知识:
一个函数在执行完毕后,这个函数所创建的词法作用域通常会被销毁(用‘通常’来修饰是因为闭包会保存这个词法作用域,所以从闭包的角度来说,并没有销毁,而且这个作用域还会在需要的时候被使用)
闭包的实质就是——函数在本身定义时的词法作用域之外执行,但是函数仍保存了在它定义时的词法作用域。闭包使得函数可以继续访问定义时的词法作用域
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包(也可以通过这句话去理解闭包的实质)
闭包在函数定义时就已经形成,只是我们使用与否
看下面代码:
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();
这是闭包吗?——当然不是!函数bar在其本身定义时的词法作用域(即foo函数的词法作用域)之内执行,这只是普通的词法作用域中的LHS与RHS查询
但是上述代码已经形成了闭包(因为最开始就说了——闭包在函数定义时就已经形成,一旦函数进行了传递等操作使函数在定义时的作用域外执行,那么,那时闭包才会发生作用),只是这儿的整个执行过程并没有使用到闭包而已!(这儿的闭包就是——bar函数已经保存了它定义时的词法作用域——即foo的作用域以及全局作用域,一定不要忘记全局作用域)
再看下面代码,它清晰地展示了闭包效果:
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 —— 这就是闭包的效果
bar在在自己定义时的词法作用域以外的地方执行
在foo() 执行后,通常会期待foo()的词法作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。
但是由于闭包的存在,事实上foo的词法作用域依然存在,因此没有被回收。谁在使用foo的词法作用域?原来是bar()本身在使用(不要忘记,bar本身也保存了全局作用域,当然,‘bar保存了全局作用域’这个说法或许并不正确!但是,实际是,只要foo的词法作用域还查询不到想要的标识符的话,LHS或RHS总会到全局作用域中去找,所以如果你认为‘bar保存了全局作用域’这个说法存在问题,那么从这个角度去理解也是合乎逻辑的)
拜bar() 所声明的位置所赐,它拥有涵盖foo() 内部作用域的闭包,使得该作用域能够一直存活,以供bar() 在之后任何时间进行引用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包
因此,在几微秒之后变量baz被实际调用(调用内部函数bar),不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问变量a
2个小练习
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); //这就是闭包!
}
var fn;
function foo() {
var a = 2;
function baz() {
console.log( a );
}
fn = baz; // 将baz 分配给全局变量
}
function bar() {
fn(); //这就是闭包!
}
foo();
bar(); // 2
上述的闭包代码已经十分直观了!因为为说明该问题,它们的结构都进行了人为的修饰
‘隐藏的闭包’就是说,平时在代码中不那么明显的闭包,虽然它确实是闭包,但是无论从结构或形式上都不像上一节的代码那么明显
function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
没错!上述代码是闭包的!——timer保存了wait的词法作用域(乐意的话!你还可以认为它保存了全局作用域)
将一个内部函数(名为timer)传递给setTimeout(..)。timer 具有涵盖wait(..) 作用域的闭包,因此还保有对变量message 的引用
wait(..) 执行1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有wait(..)作用域的闭包
深入到引擎的内部原理中,内置的工具函数setTimeout(..) 持有对一个参数的引用,这个参数也许叫作fn 或者func,或者其他类似的名字。引擎会调用这个函数,在例子中就是内部的timer 函数,而词法作用域在这个过程中保持完整
除此之外,闭包还潜藏在定时器(前面)、事件监听器(回调函数)、Ajax请求、循环、…中。说的绝对一点,在使用了回调函数的地方都存在闭包!
首先看一个较简单明显的例子:
function test(){
var a = 100;
function foo(){
a = 200;
}
function bar(){
console.log(a);
}
return [foo, bar];
}
var funArr = test();
funArr[0]();
funArr[1]();//200
显然,因为闭包,foo与bar都保存了test的作用域,而foo对a的修改直接影响了bar的执行结果,因此显而易见,foo与bar共享同一个test作用域(这也是闭包的一个特点)
再看一个例子:
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
//输出5次6
是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i
这样说的话,当然所有函数共享一个i 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有
如果我就要输出1~5,怎么办?——使用闭包
这样吗?
for (var i=1; i<=5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
})();
}
bu!!!应该这样
for (var i=1; i<=5; i++) {
(function(j) {//也可以将形参定义为i
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})( i );
}
在迭代内使用IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问
函数每执行一次就会生成一个新的作用域
ps:本文参考并引用下列书籍
《你不知道的JavaScript》(上卷)