闭包在JavaScript
是一个非常重要得概念,但我觉得又比较难理解。因为它和其他的语言不一样,其他语言有在函数内部调用其他函数,但是多个函数嵌套好像是没有的。
在这之前,我觉得最好先了解一下作用域与变量提升。
作用域
通常,我们是这样定义的。在函数体外的声明的变量,我们称为全局变量;在函数体内声明的变量,我们称之为局部变量。函数体内可以访问到函数外的变量,反之却不行。这也好理解。
function foo(a){
var b = a *2;
function bar(c) {
console.log (a,b,c);
}
bar(b*3);
}
foo(2);
以上代码中:
- 包含在全局作用域的,只有一个标识符:
foo
- 包含在
foo
所创建的作用域,有:a
、bar
、c
- 包含在
bar
所创建的作用域内的,只有c
由于a
、bar
、c
都附属于foo()
的作用域气泡内,所以无法从foo()
外部(这里的外部就是全局作用域)对它们进行访问。由于c
附属于bar()
的作用域气泡内,所以也无法从foo()
的作用域内对它进行访问,全局作用域则更无法访问了,这就是所谓的链式作用域。
因为我们是通过函数而创建了一个封闭的作用域,所以也叫做函数作用域。
这里我们又开始讨论另一个问题:有名字的函数也意味着会污染全局作用域。所以,在JavaScript
中,函数表达式可以是匿名的。匿名的函数表达式书写起来方便快捷,但对代码的可读性/调试不太友好。所以,始终给函数表达式命名是一个最佳实践。
如果,我们采用IIFE
(立即执行函数表达式),将函数包含在一对()
内部,成为一个表示式,通过在末尾加上另外一个()
可以立即执行这个函数。这很好地避免了对全局作用域的污染(为什么这么说,因为函数名相当于在一个内部作用域中声明)。IIFE
还有很多其他的用法,我们下次可以讨论。
函数声明是不可以匿名的。
问题的重点来了,函数作用域是一个块作用域,那还有其他块作用域吗?比如:
for (var i=0; i < 10; i++){
console.log (i);
}
console.log(i); // 10
在C
语言中,for
循环会创建了一个局部的块作用域,使得for
循环之外的作用域是无法访问i
变量的(当然,对于C
语言,该变成for (int i=0; i < 10; i++)
)。但是,这里我们会第四行输出10
,这说明并没有for
循环创建的这个作用域。
所以,当使用var
声明变量时,它写在哪里都是一样的(这涉及到变量提升)。
幸好,ES6
改变现状,引入了let
关键字。可以将变量绑定到所在的任意作用域内(通常是{…}内部)。使用let
进行的声明不会在块作用域中进行提升。
for (let i=0; i < 10; i++){
console.log (i);
}
console.log(i); // ReferenceError
ES3
规范中规定try/catch
的catch
分句会创建一个块作用域,其中声明的变量仅在catch
分句中有效。当然,很少人会注意到,我们也不展开。
const
关键字同样可以创建块作用域变量,但其值是固定的常量。
if (true) {
var a = 2;
const b = 3;
a = 3; //正常
b = 4; //错误
}
console.log(a); //3
console.log(b); //ReferenceError
提升 – hoisting
var关键字声明的变量提升
a = 2;
var a;
console.log(a); // 结果输出 2
为什么会输出2
呢。其实呢,在编译阶段,JavaScript
会将变量进行提升,实际是进行如下的处理:
var a; //对变量的声明进行了提升
a = 2;
console.log(a);
那我们再举个例子:
console.log(a);
var a = 2;
会输出什么呢?答案是undefined
。因为实际上是这样解释的:
var a;
console.log(a); //undefined
a = 2;
变量a
声明了,但是并未赋值,所以值是undefined
(未定义状态)。
注意: 每个作用域都会进行提升操作,而不仅仅是全局作用域内。
函数提升
foo();
function foo(){
console.log(2);
};
因为函数声明被提升了,所以第一行代码可以被执行。
function foo(){
console.log(2);
};
foo();
到这里,我们来解释一下变数表达式为何不会被提升。
bar(); //TypeError: bar() is not a function
var bar = function foo(){
console.log(2);
}
上面这段代码被解释成:
var bar;
bar();
bar = function foo(){
console.log(2);
}
对于第二行代码来讲,我们只是定义了一个变量bar
,但尚未赋值为一个函数。对于引擎来说,我可以找到bar
这个变量,但是这个变量做了它不可能做的事情,所以是TypeError
的错误。
ReferenceError
通常指的是在作用域范围内找不到这个变量,而TypeError
是在作用域找到了但是做了它不可能做的事情。
函数优先准则
函数声明和变量声明都会被提升。但函数会首先被提升,然后才是变量。
闭包
闭包是什么?烦死了,我觉得烦,说不清,理还乱。
在Professional JavaScript for Web Developers
这本大牛的书中,是这样说的:闭包是指有权访问另一个函数作用域中的变量的函数。这可就宽泛了。
在你不知道的 JavaScript
一书中是这样解释的:当函数可以记住并访问所在的词法作用域时,就产生了闭包。这个说法就很抽象了。
function foo(){
var a = 2;
function bar(){
console.log(a); //2
}
bar();
}
foo();
这是个闭包吗?按照Nicholas C.Zakas
(上面这本书的作者)的说法,是。因为嵌套的函数bar()
可以访问foo()
作用域气泡的内容。也许会惊讶,这不就是作用域链的关系吗?我们在前面就解释过了呀!
我觉得,这是一般意义上的闭包。
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo(); //8
baz(); //闭包
上面这段代码中,在第八行代码foo()
被执行后,照理来讲,我们会期待foo()的整个内部作用域被销毁。然而第九行代码baz()
被调用后,bar()
被正常执行。所以,由于bar()
所声明的位置,它拥有的一个包含foo()
作用域的闭包,使得该作用域(即a
、bar
标识符)可以继续存活,可以供bar()
在之后的任何时间进行引用。
所以,即使这个内部函数bar()
在其他地方被调用了,但它仍然可以访问上游作用域。
本质上,无论何时何地。如果将函数当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。在定时器、时间监听器 Ajax 请求,跨窗口通信, Web Worker 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。 – 《你不知道的 JavaScript》
《Professional JavaScript for Web Developers》 主要从作用域链的角度解释了闭包的形成。(关于这一点,会在以后补充,这样就能和《你不知道的 JavaScript》有关闭包的内容对应起来了)
循环和闭包
这是个经常碰到的有关闭包和setTimeout()
的例子,不说这个例子的实际意义,我觉得还挺好玩的。
for (var i=1; i<=5;i++){
setTimeout(function timer() {
console.log(i);
},i*1000);
这段代码的目的当然是想说:每隔一秒,分别输出数字1~5。
实际输出:每隔一秒的频率输出五次6。
怎么回事?😳
setTimeout()
函数是一个延迟函数,timer()
是一个回调函数。我们将上面这段代码拆解一下:
...//A
setTimeout (function timer(){
//C
}, 1000);
...//B
这里,我们有三个要执行的操作A
、B
、C
。那我们怎么去描述这段代码的运行方式呢?一种是:执行A,然后设定一个延时等待1000毫秒,到时后马上执行C。
另一种说:执行A,设定延时1000毫秒,执行B,然后定时到时我们执行C。显然,这种说法(第二种)说话是更准确的。
所以,在等待第一个定时到时时,循环已经进行了5次。这是,i
的值已经是6
了。而回调函数timer()
最终访问的是全局作用域中的i
的值。但为什么会以每秒一次的频率呢?我们这么来解释。
第一个操作: i = 1; 定时1s;
第二个操作: i = 2; 定时2s;
第三个操作: i = 3; 定时3s;
第四个操作: i = 4; 定时4s;
第五个操作: i = 5; 定时5s;
第六个操作: i = 6; 循环停止;
第七个操作: 1s的定时到;console.log(6);
第八个操作: 2s的定时到;console.log(6);
第九个操作: 3s的定时到;console.log(6);
第十个操作: 4s的定时到;console.log(6);
第十一个操作: 5s的定时到;console.log(6);
这样是不是好理解多了。但也会想,我抛弃时间,只要打印出1~5就行了。然后改成setTimeout(..., 0)
。我让每次循环的定时为0
毫秒,那它肯定会立即执行吧。这个想法有点天真。
以前说过,setTimeout()
定时器的精度不会太高,只能确定你的回调函数不会在指定的时间间隔之前运行,也就是说也许就是定时时,或者定时后回调函数才会运行,这是一个叫JavaScript
任务队列决定的。怎么理解呢? 我记得有个人举了个这样的例子:
我们可以这样的任务队列看成是去
KFC
就餐。如果你要吃汉堡,点餐员通常不会直接给你配好你要吃的,而是会给你一个凭据,或者说是编号,告诉你这个汉堡大概需要等待多少时间做好。汉堡做好后,通知你取餐时,你也不能马上拿到你的汉堡。因为在你等待的这个时间,又有很多人在队伍里了,所以你需要排在队伍后边等待前面的人取完餐或者点完餐。当然事情真正去KFC
并不需要这么折腾,否则多累呀。
所以,第一次循环中你运行到setTimeout()
函数时,刚想立即打印i
的值,但是,不好意思,第二次循环已经排在你的前面了,第三次,第四次,第五次也排在你的前面。所以你只能默默地等待它们运行完。所以,回调函数依然是在循环结束后才会执行,每次输出一个6
。
然后,我们就抓狂了。要我咋办?用立即执行函数表达式创建一个封闭的作用域
for (var i = 1; i<=5; i++){
(function (j) {
setTimeout(function timer() {
console.log(j);
}, j*1000);
})(i);
}
在迭代内使用IIFE
会为每个迭代生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代的内部,每个迭代中都会含有一个含有正确值的变量供我们访问。
它的本质是将块作用域转换成一个可以被关闭的作用域。
当然,这不是唯一的解决方法,如果我们使用let
关键字来声明变量。
for (let i =1; i<= 5; i++){
setTimeout(function timer() {
console.log(i);
},i*1000);
}
所以,多使用let
而不是var
是一个最佳实践。
我们来举一个闭包的例子
这个例子所涉及到的仍然是上面的内容。
var gimmeSomething = (function (){
var nextVal;
return function () {
if (nextVal == undefined)
nextVal = 1;
else
nextVal = (3 * nextVal) + 6;
return nextVal;
};
})();
console.log(gimmeSomething()); //1
console.log(gimmeSomething()); //9
console.log(gimmeSomething()); //33
console.log(gimmeSomething()); //105
惊奇吧。按照我们其他语言的想法,每次的结果都应该输出1
呀。但,这是闭包呀。我们第一次运行gimmeSomething()
时,引用调用了内部的匿名函数,这个函数涵盖了立即执行函数的内部作用域的闭包,所以该作用域一直存活,也就是说变量nextVal
一直存活。是不是很绕?也许是我自己很绕吧。
匿名函数和闭包
两者没有直接的联系,只不过很多情况下,有匿名函数的地方就有闭包。
闭包的作用
模仿块级作用域
虽然说模仿块级作用域并不是闭包的功劳,而是通过 IIFE 函数实现的。比如:
(function(){
// 这里是块级作用域
});
但是呢,我们通常将 IIFE 和闭包结合起来,所以说也有闭包的一点功劳。比如下面这个栗子。
function outputNumbers (count){
(function (){
for (var i = 0; i < count; ++i){
// do something
}
})();
}
这个例子中,用 IIFE 实现了一个块级作用域,但私有作用域中能够访问变量 count,就是闭包的结果。
在对象中创建私有变量
严格来说,在 JavaScript 中,所有对象的属性都是公有的。但是,倒是有函数作用域这一概念,使得在函数中定义的变量,都可以认为是私有变量,因为不能在函数的外部访问这些变量。
我们在函数内部创建闭包,就可以创建用于访问私有变量的公有方法。举例,定义个构造函数。
function MyObject (){
// 私有变量和私有函数
var privateVariable = 10;
function privateFunction (){
return false;
}
//公有方法
this.publicMethod = function (){
privateVariable++;
return privateFunction();
};
}
在这个例子中,通过构造函数 MyObject 创建的实例是没有办法访问 privateVariable 和 privateFunction(),只能使用 publicMethod() 这一途径。而公有方法 publicMethod() 由于闭包的关系可以访问到 privateVariable 和 privateFunction()。这就是闭包起的作用。
闭包的缺陷
可能会造成内容泄露。