谈谈你对闭包的理解

变量的作用域

JavaScript 中,变量的作用域有两种:全局作用域和局部作用域

JavaScript 语言特殊之处在于,函数内部可以直接读取全局变量

1
2
3
4
5
6
7
var n = 999;

function f1() {
alert(n);
}

f1(); // 999

另一方面,在函数外部自然无法读取函数内部局部变量

1
2
3
4
5
6
function f1() {
// 注意此处必须使用var声明符,否则就是全局声明
var n = 999;
}

alert(n); // error

如何从函数外部读取函数内局部变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function f1() {
var n = 999;

// 1. JavaScript 语言允许在函数内部定义新的函数
function f2() {
alert(n); // 2. 可以在内部函数中访问父函数中定义的变量
}

return f2; // 3. 函数是一等公民,所以函数可以作为返回值
}

var result = f1();

result(); // 999 如此便实现了在函数外部访问函数内部局部变量

闭包是什么

上述代码中的 f2 函数就是一个闭包, 闭包就是能够读取其他函数内部变量函数

本质上,闭包就是将函数内部和函数外部连接起来的一座桥梁

闭包的作用

  • 读取函数内部变量

  • 让函数内部被闭包引用的变量始终保持在内存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function f1() {
var n = 999;

// 全局变量+匿名函数,同时也是一个闭包
// 通过此函数,不仅可以访问函数内部变量,还可以修改函数内部变量
add = function () {
n += 1;
};

function f2() {
alert(n);
}

return f2;
}

var result = f1();

result(); // 999

add();

result(); // 1000

在上述代码中,result 实际上就是一个闭包函数 f2。第一次运行的值是 999,第二次运行的值是 1000。这说明了,函数 f1 中的局部变量 n 一直保留在内存中,并没有在函数 f1 调用后被自动清除

由于闭包会使得函数内变量被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页性能问题

闭包会在父函数外部,改变父函数内部变量的值,所以,如果你把父函数当作对象(object)使用,把闭包当作它的公用方法(public method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 使用闭包来定义公共函数,并令其可以访问私有函数和变量
var Counter = (function () {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function () {
changeBy(1);
},
decrement: function () {
changeBy(-1);
},
value: function () {
return privateCounter;
},
};
})();

console.log(Counter.value()); /* logs 0 */
Counter.increment();
Counter.increment();
console.log(Counter.value()); /* logs 2 */
Counter.decrement();
console.log(Counter.value()); /* logs 1 */

以这种方式使用闭包,提供了许多与面向对象编程相关的好处 —— 特别是数据隐藏和封装

1
2
3
4
5
6
7
8
9
10
function add(x) {
return function (y) {
return x + y;
};
}
let addFun1 = add(4);
let addFun2 = add(9);

console.log(addFun1(2)); // 6
console.log(addFun2(2)); // 11

函数柯里化使用了闭包

1
2
3
4
5
6
7
8
9
10
11
12
13
//原生的setTimeout传递的第一个函数不能带参数
setTimeout(function (params) {
alert(params);
}, 1000);

//通过闭包可以实现传参效果
function func(params) {
return function () {
alert(params);
};
}
let f1 = func(1);
setTimeout(f1, 1000);

setTimeout 传参使用了闭包

debounce/throttle 都是用到了闭包

通过闭包创建一个立即执行函数,可以用来做模块化,比如 jQuery, 再比如 Webpack 打包后的 bundle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(function (global, factory) {
"use strict";

if (typeof module === "object" && typeof module.exports === "object") {
module.exports = global.document
? factory(global, true)
: function (w) {
if (!w.document) {
throw new Error("jQuery requires a window with a document");
}
return factory(w);
};
} else {
factory(global);
}

// Pass this if window is not defined yet
})(typeof window !== "undefined" ? window : this, function (window, noGlobal) {
"use strict";

// @CODE
// build.js inserts compiled jQuery here

return jQuery;
});
1
2
3
4
5
6
7
8
9
10
11
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function () {
return this.name;
};

this.getMessage = function () {
return this.message;
};
}

上述代码创建了一个闭包,但是完全是没有必要的闭包,可以使用原型替代

闭包面试题

代码片段 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var name = "The Window";

var obj = {
name: "My Object",

getNameFunc: function () {
// this指向obj
return function () {
// this指向window
return this.name;
};
},
};

alert(obj.getNameFunc()()); // "The Window"

代码片段 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var name = "The Window";

var obj = {
name: "My Object",

getNameFunc: function () {
var that = this;
return function () {
// that是上一层函数的this,相当于把obj传递下来了
return that.name;
};
},
};

alert(obj.getNameFunc()()); // "My Object"

代码片段 3

1
2
3
4
5
6
7
8
9
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0](); // 3
data[1](); // 3
data[2](); // 3

代码片段 4

