闭包(closure
)是JavaScript
中一个“神秘”的概念,许多人都对它难以理解,我也一直处于似懂非懂的状态,前几天深入了解了一下执行环境以及作用域链,可戳查看详情,而闭包与作用域及作用域链的关系密不可分,所以就再深入去理解了一番。
词法作用域Lexical Scope
首先我们来理解一下作用域的概念:
通常来说,一段程序代码中所用到的标识符并不总是有效/可用的,而限定这个标识符的可用性的代码范围就是这个标识符的作用域
作用域有词法作用域与动态作用域之分,词法作用域也可称为静态作用域,这样与动态作用域看起来更对应。
- 词法作用域在词法分析阶段就确定了作用域,之后不会再改变;也就是说词法作用域是由你把代码写在哪里来决定的,与之后的运行情况无关
- 动态作用域在运行时根据程序的流程信息来动态确定作用域;也就是说动态作用域与运行情况有关
- 大部分编程语言都是基于词法作用域,其中包括
JavaScript
下面我们使用代码来说明两者的区别(此处仅仅使用JavaScript
来说明两种情况,实际上JavaScript
只基于词法作用域)
var cc = 6;
function foo() {
console.log(cc); // 会输出6还是66?
}
function bar() {
var cc = 66;
foo();
}
bar();
- 如果是词法作用域:会输出6,词法作用域在写代码时就静态确定了,也就是定义
foo
函数的时候就确定了,foo
函数的内部要访问变量cc
,由于foo
的内部作用域中没有cc
变量,所以会根据作用域链访问到全局中的cc
变量;这与在何处调用foo
函数无关。 - 如果是动态作用域:会输出66,动态作用域要根据代码的运行情况来确定,它关心
foo
函数在何处被调用,而不关心它定义在哪里;foo
函数的内部要访问变量cc
,而foo
的内部作用域中没有cc
变量时,会顺着调用栈在调用foo()
的地方查找变量cc
,此处是在bar
函数中调用的,所以引擎会在bar
的内部作用域中查找cc
变量,这个cc
变量的值为66
词法作用域链Lexical Scope Chain
var cc = 1;
function foo() {
var dd = 2;
console.log(cc);//1
console.log(dd);//2
}
foo();
console.log(dd); //ReferenceError: dd is not defined
上面这一段代码中,有全局变量cc
以及局部变量dd
,在foo
函数内部可以直接访问全局变量cc
,而在foo
函数外部无法读取foo
函数内的局部变量dd
。
这种结果的产生源于JavaScript
的作用域链,也正是因为这个作用域链才有了生成闭包的可能。
作用域链这一部分在另一篇文章中有详细介绍,可戳JavaScript基础系列---执行环境与作用域链,看完可以帮助更好的理解下文
什么是闭包?
关于闭包没有一个官方的定义,不同的书籍解读可能有些不同
在《JavaScript权威指南》中:
是指函数变量可以被隐藏于作用域链之内,因此看起来是函数将变量“包裹”了起来
在《JavaScript高级程序设计》中:
闭包是指有权访问另一个函数作用域中的变量的函数
在《你不知道的JavaScript--上卷》中:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用
域之外执行
在维基百科的定义:
在计算机科学中,闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。
其中自由变量指:
在函数中使用的,但既不是函数参数也不是函数的局部变量的变量
一开始我也一直纠结于闭包的定义,想确切的知道闭包是什么,但是由于没有官方的定义,难以确定。所以本文中将以维基百科中的定义为准即:
在计算机科学中,闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。
闭包的创建
根据闭包的定义我们可以看出,闭包的产生条件是函数以及该函数引用了自由变量,二者缺一不可。
而这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外这一描述是闭包的特性,使用闭包后能观察到的一种现象,而不是闭包产生的条件。所以之前看到有些人说,需要将一个函数的内部函数返回才能算闭包的言论我觉得应该是不正确的,这应该是在使用闭包。
常说的闭包会导致性能问题,也是因为这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外这一闭包特性,按理来说,在函数 执行后,函数的整个内部作用域通常都会被销毁,因为我们知道引擎有垃
圾回收器用来释放不再使用的内存空间,但是闭包可以阻止这件事的发生,从而可能导致内存中保存大量的变量,从而消耗大量内存产生网页性能问题。(注意是可以,可能而非一定)
下面我们直接来看几个栗子:
1.如果考虑全局对象,那么引用了全局变量的函数可以看做创建了闭包,因为全局变量相对于该函数来说是自由变量
var a = 1;
function fa() {
console.log(a);
}
fa();
此处,函数fa
引用了自由变量a
,fa
创建了闭包
2.更常见的是在一个函数内部创建另一个函数
function outer(){
var b = 2;
function inner(){
console.log(b);
}
inner();
}
outer();
此处,函数inner
引用了自由变量b
,inner
创建了闭包。
根据JavaScript基础系列---执行环境与作用域链中的描述我们可以知道,调用outer()
后,会进入Function Execution Context outer
的创建阶段:
- 创建作用域链,
outer
函数的[[Scopes]]
属性被加入其中 - 创建
outer
函数的活动对象AO
(作为该Function Execution Context
的变量对象VO
),并将创建的这个活动对象AO
加到作用域链的最前端 - 确定
this
的值
此时Function Execution Context outer
可表示为:
outerEC = {
scopeChain: {
pointer to outerEC.VO,
outer.[[Scopes]]
},
VO: {
arguments: {
length: 0
},
b: 2,
inner: pointer to function inner(),
},
this: { ... }
}
接着进入Function Execution Context outer
的执行阶段:
-
当遇到
inner
函数定义语句,进入inner
函数的定义阶段,inner
的[[Scopes]]
属性被确定inner.[[Scopes]] = { pointer to outerEC.VO, pointer to globalEC.VO }
-
遇到
inner()
调用语句,进入inner
函数调用阶段,此时进入Function Execution Context inner
的创建阶段:- 创建作用域链,
inner
函数的[[Scopes]]
属性被加入其中 - 创建
inner
函数的活动对象AO
(作为该Function Execution Context
的变量对象VO
),并将创建的这个活动对象AO
加到作用域链的最前端 - 确定
this
的值
- 创建作用域链,
-
此时
Function Execution Context inner
可表示为:innerEC = { scopeChain: { pointer to innerEC.VO, inner.[[Scopes]] }, VO: { arguments: { length: 0 }, }, this: { ... } }
- 接着进入
Function Execution Context inner
的执行阶段:遇到打印语句console.log(b);
,通过inner.[[Scopes]]
访问到变量b=2
- 至此,函数
inner
执行完毕,Function Execution Context inner
的作用域链及变量对象被销毁 - 然后函数
outer
也执行完毕,Function Execution Context outer
的作用域链及变量对象被销毁。
这种情况下,函数执行完毕后该销毁的都被销毁了,没有占用内存,所以这种情况下闭包是不会对性能有占用内存方面的影响的。
3.最常被讨论的闭包
栗子1
function fa(){
var n = 666;
function fb(){
console.log(n);
}
return fb;
}
var getN = fa();
getN();
此处,函数fb
引用了自由变量n
,fb
创建了闭包,并且fb
被传递到了创造它的环境以外(所在的词法作用域以外)。
这段代码的执行情况与上面类似,鉴于篇幅就不一一展开详细描述了,大家可以自己推一遍;现在主要描述一下不同之处,在fa
函数的最后,fa
函数将它的内部函数fb
返回了,按理说返回之后fa
函数就执行完毕了,其作用域链和活动对象应该被销毁,但是闭包fb
阻止了这件事的发生:
-
函数
fb
定义之后其[[Scopes]]
属性被确定,这个属性至此之后一直保持不变,直至函数fb
被销毁,可以表示为fb.[[Scopes]] = { pointer to fa.VO, pointer to globalEC.VO }
- 函数
fa
执行完毕后,将其返回值--fb
函数赋给了全局变量getN
,这样一来由于getN
是全局变量,而全局变量是在Global Execution Context
中的,需要等到应用程序退出后 —— 如关闭网页或浏览器 —— 才会被销毁,那么也就意味着fb
函数也要到这时才会被销毁 -
fb
函数的[[Scopes]]
属性中引用了fa
函数的变量(活动)对象,意味着fa
函数的变量(活动)对象可能随时还需要用到,这样一来fa
函数执行完毕之后,只有Function Execution Context fa
的作用域链会被销毁,而变量(活动)对象仍然会在内存中 - 这样遇到
getN()
语句时,实际上就是调用fb
函数,于是顺着fb
的作用域链找到变量n
并打印出来
这里我们分析一下,变量n
是闭包fb
引用的自由变量,创造这个n
这个自由变量的是函数fa
,此时fa
执行完毕之后,自由变量n
仍然可以访问到(仍然存在),并且在fa
函数外也能访问到(离开fa
之后)。这一点也就正对应于这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外
除了将内部函数return
这种方式之外,还有其他方式可以使用闭包,这些方式的共同之处是:将内部函数传递到创造它的环境以外(所在的词法作用域以外),之后无论在何处执行这个函数就都会使用闭包。
-
栗子2
function foo() { var a = 2; function baz() { console.log( a ); // 2 } bar( baz ); } function bar(fn) { fn(); } foo();
这个栗子中,是通过函数传参来将内部函数
baz
传递到它所在的词法作用域以外的 -
栗子3
var fn; function foo() { var a = 2; function baz() { console.log( a ); } fn = baz; // 将baz 赋给全局变量 } foo(); fn(); // 2
这个栗子中,是通过赋值给全局变量
fn
来将内部函数baz
传递到它所在的词法作用域以外的。
在栗子1和栗子3这种情况下呢,闭包使得它自己的变量对象以及包含它的函数的变量对象都存在于内存中,如果滥用就很有可能导致性能问题。所以在不需要闭包后,最好主动解除对闭包的引用,告诉垃圾回收机制将其清除,比如在上面这些例子中进行getN = null;fn = null
的操作。
4.经常用但可能并没有意识到它就是闭包的闭包
-
栗子1
function wait(msg) { setTimeout( function timer() { console.log( msg ); }, 1000 ); } wait( "Hello, closure!" );
上面的代码其实可以理解为下面这样:
function wait(msg) { function timer(){ console.log( msg ); } setTimeout( timer, 1000 ); } wait( "Hello, closure!" );
内部函数
timer
引用了自由变量msg
,timer
创建了闭包,然后将timer
传递给setTimeout(..)
,也就是将内部函数timer
传递到了所在的词法作用域以外。当
wait(..)
执行1000
毫秒后,wait
的变量对象并不会消失,timer
函数可以访问变量msg
,只有当setTimeout(..)
执行完毕后,wait
的变量对象才会被销毁。 -
栗子2
function bindName(name, selector) { $( selector ).click( function showName() { console.log( "This name is: " + name ); } ); } bindName( "Closure", "#closure" );
上面的代码其实可以理解为下面这样:
function bindName(name, selector) { function showName(){ console.log( "This name is: " + name ); } $( selector ).click( showName ); } bindName( "Closure", "#closure" );
内部函数
showName
引用了自由变量name
,showName
创建了闭包,然后将showName
传递给click
事件作为回调函数,也就是将内部函数showName
传递到了所在的词法作用域以外。
当bindName(..)
执行之后,bindName
的变量对象并不会消失,每当这个click
事件触发的时候showName
函数可以访问变量name
。
5.同一个调用函数创建的闭包共享引用的自由变量
function change() {
var num = 10;
return{
up:function() {
num++;
console.log(num);
},
down:function(){
num--;
console.log(num);
}
}
}
var opt = change();
opt.up();//11
opt.up();//12
opt.down();//11
opt.down();//10
opt.up
和opt.down
共享变量num
的引用,它们操作的是同一个变量num
,因为调用一次change
只会创建并进入一个Function Execution Context change
,通过闭包留在内存中的变量对象只有一个。
6.不同调用函数创建的闭包互不影响
function change() {
var num = 10;
return{
up:function() {
num++;
console.log(num);
},
down:function(){
num--;
console.log(num);
}
}
}
var opt1 = change();
var opt2 = change();
opt1.up();//11
opt1.up();//12
opt2.down();//9
opt2.down();//8
change
函数被调用了两次,分别赋值给opt1
和opt2
,此时opt1.up,opt2.up
以及opt1.down,opt2.down
是互不影响的,因为每调用一次就会创建并进入一个新的Function Execution Context change
,也就会有新的变量对象,所以不同调用函数通过闭包留在内存中的变量对象是独立的,互不影响的。
7.关于上面提到的两点,有一个谈到闭包就被拿出来的例子:
for(var i=1;i<6;i++){
setTimeout(function(){
console.log(i);
},i*1000);
}
上述例子乍一看会觉得输出的结果是:每隔1s
分别打印出1,2,3,4,5
;然而实际上的结果是:每隔1s
分别打印出6,6,6,6,6
。
那么是为什么会这样呢?下面就来解析一下(ES6
之前没有let
命令,不存在真正的块级作用域):
变量i
此处为全局变量,我们考虑全局变量,那么传递给setTimeout(...)
的这个匿名函数创建了闭包,因为它引用了变量i
;虽然循环中的五个函数是在各次迭代中分别定义的,但是它们引用的是全局变量i
,这个i
只有一个,所以它们引用的是同一个变量(如果在此处将全局对象想象成一个仅调用了一次的函数的返回值,那么这个现象便可以对应于 ———— 同一个调用函数创建的闭包共享引用的自由变量)
而setTimeout()
的回调会在循环结束时才执行,即使每个迭代中执行的是setTimeout(.., 0)
,而循环结束时全局变量i
的值已经变成6了,所以最后输出的结果是每隔1s
分别打印出6,6,6,6,6
。
要解决上面这个问题,最简单的方式当然是ES6
中喜人的let
命令了,仅需将var
改为let
即可,for
循环头部的let
声明会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
抛开喜人的ES6
,又该怎么解决呢,既然上面的问题是由于共享同一个变量而导致的,那么我想办法让它不共享,而是每个函数引用一个不同的变量不就好了。上面提到了 ———— 不同调用函数创建的闭包互不影响,我们就要利用这个来解决这个问题:
for(var i=1;i<6;i++){
waitShow(i);
}
function waitShow(j){
setTimeout(function(){
console.log(j);
},j*1000);
}
我们将循环内的代码改成了一个函数调用语句waitShow(i)
,而waitShow
函数的内容就是之前循环体内的内容;waitShow
内部传递给setTimeout(...)
的这个匿名函数仍然创建了闭包,只不过这次引用的是waitShow
的参数j
。
现在每迭代一次,便会调用waitShow
一次,而我们从上文中已经知道不同调用函数创建的闭包互不影响,所以就可以解决问题了!当然,这还不是你常见的样子,现在我们稍稍改动一下,就变成非常常见的IIFE
形式了:
for(var i=1;i<6;i++){
(function(j){
setTimeout(function(){
console.log(j);
},j*1000);
})(i)
}
balabala说了这么多,其实我们平常写代码的时候经常无意识的就创建了闭包,但是创建了我们不一定会去使用闭包,而闭包的“威力”需要通过使用才能看得到。
闭包的应用
闭包到底有什么用呢?我觉得总结成一句话就是:
“冻结”闭包的包含函数调用时的变量对象(使其以当前值留在内存中),并只有通过该闭包才能“解冻”(访问/操作留在内存中的变量对象)
粗看可能不是很能理解,下面我们结合具体的应用场景来理解:
-
恩。。。首先我们来看一个老朋友,刚刚见过面的老朋友
for(var i=1;i<6;i++){ (function(j){ setTimeout(function(){ console.log(j); },j*1000); })(i) }
在这个栗子中,每个
IIFE
自调用时,其内部创建的闭包将其当时的变量对象“冻结”了,并且通过将这个闭包作为setTimeout
的参数传递到IIFE
作用域以外;所以第一次循环“冻结”的j
的值是1,第二次循环“冻结”的j
的值是2......当循环结束后,延迟时间到了后,setTimeout
的回调执行(即使用闭包),“解冻”了之前“冻结”的变量j
,然后打印出来。 -
既然提到
setTimeout
,那再来看看另外一个应用,我们知道在标准的setTimeout
是可以向延迟函数传递额外的参数的,形式是这样:setTimeout(function[, delay, param1, param2, ...])
,,一旦定时器到期,它们会作为参数传递给function
。但是万恶的IE
搞事情,在IE9
及其之前的版本中是不支持传递额外参数的。那有时候我们确实有需要传参数,怎么办呢。通常的解决方法有下面这些:function fullName( givenName ){ let familyName = "Swift"; console.log("The fullName is: " + givenName + " " + familyName); } setTimeout(fullName,1000,"Taylor Alison");
- 使用一个匿名函数包裹
setTimeout(function(){ fullName("Taylor Alison"); },1000);
- 使用
bind
(ES5
引入)
setTimeout(fullName.bind(undefined,"Taylor Alison"),1000);
polyfill
-
使用闭包
function fullName( givenName ){ let familyName = "Swift"; return function(){ console.log("The fullName is: " + givenName + " " + familyName); } } let showFullName = fullName("Taylor Alison"); setTimeout(showFullName,1000);
fullName
内的匿名函数创建了闭包,并作为返回值返回,调用fullName()
后返回值赋给变量showFullName
,此时fullName
的变量对象被“冻结”,只能通过showFullName
才能“解冻”,定时器到期后,showFullName
被调用,通过之前被“冻结”的变量对象访问到givenName
和familyName
。
- 待续(有时间补上)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。