很多分享都会解释什么是闭包,不得不说大都管中窥豹。比如某 star 1k 的项目中描述
所有函数都是闭包
是不恰当的。但是在有 1k stars 的情况下,居然没有 issue 指出其中的错误。
首先必须说,闭包就取名和中文翻译而言,在你正在理解了它之后,会发现它恰到好处。如果你还觉得命名很奇怪,那么就说明你并不理解它。
就如同它的名字描述的一般,闭包表示的是一个封闭的内存空间。每个函数被创建的时候,都有一个与之关联的闭包。在了解闭包的使用场景之前,先看下面一个例子:
function f() {
var i = 0;
console.log(i);
}
f();
这段代码非常简单。我们知道一旦 f
执行完毕,那么它本次执行的栈上的数据将会被释放,所以每次调用结束后,栈上的 i
都会被及时的释放。
再来看另一个例子:
function f() {
var i = 0;
return function () { // f1
console.log(i);
}
}
var ff = f();
ff();
和第一个例子一样,这段代码同样会打印 0
。但是这似乎打破了我们第一个例子的总结,按照第一个例子的说法,f
运行结束后,本次调用的栈上的 i
应该被释放掉了。但是我们随后调用返回的匿名函数,发现并没有报错,这就归功于闭包。
每个函数被创建的时候,都会有一个与之关联的闭包被同时创建。在新创建的函数内部,如果引用了外部作用域中的变量,那么这些变量都会被添加到该函数的闭包中。
注意上面代码的注释,为了方便描述,我们将匿名函数取名为 f1
。当 f
被调用的时候,f1
被创建,同时与之关联的闭包也被创建。由于 f1
内部引用了位于其作用域之外的、f
作用域中的变量 i
,因此 f
作用域中的 i
被拷贝到了 f1
的闭包中。这就解释了,为什么 f
执行完成之后,调用 f1
依然可以打印 0
。
现在来看一下第三个例子:
function f() {
var i = 0;
function f1() {
console.log(i);
}
i = 1;
return f1;
}
var ff = f();
ff();
我们会发现打印 1
。好像又与第二个例子的结论有些冲突,f
中的 i
不是被拷贝到了 f1
的闭包中吗?为什么不是打印 0
而是打印 1
呢?
这是因为,我们还没有介绍发生拷贝的时机。如果新创建的函数,引用了外部作用域的变量,并且该变量为活动的,那么并不急于将该变量的内容拷贝到闭包中,而是将该变量所指向的内存单元的地址保存于闭包中。比如我们这里,只是先将 i
所绑定到的内存地址保存于闭包中,等到 i
为非活动状态时,才会进行拷贝。也就是这里,当 f
即将运行结束时,i
的将变为非活动状态,那么需要将其内容拷贝到引用它的闭包中,也就是这里的 f1
的闭包中。一旦内容被拷贝到闭包中,除了与之关联的函数对象之外,再也没有其他方式可以访问到其中的内容。
顺便介绍一下,那么闭包中占用的内存何时才会被释放呢?答案就是当与它关联的函数对象被释放的时候。比如我们接着上面的例子运行:
var ff = null
我们将引用 f1
的变量 ff
赋值为 null
,这样就没有任何变量引用 f1
了,所以 f1
成为了垃圾,会在未来的某个时间点(具体要看 GC 的实现以及运行情况),由垃圾回收器进行所占内存回收。
上面的例子,其实就是下面的例子的简化版:
function f() {
var a = [];
for(var i = 0; i < 2; i++) {
var ff = function () {
console.log(i)
};
a.push(ff);
}
return a;
}
const [f1, f2] = f();
f1();
f2();
这里新创建的两个函数都会打印 2
,想必这个例子大家都很熟悉了,就不再赘述了。只是有一个问题需要注意,既然上面提到了说,新创建的函数引用的外部作用域上的变量内容、最终都会拷贝到该函数的闭包中,那么上面的例子中,i
是不是被拷贝了两次?
再来看一个例子:
function f() {
var a = [];
for(var i = 0; i < 2; i++) {
var ff = function () {
console.log(i)
};
a.push(ff);
}
a.push(function () {
i++;
});
return a;
}
const [f1, f2, f3] = f();
f1();
f3();
f2();
这个例子会打印什么?答案是 2
和 3
。这是因为闭包的另一个机制,同一个变量被引用它的多个闭包所共享。我们在 for
循环内部创建了两个函数,在循环外部创建了一个函数,这三个函数的都引用了 f
中的 i
,因而 i
被这三个函数的闭包所共享,也就是说在 i
离开自己所属的作用域时(f
退出前),将只会发生一次拷贝,并将新创建的三个函数的闭包中的 i
的对应的指针设定为那一份拷贝的内存地址即可。对于这一个共享的拷贝地址,除了这三个闭包之外,没有其他方式可以访问到它。
必须再次强调的是,被引用的变量拷贝到闭包中的时机发生在、被引用的变量离开自己所属的作用域时,即状态为非活动时。考虑下面的例子:
function f() {
const a = [];
for(let i = 0; i < 2; i++) {
var ff = function () {
console.log(i)
};
a.push(ff);
}
return a;
}
const [f1, f2] = f();
f1();
f2();
我们知道 ES6 中引入了 let
关键字,由它声明的变量所属块级作用域。在上面的例子中,我们在 for
循环体的初始化部分使用了 let
,这样一来 i
的作用域被设定为了该循环的块级作用域内。不过另一个细节是,循环体中的 i
,也就是 ff
中引用的 i
,在每次迭代中都会进行重新绑定,换句话说循环体中的 i
的作用域是每一次的迭代。因此在循环体中,当每次迭代的 i
离开作用域时,它的状态变为非活动的,因此它的内容被拷贝到引用它的闭包中。
闭包常常会和 IIFE 一起使用,比如:
var a = [];
for(var i = 0; i < 2; i++) {
a.push((function (i) { // f1, i1
return function () { // f2
console.log(i) // i2
}
})(i)); // i3
};
const [f1, f2] = a;
f1();
f2();
在上面的例子中,让人迷惑的除了闭包的部分之外,就是 i1
,i2
和 i3
了。
i1
是f1
的形参i2
是f2
中对外层作用域中的变量的引用i3
是全局的变量i
,IIFE 执行时i
对应的值将被作为实参来调用f1
- 当
f1
被调用时,也就是 IIFE 执行阶段,它内部创建了一个新的函数f2
,同时也创建了f2
对应的闭包 - 由于
f2
中引用了外层作用域中的i
,即f1
执行期间的i
,且i
为活动内容,所以f2
的闭包中添加一条 Key 为i
,Value 为指向f1
中活动的i
绑定到的内存单元的地址 - 当 IIFE 执行完毕,即
f1
要退出的时候,其栈上活动对象i
就会离开作用域,因此需要将i
拷贝到引用它的闭包中。
到目前为止,我们看到的例子都引用的直接外层作用域中的变量,那么我们再来看一个例子:
function f(x) { // f1
return function (y) { // f2
return function (z) { // f3
console.log(x + y + z)
}
}
}
const xy = f(1);
const xyz = xy(2);
xyz(3);
为了方便描述,我们分别标记了 f1
,f2
,f3
。我们在 f3
内部,引用了 x
和 y
,并且 x
并不是 f3
的直接外部作用域。那么这个闭包的构建过程时怎样的?
在 JS 中,函数也是以对象的形式存在的,如果将与函数关联的闭包想象成函数对象的一个类型为 Map<string, Value> 的属性也不过份,比如:
const CLOSURE = Symbol('closure');
const FUN_BODY = Symbol('fun-body');
const FUN_PARAMS = Symbol('fun-params');
const funObj = {
[FUN_PARAMS]: [/* parameters list */],
[FUN_BODY]: [/* instructions */],
[CLOSURE]: new Map<string, Value>(), // Value 可以被多个 closure 共享
}
即使在引擎的实现阶段,因为性能或者实现差异不采用这样的设计,但本质上与这个结构含义是一致的。为了能在运行阶段创建函数对象,在编译阶段就需要收集到必要的信息:
- 形参列表
- 函数体
- 引用的外部变量
比如在编译 f3
的阶段,我们发现它内部引用了外部的 x
和 y
,由于 x
不是直接存在于父及作用域 f2
中的,为了使得未来使用 f2
创建 f3
的时候,仍能够找到 x
的绑定,我们需要将 x
加入到 f2
的闭包中。所以在编译阶段,我们会在 f2
的信息中标注它内部引用了外部变量 x
。这样在创建 f2
的时候,x
就会被拷贝到它的闭包中了,等到使用它再创建 f3
的时候,f3
中的 x
也就有了着落。
最后来一个拓展练习:
function f(x) {
return [
function () { x++ },
function (y) {
return function (z) {
console.log(x + y + z)
}
}
]
}
const [f1, xy] = f(1);
const xyz = xy(2);
f1();
xyz(3);
如果想要了解跟多引擎层面实现闭包的细节,可以参考我的另外的项目,Naive - 使用 Rust 编写的 JS 引擎