JS闭包的底层运行机制
我已经使用闭包很长时间了,我学会了如何使用它,但是对于闭包怎样工作,幕后发生了什么,我并没有透彻的理解。闭包到底是个什么?维基没有帮到什么忙。当闭包创建与删除时,应该是怎样实现的?
1 |
|
当我最终搞清楚后,我非常兴奋决定向大家解释一下:至少我现在绝对不会忘了。
Tell me and I forget. Teach me and I remember. Invoke me and I learn.
Benjamin Franklin
当我在阅读现有的对闭包的解释时,我竭力将闭包与其他东西形象地关联起来的:哪一个对象引用了其他对象,哪一个对象继承了另一个对象,等等。我没找到想要的插图效果,所以我还是自己画吧。
我假设儒者都已经熟悉JavaScript,知道什么是全局对象(Global Object),知道在JS中,函数是“一等公民”,等等。
作用域链(Scope chain)
当JS代码在执行时,需要一些空间来存储局部变量(local variables).我们就把这些空间称为作用域对象(scope object, 也有人称之为词法环境(Lexical Environment))。比如,当你调用一些函数时,被调函数定义了局部变量,这些变量保存在作用域对象中。你可以把作用域对象看作一个普通对象,但是不能对其进行直接引用;你只能修改它的属性,但不能引用作用域对象本身。
这里,作用域对象的概念,与C或者C++将局部变量存储在栈中不同,JS中作用域对象被分配在堆内存中(至少表现出来是这样),所以即使函数已经返回,它们也可能保持分配状态。稍后再谈。
如你所望,作用域对象可能有父作用域对象(parent scope chain)。当代码要访问一些变量时,解释器查找当前作用域对象的属性,若属性不存在,解释器会到父作用域对象中查找,如果还没有,到父作用域对象的父作用域对象中查找,直到找到或没有父作用域对象为止。我们将这个线性查找过程中的作用域对象序列称之为作用域链。
在作用域链上查找变量的过程和原型链上查找属性的过程很相似,但有一点不同:当你要访问普通对象中并不存在的属性时,且这个属性在作用域链中也没有,它并不会报错,只是返回一个undefined。但你要是在作用域链中访问一个不存在的属性(即不存在的变量),那就会抛出ReferenceError.
作用域链中的最后一个查找的作用域对象就是全局对象(Global Object)。在JS的top-level code(最外层代码)中,作用域链中只有一个对象:全局对象。所以,当在最外层代码中定义变量,他们就定义在了全局对象上。当有函数调用时,作用域链就加入了多个作用域对象。你可能以为当一个函数在最外层代码被调用时,作用域链一定只有两个作用域对象,但这不是真的。因为可能有2个或多个,取决于具体情况。下面详解。
Top-level code
理论扯得够多了,我们来看点儿具体案例。下面是一个很简单的my_scripts.js
例子:
1 |
|
在最外层代码中我们创建了两个变量。我之前说过,对于最外层代码,作用域对象就是全局对象:
在上图中,有一个执行上下文(也就是最外层的my_scripts.js
代码),它引用了作用域对象。当然,全局对象中还有许多其他的东西没有画在图中。
非嵌套函数
考虑如下代码:
1 |
|
当定义了myFunc
函数后,myFunc
标识符就添加到了当前作用域对象中(本例中就是全局对象),这个标识符引用的是**函数对象(function object)。这个函数对象包含了函数的代码字符串和一些其他属性。这些属性中我们要关注的是内部属性[[scope]]
,它指向了当前作用域对象(current scope object)**,也就是,当函数被定义时,处于活动状态的作用域对象(本例中,就是全局对象)。
活动状态,就是作用域链查找时,第一个访问到的作用域对象,不用再向下查找。(《JS高级程序设计》里面的活动对象active object,作用域对象就是变量对象(variable object),到了执行阶段,处于活动状态的变量对象就是活动对象(active object))
所以,到console.log("outside");
被执行时,我们就有了下面的情况:
我们来看一下:myFunc
变量引用的函数对象不仅包含了函数代码,被引用的函数对象还指向函数定义时的作用域对象(图中[[scope]]
表示,也就是Global object
)。这非常重要!
当函数被调用时,创建新的作用域对象。新创建的作用域对象,继承自被调函数引用的作用域对象,并且包含了myFunc
的局部变量(以及它的参数值)。
所以,当实际调用myFunc
时,如下:
现在我们就有了一个作用域链:如果想要访问myFunc
中的变量,JS将会在第一个作用域对象:myFunc() scope
中查找。如果没有找到,就到作用域链的下一个作用域对象中(这里就是Global object
)查找。如果要找的属性在作用域链中并没有,就抛出ReferenceError
.
例如,我们访问myFunc中的a
,就会从第一个作用域对象myFunc() scope
中得到1
。如果要访问foo
,会在myFunc() scope
中得到3
,不用再到Global object
中找了。如果访问bar
,会从Global object
中得到2
。查找的机制和原型继承非常像。
需要注意的是,只要有对这些作用域对象的引用,这些作用域对象就会保存在内存中。当解除对某个作用域对象的最后一个引用时,可以对该作用域对象进行垃圾回收(但不一定立刻被回收)。
所以,当myFunc()
返回,且没有对myFunc() scope
的引用时,它会被垃圾回收。所以我们又回到之前的状态:
从现在起,我将不再在图中画出函数对象。但记得:在JavaScript中对函数的任何引用都指向函数对象,而函数对象又引用作用域对象。
嵌套函数
正如我们在前面的讨论中看到的,当函数返回后,没有引用作用域对象,因此,它会被垃圾回收。但是,如果我们定义嵌套函数并返回它(或存储在外部某处),会怎么样呢?您已经知道:函数对象总是指向它创建时的作用域对象。因此,当我们定义嵌套函数时,它会获得对外部函数的当前作用域对象的引用。如果我们将这个嵌套函数存储在外部某个地方,那么即使在外部函数返回后,作用域对象也不会被垃圾回收:仍然保持对它的引用!请考虑以下代码:
1 |
|
当我们调用createCounter(100)
时,有如下操作:
注意,createCounter(100) scope
被嵌套函数increment
和get
引用。如果createCounter()
没有返回任何内容,当然,这些内部自引用也就不会被包含在内,那么无论如何createCounter(100) scope
都会被垃圾回收。但是由于createCounter()
返回的对象包含对这些函数的引用,因此有以下内容:
花点时间思考一下:createCounter(100)
函数已经返回,但是它的作用域仍然存在,可以由内部函数访问,而且只能由这些函数访问。我们不能直接访问createCounter(100) scope
对象,只能调用myCounter.increment()
或myCounter.get()
。这些函数对createCounter
的作用域具有唯一的私有访问权。
让我们尝试调用,例如myCounter.get()
。回想一下,当调用任何函数时,都会创建新的作用域对象,并且由该函数引用的作用域链会用这个新的作用域对象进行扩充。所以,当调用myCounter.get()
时,情况如下:
函数get()
的作用域链的第一个作用域对象是空对象get() scope
。因此,当get()
访问counter
变量时,JavaScript无法在作用域链的第一个对象上找到它,进而到下一个作用域对象,并在createCounter(100) scope
上使用counter
变量。然后函数get()
返回它的值。
你可能已经注意到myCounter
对象还作为this
给到了函数myCounter.get()
(在图中用红色箭头表示)。这是因为this
从来都不是作用链的一部分,应该意识到这一点。后面会谈到this
。
调用increment(5)
比较有趣,因为这个函数有一个参数:
如上图,参数value
存储在调用increment(5)
创建的作用域对象中。当此函数访问value
值时,JavaScript立即在作用域链中的第一个对象上找到它。但是,当函数访问counter
时,JavaScript无法在作用域链中的第一个对象上找到它,进而到下一个作用域对象,并在那里找到它。因此,increment()
修改了createCounter(100) scope
上的counter
变量。实际上没有人能修改这个变量。 This is why closures are so powerful: the myCounter
object is impossible to compromise. Closures are very appropriate place to store private things.
注意,即使未使用到参数initial
,它也被存储在作用域对象createCounter()
中。因此,如果去掉显式的var counter = initial
,将initial
重命名为counter
,并直接使用它,我们可以节省一些内存。但是,为了可读性,我们使用参数initial
和var counter
。
重要的是绑定作用域是“活动的”。函数调用时,不会为此函数拷贝当前的作用域链,而是用新的作用域对象扩展当前作用域链,当链中的任何作用域对象被任何函数修改时,此更改将立即被其作用域链中包含此作用域对象的所有函数观察到。当increment()
修改counter
值时,对get()
的下一次调用将返回更新的值。
这就是为什么这个众所周知的例子不起作用(所有this.innerHTML
的i值都为elems.length
):
1 |
|
在循环中创建了多个函数,所有这些函数都引用了其作用域链中的同一作用域对象。所以,它们使用完全相同的变量i
,而不是它的私有副本。有关此例的进一步解释,请参见此链接:Don’t make functions within a loop.
相似的函数对象,不同的作用域对象
现在,我们试着扩展一点我们的counter
例子,来找点乐子。如果我们创建多个counter
对象呢:
1 |
|
创建myCounter1
和myCounter2
时,我们有以下内容:
牢记,每个函数对象都有对作用域对象的引用。因此,在上面的示例中,myCounter1.increment
和myCounter2.increment
引用的函数对象具有完全相同的代码和相同的属性值(name
、length
和其他),但它们的[[scope]]
引用的是不同的作用域对象。
为了简单起见,图中没有包含单独的函数对象,但它们仍然存在。
1 | var a, b; |
所以,这就是它的工作原理。闭包的概念非常强大。
作用域链和”this”
不管你喜不喜欢,this
不是作为作用域链的一部分保存的。相反,this
的值取决于函数调用模式:也就是说,您可以用不同的this
值调用相同的函数。
调用模式
这个主题非常值得再写一篇文章,所以我不会深入讨论,但是作为一个快速的概述,有四种调用模式。我们开始吧:
方法调用模式
1
2
3
4
5
6
7
8
9
10
var myObj = {
myProp: 100,
myFunc: function myFunc() {
return this.myProp
}
}
myObj.myFunc() //-- returned 100如果调用表达式包含refinement(点或
[下标]
),则函数将作为方法调用。所以,在上面的例子中,this
给myFunc()
的是对myObj
的引用。函数调用模式
1
2
3
4
5
6
function myFunc() {
return this
}
myFunc() //-- returns undefined如果没有refinement,则取决于代码是否在严格模式下运行:
- 在严格模式下,
this
是undefined
- 非严格模式下,
this
指向Global Object
- 在严格模式下,
构造函数调用模式
1
2
3
4
5
6
7
function Myobj() {
this.a = 'a'
this.b = 'b'
}
var myObj = new MyObj()当使用
new
前缀调用函数时,JavaScripts会分配继承自函数prototype
属性的新对象,而这个新分配的对象就作为this
分配给函数。Apply调用模式
1
2
3
4
5
6
7
8
9
10
11
function myFunc(myArg) {
return this.myProp + " " + myArg
}
var result = myFunc.apply(
{ myPtop: "prop" },
[ "arg" ]
)
//-- result is "prop arg"我们可以用任意值作为
this
传递。在上面的例子中,我们使用了Function.prototype.apply()
函数。除此之外,也可以用:Function.prototype.call()
Function.prototype.call()
下面的例子中,我们主要用方法调用模式。
嵌套函数中”this”的用法
考虑如下代码:
1 |
|
输出:hidden: value-in-closure', myProp: 'inner-value'
在调用myObj.createInnerObj()
时:
当我们调用myInnerObj.innerFunc()
时,它如下所示:
从上面可以清楚地看出,给到myObj.createInnerObj()
的this
指向了myObj
,而给到myInnerObj.innerFunc()
的this
指向myInnerObj
:这两个函数都是用方法调用模式调用的,如上所述。这就是为什么innerFunc()
中的this.myProp
的计算结果是"inner-value"
,而不是"outer-value"
。
所以,我们可以使用不同的myProp
简单地欺骗innerFun()
:
1 | /* ... see the definition of myObj above ... */ |
输出:hidden: 'value-in-closure', myProp: 'fake-inner-value'
或者使用apply()
或call()
:
1 | /* ... see the definition of myObj above ... */ |
输出:hidden: 'value-in-closure', myProp: 'fake-inner-value-2'
然而,有时内部函数实际上需要访问外部函数的this
,而与调用内部函数的方式无关。有一个常见的习惯用法:我们需要在闭包中显式保存所需的值(即,在当前作用域对象中),如:var self = this;
,并在内部函数中使用self
,而不是this
。考虑:
1 | ; |
输出:hidden: 'value-in-closure', myProp: 'outer-value'
这样,我们有如下:
如上图,这次,innerFunc()
通过闭包中存储的self
,可以访问到外部函数的this
值。
结论
让我们回答在文章开头的几个问题:
- 什么是闭包?-它是同时引用函数对象和作用域对象的对象。实际上,所有JavaScript函数都是闭包:没有作用域对象就不可能有对函数对象的引用。
- 它是什么时候创建的?-因为所有的JavaScript函数都是闭包,所以很明显:当定义一个函数时,实际上定义了一个闭包。因此,它是在定义函数时创建的。但要确保区分闭包创建和新作用域对象创建:定义函数时创建闭包(函数+对当前作用域链的引用),调用函数时创建新作用域对象(并用于扩展闭包的作用域链)。
- 什么时候删除?-就像JavaScript中的任何普通对象一样,当没有对它的引用时,它会被垃圾回收。
进一步阅读:
- JavaScript: The Good Parts 作者Douglas Crockford。理解闭包是如何工作的当然很好,但理解如何正确使用它们可能更重要。这本书非常简洁,里面有许多伟大且有用的模式。
- JavaScript: The Definitive Guide 作者David Flanagan。顾名思义,这本书以非常详细的方式解释了语言。