前言
本文参考资料
《你不知道的JavaScript》上卷
《深入理解ES6》
《JavaScript高级程序设计》(第四版)
JavaScript深入之闭包
一、闭包是什么?
MDN对闭包的定义:
闭包是指那些能够访问自由变量的函数
《JavaScript高级程序设计》对闭包的定义:
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
在《JavaScript权威指南》中说:
所有的JavaScript函数都是闭包
这个听起来是不是有点奇怪?但是仔细想想的确如此,理论上,所有的函数,它们都在创建的时候将上层上下文的数据保存起来了,也就是说,它们会自动记住使用隐藏[[Environment]]属性创建的它们的位置,然后它们的代码可以访问外部变量。
从实践的角度上来说,以下的函数才算是闭包:1. 即使创建它的上下文已经销毁,它仍然存在;2. 在代码中引用了自由变量(自由变量就是在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。)
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。 ——《你不知道的JavaScript》上
二、理解闭包
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();
这是闭包吗?
技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释bar()对a的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。
让我们看看这段代码,清晰地展示了闭包:
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()依然持有对该作用域的引用,而这个引用就叫作闭包
因此,在几微秒之后变量baz被实际调用(调用内部函数bar),不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问变量a。
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域。
三、你可能没有意识到的闭包
其实闭包的身影处处可见,让我们来看看下面这段代码:
function wait(message) {
setTimeout( function timer() {
console.log( message );
}, 1000 );
}
wait( "Hello, closure!" );
将一个内部函数(名为 timer)传递给setTimeout(…)。timer 具有涵盖 wait(…) 作用域的闭包,因此还保有对变量 message 的引用。
wait(…) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(…)作用域的闭包。
深入到引擎的内部原理中,内置的工具函数 setTimeout(…) 持有对一个参数的引用,这个
参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是
内部的 timer 函数,而词法作用域在这个过程中保持完整。
这就是闭包。
本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!
IIFE
通常认为IIFE是典型的闭包例子,但根据先前对闭包的定义,严格来讲它并不是闭包
var a = 2;
(function IIFE() {
console.log( a );
})();
这段代码中IIFE并不是在它本身的词法作用域外执行的。它在定义时所在的作用域中执行。而a是通过普通的词法作用域查找而非闭包发现的。
循环与闭包
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
},i*1000 );
}
这段代码在运行时会以每秒一次的频率输出五次 6。为什么?
- 延迟函数的回调会在循环结束时才执行,即使每个迭代器执行的是setTimeout(…, 0),所有的回调函数依然是在循
环结束后才会被执行,因此会每次输出一个 6 出来。 - 循环终止的条件是i不再<=5,条件首次成立时i的值是6。所以输出显示的是循环结束时i的最终值。
这里引伸出一个更深入的问题,代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是
根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
缺陷是什么?我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。
要想达到目的,我们可以这样
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function timer() {
console.log( j );
}, j*1000 );
})(i);
}
利用IIFE来创造更多的词法作用域,这里不传i进去的话,IIFE的作用域是空的,所以它需要有自己的变量,用来在每个迭代中储存i的值。
我们还可以这样
for (let i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
四、一些练习题
一道经典面试题:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0](); //3
data[1](); //3
data[2](); //3
改成闭包版本:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}
data[0](); //0
data[1](); //1
data[2](); //2
这道题很有意思!
var add = function(x) {
var sum = 1;
var tmp = function(x) {
sum = sum + x;
return tmp;
}
tmp.toString = function() {
return sum;
}
return tmp;
}
alert(add(1)(2)(3)); //6
举例add(1)(2)(3),代码执行add(1)的时候,声明了add函数的局部变量sum并赋值为1,同时返回子函数tmp,这样add(1)(2)(3)就相当于tmp(2)(3),因为tmp函数需要用的sum这个变量,使得add执行完毕之后并没有清除sum这个局部变量的数据。这样执行tmp(2)的时候将2与sum相加保存在sum上,同时返回自身tmp。这时sum为3,tmp(2)(3)就相当于tmp(3),然后运行tmp(3),把3与sum相加保存在变量sum上,同时返回tmp。这时add(1)(2)(3)运行之后结果是tmp函数(sum=6),而用console.log()函数显示结果的时候会将里面的内容自动转换为字符串,所以console.log(tmp)相当于console.log(tmp.toString()),而这个toString()函数被重定义为return sum,所以结果就是console.log(sum)//6,以此类推如果后面还有括号那么sum将继续加下去到最后剩下tmp然后运行toString()返回结果。
总结与思考
其实闭包并没有想象中难,只要理解并记住闭包的定义以及作用,就非常好理解闭包函数的运转机制了,做题也so easy!