1
2
3
4
5
6
7
8
9
10
11
12
13
var data = [];
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(
(data[j] = function () {
console.log(j);
}),
0
);
})(i); // 立即调用函数
}

// 不用调用就会依次打印0,1, 2

代码片段 5

1
2
3
4
5
6
7
8
9
var data = [];
for (let i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0](); // 0
data[1](); // 1
data[2](); // 2

V8 如何处理闭包

惰性解析

V8 执行 JavaScript 代码,需要经过编译和执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段

在编译 JavaScript 代码的过程中,V8 并不会一次性将所有的 JavaScript 解析为中间代码,这主要是基于以下两点:首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码都有 10 多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。

基于以上的原因,所有主流的 JavaScript 虚拟机都实现了惰性解析。所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

关于惰性解析,可以结合下面这个例子来分析下:

1
2
3
4
5
6
7
8
function foo(a, b) {
var d = 100;
var f = 10;
return d + f + a + b;
}
var a = 1;
var c = 4;
foo(1, 5);

当把这段代码交给 V8 处理时,V8 会至上而下解析这段代码,在解析过程中首先会遇到 foo 函数,由于这只是一个函数声明语句,V8 在这个阶段只需要将该函数转换为函数对象,如下图所示:

注意,这里只是将该函数声明转换为函数对象,但是并没有解析和编译函数内部的代码,所以也不会为 foo 函数的内部代码生成抽象语法树。

然后继续往下解析,由于后续的代码都是顶层代码,所以 V8 会为它们生成抽象语法树,最终生成的结果如下所示:

代码解析完成之后,V8 便会按照顺序自上而下执行代码,首先会先执行“a=1”和“c=4”这两个赋值表达式,接下来执行 foo 函数的调用,过程是从 foo 函数对象中取出函数代码,然后和编译顶层代码一样,V8 会先编译 foo 函数的代码,编译时同样需要先将其编译为抽象语法树和字节码,然后再解释执行。

好了,上面就是惰性解析的一个大致过程,看上去是不是很简单,不过在 V8 实现惰性解析的过程中,需要支持 JavaScript 中的闭包特性,这会使得 V8 的解析过程变得异常复杂。

为什么闭包会让 V8 解析代码的过程变得复杂呢?要解答这个问题,我们先来拆解闭包的特性,然后再来分析为什么闭包影响到了 V8 的解析流程。

拆解闭包——JavaScript 的三个特性

JavaScript 中的闭包有三个基础特性。

第一,JavaScript 语言允许在函数内部定义新的函数,代码如下所示:

1
2
3
4
function foo() {
function inner() {}
inner();
}

这和其他的流行语言有点差异,在其他的大部分语言中,函数只能声明在顶层代码中,而 JavaScript 中之所以可以在函数中声明另外一个函数,主要是因为 JavaScript 中的函数即对象,你可以在函数中声明一个变量,当然你也可以在函数中声明一个函数。

第二,可以在内部函数中访问父函数中定义的变量,代码如下所示:

1
2
3
4
5
6
7
8
9
10
var d = 20;
//inner函数的父函数,词法作用域
function foo() {
var d = 55;
//foo的内部函数
function inner() {
return d + 2;
}
inner();
}

由于可以在函数中定义新的函数,所以很自然的,内部的函数可以使用外部函数中定义的变量,注意上面代码中的 inner 函数和 foo 函数,inner 是在 foo 函数内部定义的,我们就称 inner 函数是 foo 函数的子函数,foo 函数是 inner 函数的父函数。这里的父子关系是针对词法作用域而言的,因为词法作用域在函数声明时就决定了,比如 inner 函数是在 foo 函数内部声明的,所以 inner 函数可以访问 foo 函数内部的变量,比如 inner 就可以访问 foo 函数中的变量 d。

但是如果在 foo 函数外部,也定义了一个变量 d,那么当 inner 函数访问该变量时,到底是该访问哪个变量呢?

我们知道,每个函数有自己的词法作用域,该函数中定义的变量都存在于该作用域中,然后 V8 会将这些作用域按照词法的位置,也就是代码位置关系,将这些作用域串成一个链,这就是词法作用域链,查找变量的时候会沿着词法作用域链的途径来查找。

所以,inner 函数在自己的作用域中没有查找到变量 d,就接着在 foo 函数的作用域中查找,再查找不到才会查找顶层作用域中的变量。所以 inner 函数中使用的变量 d 就是 foo 函数中的变量 d。

第三,因为函数是一等公民,所以函数可以作为返回值,我们可以看下面这段代码:

1
2
3
4
5
6
7
function foo() {
return function inner(a, b) {
const c = a + b;
return c;
};
}
const f = foo();

