理解 JavaScript 闭包

在 JavaScript 中,原型(prototype)和闭包(closure) 可以说是这门语言的精髓,也是不太容易理解和掌握的地方。比如 Eric Elliott 就认为这两个概念是 JavaScript 的 两大支柱。前者是 JavaScript 的 OOP 实现,后者是函数式编程的关键所在。本文尝试探讨一下闭包的形成原理以及它的作用。

First-class function

和所有的函数式编程语言一样,JavaScript 的函数也是 First-class function,这意味着函数可以:

  • 赋值给一个变量;
  • 作为参数传递给其他函数;
  • 作为返回值从其他函数中返回;

就像其他所有的对象(object)一样。这是闭包得以存在的最基本的条件,因为本质上来讲,闭包就是函数,一种特殊的函数。

Function Scope

先看一个很直观的例子:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 10
function inner () {
alert(a)
}
inner()
}

foo() // displays 10

因为函数在数据结构的层面上,与其他的对象无异,因此在函数内定义另一个函数是合法的。

在这个例子中,定义 inner 函数之后立刻执行它,然后再执行函数foo,猜猜浏览器会显示什么?没错,10。也就是说,在函数 inner 的作用域中,变量 a 是可见的,尽管它不是在 inner 中定义的。这是第一个问题:为什么 inner 函数可以访问到在它外部定义的变量 a

答案是函数作用域(Function scope)。在 JavaScript 中,作用域是函数级的,不像 C 类语言那样有着块级作用域。这意味着,函数内定义的变量在整个函数中都是可见的,如果函数内的某个语句块改变了这个变量的值,那么函数内部所有其他地方对这个变量的引用都会变为这个新值。(注意,在函数的内部函数中修改这个变量并不会影响到其外部作用域,这个后面会讲到。)

例如下面这段代码,在函数中,先定义了变量 x 的值为 1,然后在一个 for 循环语句块中重新定义 x,并让 x 的值累加,最后改变的是函数里的变量 x,而不是 for 语句块里的 x。事实上,for 循环里的 var 声明并没有新建一个变量 x,因为在一个作用域内,同一个变量只能被声明一次,js 将忽略后续的声明;但是同一个变量的多次赋值操作是有效的。

1
2
3
4
5
6
7
function foo() {
var x = 1
for(var x=0; x<10; x++){}
alert(x)
}

foo() // displays 10

如果是块级作用域的话,这段代码执行的结果将会显示 1。

还记得那个经典的在循环中执行异步或者延时操作的陷阱么?

1
2
3
4
5
6
7
8
9
10
function foo() {
for(var x=0; x<5; x++){
setTimeout(function(){
alert(x)
}, 1000)
}

}

foo()

执行这段代码,浏览器在 1 秒之后会连续显示 5 个 5。这是因为在循环完成之后,foo 函数中的 x 变量的值就变为 5 了,setTimeout 中的函数所引用的 x 就是这同一个 x,所以该函数执行时,x 就是 5。

注: ES6 新加入了 let 声明,此处不作讨论。

Lexical Scoping

函数作用域解释了函数内部定义的变量在函数内部的所有地方都是可见的,但是如果不执行在函数内部定义的函数,而是直接返回它,会发生什么呢?像下面这样:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var a = 10
function inner () {
alert(a)
}
//inner()
return inner
}

var bar = foo()
bar() // displays 10

结果还是一样。这是第二个问题:为什么外部函数执行完毕后,返回的内部函数还能访问变量 a

凭直觉来看,好像是返回并赋值给 barinner 函数在 foo 执行完毕之后,还保留着对变量 a 引用?某种程度上,可以这么理解。

当然真正的原因是词法作用域(Lexical scoping)。所谓词法作用域,就是内部作用域(内部函数)可以访问它的“父作用域”(即定义它的函数所在的作用域),依次类推,一直到全局作用域。这样就形成了一条作用域链(Scope chain)。如下图所示:

VariableEnvironment

图中红线所勾勒的是作用域,红圈内表示的是 VariableEnvironment ,即该作用域内的变量(在该作用域内定义的变量)的集合。蓝色箭头则形成了一个作用域链。

但是这还不足以解释,为什么在函数 f 中可以访问变量 x,在函数 g 中可以访问变量 yx。这是因为函数的一个不可见属性:[[Scope]]。

[[Scope]] Property

每一个 js 函数在创建的时候,都有一个不可见的内部属性:[[Scope]]。可以把 [[Scope]] 属性理解为一个有序列表,这个列表的元素是函数所有的父作用域内的 VariableEnvironment,而且元素的 index 越低,则与函数的“距离”越近。也就是说,这个列表的第一个元素永远是函数的直接父级作用域的 VariableEnvironment。

而函数的执行环境(Execution environment),也即函数在执行期间其作用域内所有的变量的集合(包括在其内部定义的和“继承”过来的),可以这样表示:

fun.Scope = VE + [[Scope]]

更确切地讲,是这样的:

fun.Scope = [VE].concat([[Scope]])

还拿上图这个例子来说,在函数 f 创建的时候,global.VE 的值是:

1
2
3
4
global.VE = {
x: 10,
f: <function refrence>
}

f 的 [[Scope]] 属性是:

1
f.[[Scope]] = [global.VE]

所以,f 执行环境为:

1
2
3
f.Scope = f.VE + f.[[Scope]]
// 即
f.Scope = [f.VE, f.[[Scope]]]

在函数 g 创建的时候,g 的 [[Scope]]为:

1
g.[[Scope]] = [f.VE, global.VE]

g 的执行环境为:

1
2
3
g.Scope = g.VE + g.[[Scope]]
// 即
g.Scope = [g.VE, f.VE, global.VE]

看到了么?函数 g 的执行环境中已经“保存”了函数 fglobal 中的变量了,这是在函数创建的时候就确定了的——所以 Lexical scoping 也称作 Static scoping——直到函数销毁。

这同时也解释了,为什么在外部函数执行结束之后,它返回出来的内部函数,还能访问在外部函数中定义的变量。因为外部函数中定义的变量,在内部函数创建的时候,就已经自动成为内部函数的变量了,成为这个内部函数“不可分割”的一部分了。不过要注意的一点是,由于函数作用域的缘故,内部函数针对外部函数中定义的变量的更改,只在内部函数本身的作用域内有效,不能影响到它外部函数的作用域。

Conclusion

总结来说,内部函数(inner function)暴露 给定义它的那个函数(enclosing function)以外的作用域,那么这个内部函数就构成了一个闭包。所谓暴露,就是执行完外部函数后:

  • 返回这个内部函数,把它值赋值给一个变量;
  • 把内部函数赋值给更外层作用域的某个对象上;
  • 返回包含有这个内部函数的对象;

Reference

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
http://jibbering.com/faq/notes/closures/#clClose
http://pierrespring.com/2010/05/11/function-scope-and-lexical-scoping/
https://javascriptweblog.wordpress.com/2010/10/25/understanding-javascript-closures/
http://dmitrysoshnikov.com/ecmascript/chapter-4-scope-chain/