启示
闭包并不是一个需要学习新的语法或模式才能使用的工具,它也不是一件必须接受像Luke2一样的原力训练才能使用和掌握的武器。
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境。
最后你恍然大悟:原来在我的代码中已经到处都是闭包了,现在我终于能理解它们了
实质问题
下面是直接了当的定义,你需要掌握它才能理解和识别闭包:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
下面用一些代码来解释这个定义。
function foo() {
var a = 2;
function bar() {
console.log( a ); // 2
}
bar();
}
foo();
基于词法作用域的查找规则,函数bar()可以访问外部作用域中的变量a.
技术上来讲,也许是闭包。但根据前面的定义,确切地说并不是。
从纯学术的角度说,函数bar()具有一个涵盖foo()作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为bar()被封闭在了foo()的作用域中。为什么呢?原因简单明了,因为bar()嵌套在foo()内部。
通过这种方式定义的闭包并不能直接进行观察,也无法明白在这个代码片段中闭包是如何工
作的。
下面我们来看一段代码,清晰地展示了闭包:
function foo() {
var a = 2;
function bar() {
console.log( a );
}
return bar;
}
var baz = foo();
baz(); // 2 ———— 朋友, 这就是闭包的效果
这个例子中,将bar()函数当做一个值类型传递,并将其引用的函数本身当作返回值。
在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过
不同的标识符引用调用了内部的函数bar()。
bar()显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方执行。
在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用
来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑对其进
行回收。
而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回
收。谁在使用这个内部作用域?原来是bar()本身在使用。
拜bar()所声明的位置所赐,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以
供bar()在之后任何时间进行引用。
bar()依然持有对该作用域的引用,而这个引用就叫作闭包。
这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作
用域。
当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包
function foo() {
var a = 2;
function baz() {
console.log( a ); // 2
}
bar( baz );
}
function bar(fn) {
fn(); // 妈妈快看呀, 这就是闭包!
}
把内部函数baz传递给bar,当调用这个内部函数时(现在叫作fn),它涵盖的foo()内部作用域的闭包就可以观察到了,因为它能够访问a。
传递函数当然也可以是间接的。
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)传递给setTimeout(..)。timer具有涵盖wait(..)作用域的闭包,因此
还保有对变量message的引用。
wait(..)执行1000毫秒后,它的内部作用域并不会消失,timer函数依然保有wait(..)作用域的闭
包。
本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类
型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通
信、Web Workers或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使
用闭包!
循环和闭包
for (var i=1; i<=5; i++) {
setTimeout( function timer() {
console.log( i );
}, i*1000 );
}
这段代码在运行时会以每秒一次的频率输出五次6。
事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被
执行,因此会每次输出一个6出来。
假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本。但是根据作用域
的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭
在一个共享的全局作用域中,因此实际上只有一个i。
第3章介绍过,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会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域
封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
重返块作用域
每次迭代我们都需要一个块作用域。第3章介绍了let声明,可以用来劫持块作用域,并且
在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。因此
for (var i=1; i<=5; i++) {
let j = i; // 是的, 闭包的块作用域!
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 );
}
小结
闭包就好像从JavaScript中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人才能够
到达那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的词法环境中书
写代码的。
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生
了闭包。
如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。
One More Thing
本文为原创文章,大部分来自于《你不知道的javascript》一书中,转载请注明出处https://xdlmr.github.io