观察上面这段代码,我们将 inner 函数作为了 foo 函数的返回值,也就是说,当调用 foo 函数时,最终会返回 inner 函数给调用者,比如上面我们将 inner 函数返回给了全局变量 f,接下来就可以在外部像调用 inner 函数一样调用 f 了。

以上就是和 JavaScript 闭包相关的三个重要特性:

  • 可以在 JavaScript 函数内部定义新的函数;
  • 内部函数中访问父函数中定义的变量;
  • 因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值。

这也是 JavaScript 过于灵活的一个原因,比如在 C/C++ 中,你就不可以在一个函数中定义另外一个函数,所以也就没了内部函数访问外部函数中变量的问题了。

闭包给惰性解析带来的问题

上面我们了解了 JavaScript 的这三个特性,下面我们就来使用这三个特性组装的一段经典的闭包代码:

1
2
3
4
5
6
7
8
function foo() {
var d = 20;
return function inner(a, b) {
const c = a + b + d;
return c;
};
}
const f = foo();

观察上面上面这段代码,我们在 foo 函数中定义了 inner 函数,并返回 inner 函数,同时在 inner 函数中访问了 foo 函数中的变量 d。

  • 当调用 foo 函数时,foo 函数会将它的内部函数 inner 返回给全局变量 f;
  • 然后 foo 函数执行结束,执行上下文被 V8 销毁;
  • 虽然 foo 函数的执行上下文被销毁了,但是依然存活的 inner 函数引用了 foo 函数作用域中的变量 d。

按照通用的做法,d 已经被 v8 销毁了,但是由于存活的函数 inner 依然引用了 foo 函数中的变量 d,这样就会带来两个问题:

  • 当 foo 执行结束时,变量 d 该不该被销毁?如果不应该被销毁,那么应该采用什么策略?

  • 如果采用了惰性解析,那么当执行到 foo 函数时,V8 只会解析 foo 函数,并不会解析内部的 inner 函数,那么这时候 V8 就不知道 inner 函数中是否引用了 foo 函数的变量 d。

这么讲可能有点抽象,下面我们就来看一下上面这段代码的执行流程,我们上节分析过了,JavaScript 是一门基于堆和栈的语言,当执行 foo 函数的时候,堆栈的变化如下图所示:

从上图可以看出来,在执行全局代码时,V8 会将全局执行上下文压入到调用栈中,然后进入执行 foo 函数的调用过程,这时候 V8 会为 foo 函数创建执行上下文,执行上下文中包括了变量 d,然后将 foo 函数的执行上下文压入栈中,foo 函数执行结束之后,foo 函数执行上下文从栈中弹出,这时候 foo 执行上下文中的变量 d 也随之被销毁。

但是这时候,由于 inner 函数被保存到全局变量中了,所以 inner 函数依然存在,最关键的地方在于 inner 函数使用了 foo 函数中的变量 d,按照正常执行流程,变量 d 在 foo 函数执行结束之后就被销毁了。

所以正常的处理方式应该是 foo 函数的执行上下文虽然被销毁了,但是 inner 函数引用的 foo 函数中的变量却不能被销毁,那么 V8 就需要为这种情况做特殊处理,需要保证即便 foo 函数执行结束,但是 foo 函数中的 d 变量依然保持在内存中,不能随着 foo 函数的执行上下文被销毁掉。

那么怎么处理呢?

在执行 foo 函数的阶段,虽然采取了惰性解析,不会解析和执行 foo 函数中的 inner 函数,但是 V8 还是需要判断 inner 函数是否引用了 foo 函数中的变量,负责处理这个任务的模块叫做预解析器。

预解析器如何解决闭包所带来的问题?

V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个。

第一,是判断当前函数是不是存在一些语法上的错误,如下面这段代码:

1
2
3
4
5
6
7

function foo(a, b) {
{/} //语法错误
}
var a = 1
var c = 4
foo(1, 5)

在预解析过程中,预解析器发现了语法错误,那么就会向 V8 抛出语法错误,比如上面这段代码的语法错误是这样的:

1
Uncaught SyntaxError: Invalid regular expression: missing /

第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。

总结

所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。

利用惰性解析可以加速 JavaScript 代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。

由于 JavaScript 是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。

思考题

1
2
3
4
5
function foo() {
var a = 0;
}

// 执行foo函数时,函数foo的上下文和变量a会存放到栈上,随着函数执行完毕会被销毁
1
2
3
4
5
6
7
8
9
10
function foo() {
var a = 0;
return function inner() {
return a++;
};
}

// 调用 foo函数之后先解析,在解析过程中发现有内部函数,因此会发生预解析
// 发现内部函数会引用外部函数中的变量 a,因此将这个 a 写到内存堆上。
// 最终 foo 函数执行完毕之后,a 变量不会随之销毁

问题:当调用 foo 函数时,foo 函数内部的变量 a 会分别分配到栈上?还是堆上?