理解闭包
提示:对下面定义的掌握,是你真正理解和识别闭包的关键。
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。按照《JavaScript 高级程序设计》书中所说:闭包是指有权访问另一个函数作用域中的变量的函数。
一个简单的闭包
直接通过代码来解释闭包才是最有效的。下面,让我们一起来看一段代码。
var name = '葡萄';
function eat() {
console.log( name );
}
eat(); // '葡萄'
正如代码所示,eat() 函数可以“记住”并访问变量 name。而这样的代码我们肯定写过不止一次,但可能从未意识到,我们其实是在创建一个闭包!
无需感到奇怪!因为变量 name 和函数 eat() 都是在全局作用域中声明的,而该作用域实际上就是一个闭包(只要应用处于运行状态,它就不会消失)。这也是为何,eat() 函数可以访问到外部变量,因为它仍然在作用域内并且是可见的。
另一个闭包
就上面一个例子而言,虽然闭包存在(不明显),但是我们无法对其进行观察,也不明白它是如何工作的。而我们下面这个例子,则将向你清晰地展示出闭包该有的样子。
function eat() {
var name = '葡萄';
function fruit() {
console.log( name );
}
return fruit;
}
var eatFruit = eat();
eatFruit(); // '葡萄'
从本例中,我们知道:
- fruit() 函数的词法作用域能够访问 eat() 函数的内部作用域
- fruit() 函数本身会被当作一个值类型进行返回
- 返回值(也就是 fruit() 函数)被赋值给变量 eatFruit 并调用 eatFruit()
虽然,调用 eatFruit(),实际上只是通过不同的标识符引用调用了内部的函数 fruit()。但是,它是在自己定义的词法作用域以外的地方执行的。
我们都知道 JavaScript 引擎的垃圾回收器,会释放不再使用的内存空间。而在正常情况下,eat() 函数执行完成后,它的整个内部作用域都会被销毁并被回收,变量 name 将不能再被访问。然而,代码仍按预期运行。为什么会这样呢?
原因是,上面代码中的函数形成了闭包。闭包是由函数以及声明该函数的词法环境组合而成的。该环境包含了这个闭包创建时作用域内的任何局部变量。在本例中,fruit() 还在使用 eat() 的内部作用域。由于 fruit() 所声明的位置,使得它拥有涵盖 eat() 内部作用域的闭包(该闭包不仅包含了函数的声明,还包含了在函数声明时该作用域中的所有变量),因此该作用域能够一直存在,以供 fruit() 在之后任何时间进行引用。
为了便于理解,我们可以结合图1.1来看。
图中的大圆就代表着本例的闭包(这需要你的想象),只要内部函数 fruit() 一直存在,其闭包就会一直保存着该函数的作用域中的变量。
现在知道什么是闭包了吗?没错,fruit() 函数对 eat() 函数内部作用域的引用就叫作闭包。换句话说,一个函数和对其周围状态(词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包。
加强理解
下面展示的的两个案例都对函数类型的值进行了传递。它们都在使用闭包。
案例一
function eat() {
var name = '葡萄';
function fruit() {
console.log( name );
}
eatFruit( fruit );
}
function eatFruit(fn) {
fn(); // 这就是闭包
}
eat()
案例二
var fn;
function eat() {
var name = '葡萄';
function fruit() {
console.log( name );
}
fn = fruit;
}
function eatFruit() {
fn(); // 这个也是闭包!
}
eat();
eatFruit();
通过对以上案例的观察,我们能够知道,无论使用何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用。因此,无论我们在何处执行这个函数,它必然会使用闭包。
另外,我想大家都可能写过下面这样的代码:
function backFn(value) {
setTimeout( function handleTimer() {
console.log( value );
}, 1000);
}
backFn( "葡萄" );
很显然,将内部函数 handleTimer 传递给 setTimeout,那么 handleTimer 就会具有涵盖 backFn 函数作用域的闭包。所以,在 backFn() 执行1000毫秒后,其内部作用域不会消失,handleTimer 也仍保有对变量 value 的引用。
举这个例子,主要是想告诉大家,在定时器、Ajax 请求、事件监听器或任何其他的异步(或同步)任务中使用回调函数时,实际上就是在使用闭包!这样看来,是不是觉得自己已然使用过很多次闭包了呢!
循环和闭包
对于下面的一段代码,大家应该都不陌生。
for (var i=1; i<=5; i++) {
setTimeout( function handleTimer() {
console.log( '我是:' + i );
}, 1000 );
}
通常来说,我们会认为这段代码将分别输出数字 1~5,然而实际上,这段代码会输出五次 6。
为什么呢?要搞懂这个原因,需要明白两点:
- setTimeout 的回调会在循环结束时才执行,即使你将延迟时间设为 0 毫秒。
- 循环的终止条件是 i 不再小于等于 5,而条件成立时 i 的值为 6。
因此,输出的值将是循环结束时 i 的最终值,也就是 6。
出现这种错误预期的原因:
- 想当然地认为循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。
- 没有意识到在各迭代中分别定义的函数,其实都被封闭在一个共享的全局作用域中,而这个作用域中只有一个 i 变量。
事实上,上面的代码同在全局作用域中,定义一个变量 i,然后再将延迟函数的回调重复定义五次,是完全等价的。
那么该如何解决这个问题呢?答案是:使用闭包。
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout( function handleTimer() {
console.log('我是:' + j);
}, 1000 );
})(i);
}
在这个例子中,将循环和 IIFE(立即执行函数表达式)进行结合,为每次迭代创建一个闭包并将 i 传递进去。最终,它将会按照我们预期的那样:输出数字 1~5。
问题解决了吗?是的!但是,这种方法有缺陷:它创建了很多闭包作用域。
我们都知道,过多的使用闭包并不是一件好事,因为它在处理速度和内存消耗方面对脚本性能具有负面影响。所以,我们来看一个更好的例子:
for (var i=1; i<=5; i++) {
let j = i;
setTimeout( function handleTimer() {
console.log('我是:' + j);
}, 1000);
}
学习过 es6 语法的同学们应该知道:let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
在上面例子的 for 循环中,每次迭代都会用 let 声明变量,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量,因此每个闭包都绑定了块作用域的变量,从而使得我们不再需要额外的闭包。完美!当然,这个例子还可写的更简洁一点:
for (let i=1; i<=5; i++) {
setTimeout( function handleTimer() {
console.log('我是:' + i);
}, 1000);
}
使用闭包
常见的使用闭包的情景:回调函数和封装私有方法。
回调函数
回调函数指的是需要在将来不确定的某一时刻异步调用的函数。一般来说,使用这种回调函数,往往都需要访问外部数据。下面是一个在 click 事件的回调函数中使用闭包的简易例子。
<div id="btn">click</div>
<div id="btn2">click 2</div>
<script>
// 回调函数,设置字体大小
function handle(size) {
return function () {
document.body.style.fontSize = size + 'px';
};
}
// 封装click事件监听,以便能够监听不同的元素
function clickFn(eleId, size) {
let ele = document.getElementById(eleId)
ele.addEventListener('click', handle(size));
}
clickFn('btn', 30)
clickFn('btn2', 20)
</script>
通过监听 div 元素的 click 事件,修改 body 元素的 font-size 属性。
封装私有方法
许多编程语言(比如 Java)都支持将方法声明为私有的,即它们只能被同一个类中的其它方法所调用。私有方法不仅有利于限制对代码的访问,而且还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口部分。
然而,这一非常有用的特性,原生 JavaScript 并不支持。不过,我们却可以使用闭包来模拟私有方法,这一件值得庆幸的事。
var counter = function() {
var num = 0; // 私有变量
function countNum(val) { // 私有方法
num += val;
}
// 返回两个公共函数,以便外部能够访问变量 num 和 countNum 函数
return {
increment: function() {
countNum(1);
},
value: function() {
return num;
}
}
};
var c1 = counter();
var c2 = counter();
c1.increment();
console.log( c1.value() ); // 1
console.log( c2.value() ); // 0
上述案例中,每次调用 counter() 都会创建一个闭包且有自己的词法环境。每个环境中都包含两个私有项:num 变量和 countNum 函数。它们无法在外部直接访问,只能通过返回的两个公共函数(它们是共享同一个环境的闭包):increment 和 value 才能访问。且每个闭包都引用自己词法作用域内的变量 num ,互不影响。
参考
- 《你不知道的JavaScript》上卷
- 《JavaScript忍者秘籍》第2版
- JavaScript | MDN —— 闭包