我对JavaScript闭包的一些理解

面试老被问到闭包相关的问题, 之前也一直答的不是很好. 最近研究了一下闭包相关的一些内容, 以下是我个人的一些理解.


闭包指什么

Closures are functions that refer to independent (free) variables (variables that are used locally, but defined in an enclosing scope). In other words, these functions ‘remember’ the environment in which they were created.

这段话来自MDN - Closures(Last updated by: SphinxKnight, Sep 8, 2016, 1:06:52 AM), 简单的说就是: 当一个函数中使用了其他作用域中的变量, 它就是一个闭包. 闭包能够"记住"其创建时所在的环境.

下面还有一段解释:

A closure is a special kind of object that combines two things: a function, and the environment in which that function was created. The environment consists of any local variables that were in-scope at the time that the closure was created.

这里说闭包是含有一个函数以及创建时所处环境的对象.

可以看出, MDN上对闭包的指代并不明确, 并不知道是指代函数还是函数加创建环境所组成的对象.

《JavaScript高级程序设计》中对闭包的解释是:

闭包是指有权访问另一个函数作用域中的变量的函数

其后对闭包的详细描述也都是将函数视为闭包的.
结合网络上其他的一些解释, 我认为:闭包指的是一个函数.
当然, 这个函数需要能”记住”创建时所在环境, 具体细节后面会讨论.

如何产生闭包

这里使用《JavaScript高级程序设计》中的例子.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function createComparisonFunction(propertyName) {
return function(object1, object2) {
var value1 = object1[propertyName];
var value2 = object2[propertyName];

if(value1 < value2) {
return -1;
} else if(value1 > value2) {
return 1;
} else {
return 0;
}
}
}

var compareNames = createComparisonFunction("name");
var result = compareNames({name: "Nicholas"}, {name: "Greg"});

这段代码中, createComparisonFunction内的匿名函数能够访问执行createComparisonFunctioncreateComparisonFunction内的变量propertyName, 也就满足了访问其他作用域中变量这个条件, 因此这个匿名函数就是一个闭包.

大多讲解闭包的资料上都使用的类似的代码, 有些资料上在解释的时候认为返回内部的匿名函数也是一个特点, 造成需要返回内部函数才叫闭包的误解.

实际上, 目前JavaScript引擎还不会去检测一个函数是否真的会被使用到, 因此只要创建函数, 这个函数就会”记住”自己创建时的环境, 无论这个函数是否会返回或者被用到, 也就产生了(至少对JS引擎来说)闭包. 比如以下代码执行也会产生闭包.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function createComparisonFunction(propertyName) {
// 这个函数也是闭包, 即使没有被使用过
function compareNames(object1, object2) {
var value1 = object1[propertyName];
var value2 = object2[propertyName];

if(value1 < value2) {
return -1;
} else if(value1 > value2) {
return 1;
} else {
return 0;
}
}
// 可执行代码, 在debugger处查看compareNames的<function scope>
debugger;
}
createComparisonFunction("name");

代码中compareNames函数既没有被返回, 也没有被使用, 但在Chrome下, 我们都能观察到compareNames<function scope>中包含有propertyName, 也就”记住”了其创建时的环境, 成为了闭包. 这个闭包与第一份代码的闭包没有什么区别, 只是因为闭包没有被持续引用(createComparisonFunction执行结束以后, 其活动对象不再被引用, compareNames也随之不再被引用), 所以内存可以被回收.

闭包如何”记住”创建时的环境

从上面的描述中可以知道, 闭包在函数创建时就产生了, 那么他是通过什么方式来”记住”当前环境的呢? 答案就是作用域链.
先来看看什么是作用域链.
在函数对象创建时, 会在[[Scope]]属性中保存当前执行环境的作用域链, 全局执行环境的作用域链只有全局变量对象(执行环境和变量对象是不同的东西, 变量都是放在变量对象中的).
当函数在执行时, 会创建一个新的执行环境, 执行环境的作用域链将会指向一个作用域链对象. 这个作用域链对象从函数的[[Scope]]拷贝而来, 并且会在其前端加入一个新的活动对象, 用于存放参数等变量(也就是当前函数执行的作用域).
我们看下面的代码:

1
2
3
4
5
6
7
function foo() {
// 2
debugger;
}
// 1
debugger;
foo();

debugger-1

1处的debugger我们能够观察到当前执行环境(Window)的作用域链(图中Scope部分)中只有Global: Window. 同时, foo<function scope>与当前执行环境的作用域链相同.

