声明:以下内容摘自《你不知道的javascript》上卷一书的第5章的片段。
1.循环和闭包
要说明闭包,for循环是最常见的例子。
for (var i=1; i <= 5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000);
}
正常情况下,我们队这段代码行为的预期是分别输出数字1~5,每秒一次,每次一个。
但实际上,这段代码在运行时会 以每秒一次的频率输出五次6。
为什么???
首先解释6是从哪里来的。这个循环的终止条件是i不再<=5。条件首次成立时,i的值是6。因此,输出显示的是循环结束时i的最终值。
延迟函数的回调会在循环结束时才执行。
事实上,当定时器运行时,即使每个迭代中执行的是setTimeout(..., 0),所有的回调函数依然是在循环结束后才会被执行,因为会每次输出一个6。
这里引申出一个更深入的问题,代码中到底有什么 缺陷 导致它的 行为同语义 所暗示的不一致?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。
但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但它们都 被封闭在一个共享的全局作用域中,因此实际上只有一个i(i在全局作用域中)。
即所有函数共享一个 i 的引用。
应对缺陷,我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。
上一节介绍过,IIFE会通过声明并立即执行一个函数来创建作用域。
for (var i=1; i <= 5; i++) {
(function() {
setTimeout( function timer() {
console.log( i );
}, i*1000);
})();
}
这样能行吗?试试!
。。。这样不行。为什么呢?
我们现在显然拥有更多的词法作用域了。的确每个延迟函数都会将IIFE在每次迭代中创建的作用域封闭起来。
如果作用域是空的,那么仅仅将它们进行封闭是不够的。
仔细看一下,我们的IIFE只是一个什么都没有的空作用域。它需要包含一点实质内容才能为我们所用。
它需要有自己的变量,用来在每个迭代中储存 i 的值:
for (var i=1; i <= 5; i++) {
(function() {
var j = i;
setTimeout( function timer() {
console.log( j );
}, j*1000);
})();
}
行了!!!它能正常工作了!!!
对上述代码做一点改进:
for (var i=1; i <= 5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000);
})(i);
}
当然,这些IIFE也不过就是函数,因此我们可以将i传递进去,如果愿意的话可以将变量名定位j,当然也可以还叫i。
在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
重返块作用域
仔细思考我们对前面的解决方案的分析。
我们使用IIFE在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。
ES6中,let声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。
cool codes!
for (var i=1; i <= 5; i++) {
let j = i; // ding! 闭包的块作用域
setTimeout( function timer() {
console.log( j );
}, j*1000);
}
但是,这还不是全部!
for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不仅仅被声明一次,每次迭代都会声明!随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i=1; i <= 5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000);
}
Cool, right???
块作用域和闭包联手便所向披靡。反正这个功能让我成为了一名快乐的javascript程序员。