你不知道的JavaScript(上卷)--闭包

731 阅读4分钟

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

通俗的来说就是:外部函数里定义内部函数,内部函数会调用外部函数的局部变量,外部函数的局部变量不会被释放。

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

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。

本质上无论何时何地,只要你使用了回调函数,实际上就是在使用闭包。

IIFE(立即函数执行表达式)

var a = 2; 
 
(function IIFE() {     
	console.log( a ); 
})();

虽然这段代码可以正常工作,但严格来讲它并不是闭包。为什么?因为函数(示例代码中 的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而 外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发 现的。

尽管技术上来讲,闭包是发生在定义时的,但并不非常明显,就好像六祖慧能所说:“既 非风动,亦非幡动,仁者心动耳。”5。

尽管 IIFE (也就是函数的立即调用)本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建 可以被封闭起来的闭包的工具。因此 IIFE 的确同闭包息息相关,即使本身并不会真的使用 闭包。

循环和闭包

要说明闭包,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 出来。

块作用域

for 循环头部的 let 声明还会有一 个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

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

这样输出的结果就是分别输出数字 1~5,每秒一次,每次一个。