JavaScript闭包浅析

JavaScript闭包浅析

一、前言

其实在网络上有很多关于JavaScript闭包的理解和分析,不管是从它是否是一个面试的热点,还是我们前端人必须知道的一个知识点,我觉得,既然想学习JavaScript,那么,这一关是必须要过的,毕竟它也体现在我们代码中的方方面面。

其实,我之前也看过很多的关于作用域闭包的分析,但是,今天在读《你不知道的JavaScript》这本书的的时候,发现在闭包那一节分析的挺好的,在此,我也趁机总结一下。

二、定义

这个是书中直接了当的一个定义:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

三、简单的示例

下面我们来看一段代码,清晰地展示了闭包:

function foo() {
var a = 2;
function bar() {
console.log( a );
 }
return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

函数 bar() 的词法作用域能够访问 foo() 的内部作用域。然后我们将 bar() 函数本身当作一个值类型进行传递。在这个例子中,我们将 bar 所引用的函数对象本身当作返回值。

在 foo() 执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz() ,实际上只是通过不同的标识符引用调用了内部的函数 bar() 。

bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。

在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。

拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以供 bar() 在之后任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。

当然,我们也可以用一个匿名函数来复述一下上边的例子,效果也是一样的:

function foo() {
var a=2;
return function(){
    console.log(a);
}
}
var baz=foo();
baz();

四、经典的一个循环和闭包的问题

要说明闭包, 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 的引用。循环结构让我们误以为背后还有更复杂的机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码是完全等价的。

下面回到正题。缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。

五、采用立即执行函数 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 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

问题解决啦!

六、大胆尝试,采用let,重返块作用域

仔细思考我们对前面的解决方案的分析。我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。第 3 章介绍了 let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

本质上这是将一个块转换成一个可以被关闭的作用域。因此,下面这些看起来很酷的代码就可以正常运行了:

for (var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log( j );
}, j*1000 );
}

但是,这还不是全部!(我用 Bob Barker 6 的声音说道) for 循环头部的 let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}

注意:

当我们直接运行上述代码的时候,会抛出如下的错误:

SyntaxError: Block-scoped declarations (let, const, function, class) not yet supported outside strict mode

意思也就是说:

块级作用域下的声明(let, const, function, class)等要在严格模式下才被支持。其实就是说ES6新语法在目前的环境下是不被支持的,可用一些工具对代码进行转化。

解决方法:在文件首部加上一句 “use strict”即可:

"use strict"
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
    console.log( i );
}, i*1000 );
}

运行结果就会跟我们想像的一样了:

七、总结

其实,我在上边说到了有关于闭包的内容,也只是参考了《你不知道的JavaScript》一书中的内容,但是有关于闭包的真正用途以及更深层次的概念,还待你自己去真正地领悟和开发,不过,感觉上边的那个let的用法的确让人眼前一亮,不过,可能也只是我而已,毕竟,我还是一个小菜鸟。