强烈建议在阅读本篇文章之前先去阅读本专栏【JavaScript修炼】之前的文章,否则本文对于初学者可能会难以理解。
闭包是什么?在MDN里的最新定义是:
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。
简单来说:闭包 = 函数 + 其周围环境引用
看完这个定义你可能会一头雾水,让我们来看一个例子:
var a = 1;
function foo() {
console.log(a);
}
foo();
函数foo能过访问自己内部环境的变量(虽然这里它没有内部变量)这是毫无疑问的,它也能访问外部的全局环境中的变量a,那么foo+它所能访问到的变量所处的环境的引用(这里是自身环境和全局环境)就是一个闭包。
但是上面的关于闭包的定义是技术层面或者理论层面上的,我们平时所说的闭包即实践层面上的闭包和它有所不同。
ECMAScript中,闭包指的是:
从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
从实践角度:以下函数才算是闭包:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
- 在代码中引用了自由变量
所谓自由变量就是指既不是既不是函数参数也不是函数局部变量的变量,在这个例子中,变量a对于函数foo来说就是自由变量。
所以闭包也可以这样定义:闭包 = 函数+函数能够访问的自由变量
来分析一个例子:
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
var foo = checkscope();
foo(); // 'local scope'
如果看不懂下面的分析可以去看看这个专栏之前的文章。
globalContext
,创建变量对象,其中包含了全局下各个变量声明和函数声明。checkscope()
,创建该函数的执行上下文checkscopeContext
并入栈。checkscopeContext
,复制函数的[[Scopes]]初始化作用域链。激活函数,创建活动对象,用arguments初始化,加入形参,函数内的变量声明和函数声明。讲活动对象添加到作用域链头部。初始化this。checkscope
的代码并根据代码给活动对象赋值,查找到标识符f并返回。checkscope
函数执行完毕,checkscopeContext
出栈。foo()
,其实是调用了函数f
,创建fContext
并入栈。fContext
,创建活动对象,作用域链,初始化this。fContext
的作用域链中查找到变量scope
,返回。f
执行完毕,fContext
出栈。了解了上述代码的执行过程,我们不禁有一个疑问:在执行foo的时候,函数checkscope
的执行上下文已经出栈了呀,那么foo是如何访问到它的内部变量scope
的呢?
其实这个问题我在上面的过程第10步中已经有提到了,那就是利用了作用域链。函数f在定义的时候就初始化了一个内部的[[Scopes]]属性,其中保存了父层级作用域链,可以表示如下:
f.[[Scopes]] = [checkscope.AO , global.VO]
然后在进入fContext
时又会复制[[Scopes]]
来创建作用域链并把自身的VO
加入作用域链顶端,可以表示如下:
fContext = {
ScopeChain: [ f.VO , checkscope.AO , global.VO ]
}
所以虽然checkscopeContext
在f
执行时已经销毁,但是f
的作用域链还在呀,换句话说,javascript在内部保存了f函数周围环境中的变量,所以即使checkscopeContext
已经死去,但是它的灵魂即它内部的变量等依然存在于内存之中,javascript借助词法环境就可以跟踪到这些变量,所以f
函数依然可以访问它们。
而这里的checkscope
函数就是文章标题里的**“虽死犹存的函数”**,死的是它的执行上下文(出栈销毁了),存的是它内部的变量(环境)。
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]();
输出结果都是3,让我们分析一下:
当执行到data[0]()
之前,全局的VO如下:
globalContext.VO = {
data: [...],
i: 3
}
在执行data[0]时,它作用域链表示如下
data[0]Context = {
AO = {
arguments: {...}
}
ScopeChain: [AO , globalContext.VO]
}
data[0]的AO并没有i这个变量,于是到globalContext.VO
去找,找到了,输出3,data[1]和data[2]也是同样的道理。
改写如下:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function(i){
return function(){
console.log(i)
}
})(i)
}
data[0]();
data[1]();
data[2]();
当执行到data[0]()
之前,全局对象的VO如下:
globalContext.VO = {
data: [...],
i: 3
}
和之前一样,但是data[0]
的作用域链发生了变化,如下:
data[0]Context = {
AO = {
arguments: {...}
}
ScopeChain: [AO , 匿名函数.AO , globalContext.VO]
}
匿名函数执行上下文的AO为
匿名函数Context = {
AO: {
arguments: {
0: 0,
length: 1
},
i: 0
}
}
于是data[0]()
输出0,data[1]()
输出1,data[2]()
输出2
闭包的应用主要有以下场景:
举个例子,有如下需求:
让页面上id为
box1
的元素每隔10毫秒向右移动tick
px,且tick
从0开始每次加1px。当tick > 100
时就停止。
简单啊,来:
var box1 = document.getElementById('#box1')
var tick1 = 0
var timer1 = setInterval(function(){
box1.style.left = tick + 'px'
} , 10)
ok,现在加一个需求:
给id为
box2
的元素也添加该动画
没问题啊,这么简单!ctrl + c ctrl + v
var box1 = document.getElementById('#box1')
var tick1 = 0
var timer1 = setInterval(function(){
if(tick1 > 100) clearInterval(timer1)
else box1.style.left = tick1 + 'px'
} , 10)
var box2 = document.getElementById('#box2')
var tick2 = 0
var timer2 = setInterval(function(){
if(tick2 > 100) clearInterval(timer2)
else box2.style.left = tick2 + 'px'
} , 10)
如果我们再加一个元素呢,再加100个呢?可以发现,这样子写的代码创建了一大堆的全局变量,也就是所谓的污染全局作用域,利用闭包则可以轻松解决上面的问题。
function animate(elementId){
var box = document.getElementById(elementId)
var tick = 0
var timer = setInterval(function(){
if(tick > 100) clearInterval(timer)
else box.style.left = tick + 'px'
} , 10)
}
animate('#box1')
animate('#box2')
可以看到这里使用了一个匿名函数来完成动画效果,该匿名函数作为计时器的一个参数传入,该函数通过闭包可以访问3个内部的变量:box
,tick
,timer
。这就是使用闭包的理由!
因为闭包会保存环境的引用,这些环境中的变量等是真实存在于内存之中的,因此从性能上考虑,闭包会造成内存的消耗。