debugger-2

当继续执行到2处, 这时当前执行环境为foo, 可以看到Scope下多了Local, 这里的Local就是foo的活动对象. 实际的作用域链如下(this其实指向了window, 这里并没有画出):

scope

有了作用域链对象, 函数执行时便可以从前往后访问作用域之外的变量了.
再看一个复杂一点的例子:

1
2
3
4
5
6
7
8
9
function foo() {
var name = "ST_Lighter";
function bar(prefix) {
return prefix + name;
}
return bar;
}
var o = foo();
o("My name is ");

动手画一画作用域链吧(我这里略去全局执行环境).

scope

在IE中通过debugger观察, 可以看到完整的作用域链, 以及链上对象所包含的变量. 然而在Chrome中, 结果却不完全相同.
使用下面的代码进行调试:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
var name = "ST_Lighter";
var blog = "http://stlighter.github.io";
function bar(prefix) {
debugger;
return prefix + name;
}
return bar;
}
var o = foo();
o("My name is ");

这是我在IE10中的结果, 可以看到完整的作用域链, 链上包含了foo的活动对象.

Scope in IE

而在Chrome中, 作用域链上并没有完整的foo活动对象, 没有被使用过的变量blog不见了.

Scope in Chrome

这是因为V8对闭包进行了优化. V8并不是将整个活动对象装进作用域链, 而是在函数执行时创建一个Context, 会被下面创建的函数所使用的变量将被放入Context(也还有其他方法让变量进入Context). 当创建函数时, 作用域链前端不是当前执行函数的活动对象, 而是Context. 更详细内容请参考Grokking V8 closures for fun (and profit?).
V8这样做是为了更有效的回收无用的对象, 释放内存空间.

再看闭包定义

我们现在已经了解了作用域链, 现在我们再看回头看闭包的定义.

闭包是指有权访问另一个函数作用域中的变量的函数

那么如果一个变量在作用域链上, 但是函数没有访问它, 这个函数算有权访问么(MDN上的用词表现出闭包需要直接访问作用域外的变量)? 再者, 全局作用域算作另一个函数作用域么? 那如果访问的是ES6中的块级作用域呢?

关于有权访问, 我们通过前面的测试就可以知道, 在IE下函数没必要一定要访问自己函数作用域外的变量, 函数的作用域链一定会包含完整的活动对象, 形成闭包. 而在Chrome下, 是否形成闭包则要看变量有没有被任何函数(没错, 是任何函数, 即使当前函数没有访问, 其他函数访问了, 这个变量依然会在当前函数的作用域链中出现, 形成闭包. 因为在同一作用域下创建的函数共享一个Context对象)访问. 也就是不同浏览器的闭包其实有差异.
关于另一个函数作用域, 我个人的看法是, 通常被称作闭包的函数, 作用域链中需要有一个非全局对象的活动对象. 也就是说, 作用域链上只有全局对象的函数不能称为闭包. 全局对象与其他活动对象细微的差异在于, 其他活动对象执行代码过程中创建的, 需要进行垃圾回收, 而全局对象在运行期间不需要进行回收. 当这些活动对象被作用域链引用, 就形成了闭包, 也在一定程度上造成了内存的消耗. 而ES6的块级作用域的确会实实在在的形成闭包, 所以如果要加上ES6的讨论的话这里另一个函数作用域是不准确的(嘛, 高程主要还是在ES5的前提下进行讨论的, 这样写也无可厚非).

最后, 我得出的结论是:

函数的作用域中含有全局对象之外的对象, 这个函数就是一个闭包.

闭包与内存回收

前面我们提到, 创建一个函数时, 这个函数的[[Scope]]下会保存创建时的作用域链, 由于这个作用域链会引用当前活动对象(或者一个Contex), 这就导致了这些对象的内存不能够被回收, 直到这个函数本身不再被引用.
A surprising JavaScript memory leak found at Meteor中展示了一种闭包导致的内存溢出.
简单的说, 只要对闭包使用setInterval都会导致其作用域链上的活动对象持续被引用, 无法被释放.

要确保内存能够被回收, 我们需要能够清楚的识别闭包, 并在不需要使用时取消对闭包的引用.


参考资料:
MDN Closures
JavaScript高级程序设计(第3版)
Grokking V8 closures for fun (and profit?)
A surprising JavaScript memory leak found at Meteor