执行上下文、作用域、作用域链和闭包


执行上下文、作用域、作用域链、闭包这四个概念联系非常紧密,所以放到同一篇博客中记录。现在网上的很多文章和视频都很难把这几个概念讲清楚,我看了之后,结合自己的理解记录这篇博客。如有错误,欢迎指正。


一.执行上下文

1.概念

执行上下文(execution context):当前 JavaScript 代码被解析执行时存在的环境。它是为了方便理解提出来的一种抽象概念,并非真实存在。


2.类型

在 ES6 版本下,有四种情况会创建执行上下文:

  • 进入全局代码(全局执行上下文)
  • 进入 function 函数体代码(函数执行上下文)
  • 进入 eval 函数参数指定的代码(eval 执行上下文)
  • 进入 module 代码(module 执行上下文)

我们重点关注前两种执行上下文:全局执行上下文函数执行上下文


3.执行上下文栈

全局执行上下文只会存在一个,而函数执行上下文可以存在无数个。每当函数被调用,就会创建一个新的函数执行上下文。即使是同一个函数被多次调用,也会创建多个执行上下文。

那么该如何管理这些上下文呢?这就有了执行上下文栈的概念,也叫执行栈调用栈(如果了解 js 的事件循环机制,那对于调用栈并不陌生,我个人理解调用栈和执行上下文栈本质是同一个东西,只不过在事件循环机制中,我们更关注于代码的执行,也就是下文将提到的创建执行上下文的执行阶段)。

执行栈具有LIFO(Last In First Out)的特点。当 JS 代码首次运行,都会创建一个全局执行上下文压入执行栈,之后每当有函数被调用,都将创建一个新的函数执行上下文压入栈中,当最顶层函数执行完毕,一步步出栈。

1
2
3
4
5
6
7
8
9
10
11
12
function f1() {
f2();
console.log(1);
}
function f2() {
f3();
console.log(2);
}
function f3() {
console.log(3);
}
f1(); //3 2 1

那么执行上下文究竟做了哪些事情呢?接下来我们重点讲一下。


4.执行上下文的生命周期

执行上下文的生命周期分为三个阶段:创建阶段执行阶段销毁阶段

由于标准的不断更新,对于执行上下文的内容也有了很大出入,首先将以 ES3 标准介绍执行上下文,比较容易理解,之后再讲解 ES5 标准。


(1) 创建阶段

执行上下文的创建阶段被称为预处理预编译,指在代码运行前js 引擎所作的处理,ES3 标准中执行上下文的创建阶段主要有三个部分:

  • 创建变量对象/活动对象
  • 创建作用域链
  • this 绑定

1)创建变量对象

每个执行上下文都有一个表示变量的对象——变量对象(variable object,简称VO),刚刚说了,执行上下文又分为全局执行上下文和函数执行上下文,它们的变量对象是有区别的。

全局执行上下文的变量对象叫做全局对象(global object,简称GO),在浏览器环境 JS 引擎会整合<script>标签中的内容,产生window对象,这个 window 对象就是全局对象。在<script>标签中声明的变量称为全局变量全局变量会作为 window 对象的属性存在

1
2
3
4
5
6
7
8
9
10
<script>
var a = 100;
console.log(a); // 100
console.log(window.a); // 100
console.log(window);
</script>
<script>
console.log(a); // 100
console.log(window.a); // 100
</script>

<script>标签中的声明的函数为全局函数全局函数会作为 window 对象的方法存在

1
2
3
4
5
6
<script>
function a() {
console.log(1);
}
console.log(window);
</script>

当函数被调用时,会创建函数执行上下文,函数执行上下文的变量对象叫做活动对象(activation object,简称AO)。在函数内部声明的变量称为局部变量局部变量会作为 AO 的属性存在。在函数内部声明的函数叫局部函数局部函数会作为 AO 的方法存在

1
2
3
4
5
6
7
8
9
// 通过控制台断点查看
function a() {
var i = 0;
function b() {
console.log(1);
}
b();
}
a();

其实,变量对象、全局对象、活动对象是一个东西,只不过是不同状态和阶段而已。全局执行上下文中的变量对象叫全局对象,函数执行上下文中的变量对象叫活动对象。

接下来,我们来看一段经典的代码:

1
2
console.log(a); // undefined
var a;

为什么输出打印的结果是 undefined,接下去我们就来详细讲讲。

首先,在全局执行上下文的创建阶段中(严格来讲,是创建阶段的变量对象部分),JS 引擎会做这些事情:

  1. 生成 window 对象,也就是 GO 对象
  2. 查找(全局)变量声明,作为 GO 对象的属性名,值为 undefined
  3. 查找(全局)函数声明,作为 GO 对象的方法名,值为 function

变量声明:通过var关键字声明变量

1
2
var a; // 变量声明
var a = 100; // 变量声明+变量赋值

函数声明:通过function关键字声明函数

1
2
function a() {} // 函数声明
var a = function () {}; // 注意:这种情况并不是函数声明,而是函数表达式。相当于变量声明,只不过这个变量在执行代码时被赋予一个函数

注意:

  1. 当查找函数声明并初始化时,创建了一个与之对应的函数对象,并被它引用
  2. 如果变量和函数同名,函数声明会覆盖掉变量声明,也就是说结果为 function

对于注意点 2,有些人的说法是 var 的优先级高,因为 JS 引擎会优先找 var 声明,另一些人的说法是 function 优先级高,因为如果出现同名,JS 引擎会选择 function。其实这两种说法只是理解角度不同而已,最终结果都是 function。下方的示例 2 就是演示这一点。

示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = 100;
function b() {
console.log(1);
}
/*
1.产生window对象
2.查找变量声明:a,作为GO的属性名,初始化为undefined
3.查找函数声明:b,作为GO的方法名,初始化为function(实际上是创建了一个函数对象,并让b引用)
GO:{
a:undefined,
b:function
}
*/

用图像来表示:

全局预编译

黄色虚线表示逻辑关系,红色实线表示真实的内存地址引用,下文同

实例 2:

1
2
3
4
5
console.log(a); // function
var a = 1;
function a() {
console.log(1);
}

到此为止,全局执行上下文的创建阶段结束。

严格来讲,是全局执行上下文创建阶段中的变量对象部分结束,但是在这一章节的学习中,我们会发现创建阶段的变量对象这一部分对我们理解来说是最重要的,所以这一章节中,变量对象初始化完毕后,我们就认为创建阶段结束,作用域链和 this 绑定我们暂不讨论。

接着讲解下函数执行上下文的创建阶段(严格来讲,是创建阶段的变量对象部分),JS 引擎会做这些事情:

  1. 在函数被调用时,为当前函数产生 AO 对象
  2. 查找(局部)变量声明和形参,作为 AO 对象的属性名,值为 undefined
  3. 使用实参的值改变形参的值
  4. 查找(局部)函数声明,作为 AO 对象的方法名,值为 function

示例:

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
26
27
28
29
30
31
32
function a(x) {
var i = 0;
function b() {
console.log(1);
}
b();
}
a(1);
/*
创建全局执行上下文,并压入栈
1.产生window对象
2.查找变量声明:无
3.查找函数声明:a,作为GO的方法名,初始化为function
GO:{
a:function
}
到此为止全局执行上下文的创建阶段结束

开始执行代码(这部分是执行上下文的执行阶段,但由于函数执行上下文只有在被调用时才会创建,所以需要执行,我们将在下文再接着讲执行阶段)
运行到a(1)

创建函数执行上下文,并压入栈
1.产生函数AO对象
2.查找函数内变量声明和形参:x、i,作为AO的属性名,初始化为undefined
3.将实参1赋值给形参x,x:1
4.查找函数内函数声明:b,作为AO的方法名,初始化为function
AO:{
x:1,
i:undefined,
b:function
}
*/

同样用图像来表示:

函数预编译

通过浏览器的调试工具查看:

函数预编译-调试工具

到此为止,函数执行上下文的创建阶段结束。

了解完这部分后,我们会发现之前所谓的变量提升函数提升就是指查找变量声明和函数声明。


2)创建作用域链

作用域和作用域链我将在下一章节重点讲,这里就先略过。


3)绑定 this

如果是全局执行上下文,this 其实就是全局对象,即 window 对象,如果是函数执行上下文,那就要确认当前可执行代码块的调用者, 并将调用者信息this value存入当前执行上下文,否则默认为全局对象调用。有关 this 的问题,可另行查看,本文不过多介绍,并且不再提及。


综上,在执行上下文的创建阶段,变量对象是最主要的内容。


(2) 执行阶段

所谓执行阶段,反而比创建阶段更好理解,就是运行 JS 代码。

在执行阶段中,JS 代码会被逐条执行,如果有赋值语句,就对应地去赋值……

示例:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
function a(x) {
console.log(i); // undefined
var i = 0;
console.log(i); // 0
function b() {
console.log(1);
}
b();
}
a(1);

/*
创建全局执行上下文,并压入栈
1.产生window对象
2.查找变量声明:无
3.查找函数声明:a,初始化为function
GO:{
a:function
}
全局执行上下文创建阶段结束,开始执行代码
执行到a(1)

创建a函数执行上下文,并压入栈
1.产生AO对象
2.查找形参和变量声明:x、i,并初始化为undefined
3.将实参1赋值给形参x,x:1
4.查找函数声明:b,并初始化为function
AO:{
x:1,
i:undefined,
b:function
}
函数执行上下文创建阶段结束,开始执行代码
console.log(i)
此时还没有执行赋值语句,所以i还是undefined
继续执行 var i = 0
此时AO:{ x:1, i:0, b:function }
再继续执行 console.log(i)
输出的自然是0
跳过函数声明,继续执行b()

创建b函数执行上下文,并压入栈
1.产生新的AO对象
2.查找形参和变量声明:无
3.查找函数声明:无
AO:{}
函数执行上下文创建阶段结束,开始执行代码
console.log(1)
输出打印1
b函数执行完毕,接着就是执行上下文的销毁阶段
b函数执行上下文将从执行栈中弹出并销毁,对应的AO对象也被释放
然后继续执行a函数代码,a函数执行完毕,a函数执行上下文将从执行栈中弹出并销毁,对应的AO对象被释放
继续执行全局代码
注意:即使代码全部执行完毕,全局执行上下文也不会从执行栈中弹出,直到关闭该页面
*/

以上代码请结合画图理解


(3) 销毁阶段

当函数代码执行完后,当前执行上下文(局部环境)会被弹出执行上下文栈并销毁,对应的变量对象也被释放,控制权被重新交给执行栈上一层的执行上下文。

这是一般情况,但如果产生了闭包,会有所不同。闭包将在下面的篇章中介绍。


5.总结

至此,执行上下文的所有内容都介绍完毕

执行上下文-思维导图

练习:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 第一题
var foo = function () {
console.log("foo1");
};
foo();
var foo = function () {
console.log("foo2");
};
foo();

// 第二题
foo();
var foo = function foo() {
console.log("foo1");
};
function foo() {
console.log("foo2");
}
foo();

接下去我们讲解一下作用域与作用域链。


二.作用域与作用域链

1.作用域

域,顾名思义就是区域、范围的意思。那么作用域,就是指当前代码对变量的访问区域,或者说可访问变量的集合

作用域分为三类:

  • 全局作用域
  • 局部作用域(函数作用域)
  • 块级作用域

早期 ES 标准中并没有块级作用域的概念,我将在下文 ES5 标准中介绍块级作用域,这里我们只考虑全局作用域局部作用域。从代码编写的角度可以这样理解:

作用域

上面红色框表示的就是局部作用域,黄色框表示全局作用域。结合我们上面执行上下文中所学的内容,JS引擎对变量的访问实际上就是从GO或者AO对象上查找属性,那么也就是说:

  • 全局作用域本质上就是 GO 对象
  • 局部作用域本质上就是 AO 对象

2.作用域链

那么什么是作用域链呢?以下面的代码为例:

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
26
27
28
29
30
31
32
33
var i = 0;
function a() {
i++;
console.log(i);
}
a();
/*
我们还是按照执行上下文来分析这段代码
首先创建全局执行上下文,并压入执行栈
1.产生window对象
2.查找变量声明:i,并初始化为undefined
3.查找函数声明:a,并初始化为function
GO:{
i:undefined,
a:function
}
全局执行上下文创建完毕,开始执行代码
var i = 0
将O赋值给GO对象中的i,此时的GO:{ i:0, a:function }
继续执行 a()

创建a函数执行上下文,并压入执行栈
1.产生AO对象
2.查找形参和变量声明:无
3.查找函数声明:无
AO:{}
函数执行上下文创建完毕,开始执行代码
i++
这个时候,JS引擎会先从函数作用域(也就是AO对象)中上查找i,发现函数作用域中并没有,于是就向上级作用域查找,这里代码的上级作用域就是全局作用域(GO对象),发现i为0,那么执行i++。此时GO:{ i:1, a:function }
继续执行 console.log(i)
查找过程同上,输出i为1。函数执行完毕
...(销毁阶段省略)
*/

这样一个查找过程形成的链式结构,就叫作用域链

那么问题来了,函数 a 在执行中怎么确定上级作用域是谁呢?

其实,在函数声明的时候,创建的函数对象中包含了一个隐藏属性[[scope]],这个属性记录了当前的执行环境。上面的代码用图像表示:

  1. 首先创建全局执行上下文并压入栈;产生 GO 对象;查找变量声明:i,初始化 undefined;查找函数声明:a,初始化 function,即创建一个函数对象,并被 a 引用。这个时候函数对象中的[[scope]]属性就记录了当前的执行环境,它是一个数组,而当前正处于全局执行上下文,那么这个数组的第 1 位就是 GO 对象:a.[[scope]] = [GO]

    作用域链-1

    图上这么画其实不准确,数组也是一个对象,所以[[scope]]属性应该要引用这个对象的地址,而这个对象要引用 GO 对象,但为了避免线条过多造成的视觉影响,所以采用图上的画法。

    另外提醒一点,[[scope]]属性只有 JS 引擎能够访问,下文所有与[[scope]]相关的代码都是伪代码,比如:a.[[scope]]其实是不能运行的。

  2. 全局执行上下文的创建结束,开始执行代码 var a = 0

    作用域链-2

  3. 继续执行 a(),此时函数被调用,创建函数执行上下文,并压入执行栈;产生 a 函数的 AO 对象;没有形参、变量声明和函数声明,AO 对象为空对象

    作用域链-3

  4. 还记得执行上下文创建阶段中有一个创建作用域链吗?当函数执行上下文创建时,除了创建变量对象,JS 引擎还会根据函数的[[scope]]属性去创建作用域链,将当前产生的AO对象添加到作用域链的最前端,也就是说当前执行上下文的作用域链为[aAO,GO],即 JS 引擎访问变量的顺序就是aAO->GO

    注意:

    1. 作用域链也是个数组,但它和[[scope]]是不同的,后者是函数对象中的属性,表示函数创建时的执行环境;前者是执行上下文的一部分,表示当前执行上下文中 JS 引擎访问变量的查找过程。
    2. 作用域链 = AO + [[scope]],AO 会添加在作用域链的最前面:作用域链 = [AO].concat([[scope]])
    3. 全局执行上下文的作用域链就是[GO]

    作用域链-4

  5. 此时函数执行上下文创建完毕,开始执行代码 i++,顺着作用域链查找并执行赋值语句。然后继续执行 console.log(i),输出打印 1

    作用域链-5

  6. 函数执行完毕,a 函数执行上下文弹出并销毁,aAO 被释放

    作用域链-6

  7. 继续执行全局代码,直至页面关闭,全局执行上下文弹出并销毁。

我们可以通过下面这个例子加深理解:

1
2
3
4
5
6
7
8
9
10
11
var i = 0;
function a() {
function b() {
function c() {
console.log(i);
}
c();
}
b();
}
a();

当 c 函数执行上下文创建时的图应该是这个样子:

作用域链-7

b 函数是在 a 函数中声明的,b 函数创建时[[scope]]属性的赋值,其实和 a 函数执行上下文创建作用域链的步骤是一样的,都是复制 a 函数的[[scope]]属性中的对象,将 a 函数执行上下文产生的 AO 对象添加到数组的第一位,也就是说 b 函数的[[scope]]属性和 a 函数执行上下文的作用域链内容是一样的,但是表示的含义不同。

[[scope]]所谓的保存当前执行环境,其实就是保存了变量对象到其中。

再一次强调:函数创建时,就会保存当前的执行环境。所以有一句话叫:函数的作用域在函数创建的时候决定而不是在调用的时候决定

我个人认为,这句话的准确说法应该是:函数执行上下文的 作用域链,在函数创建的时候决定而不是调用的时候决定。(AO+[[scope]])

因为现在一直在讲作用域链,突然提及函数的作用域,感觉有点奇怪。我们刚刚说了函数的作用域本质上是 AO 对象,那这句话里的函数的作用域要是按照 AO 对象去理解的话显然说不通,它指的应该是函数的[[scope]]属性。

无论你怎么理解,总归都是一句话:当函数创建时,它体内就保存了当时的执行环境。

我们再用一个例子强化这个理解:

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
function foo() {
console.log(a);
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();

/*
结果:console.log打印输出2,而不是3
过程:
创建全局执行上下文,并压入栈
1.产生window对象
2.查找变量声明:a,初始化为undefined
3.查找函数声明:foo、bar,初始化为function
GO:{
a:undefined,
foo:function
bar:function
}
同时
foo.[[scope]] = [GO]
bar.[[scope]] = [GO]
创建作用域链:因为是全局执行上下文,所以就是[GO]

全局执行上下文创建完毕,开始执行代码
var a = 2
此时GO:{ a:2, foo:function, bar:function }
继续执行bar()

创建bar函数执行上下文,并压入栈
1.产生AO对象(简称bAO)
2.查找形参、变量声明:a,初始化为undefined
3.没有形参,所以不用将实参赋值给形参
4.查找函数声明:无
bAO:{
a:undefined
}
创建作用域链:[bAO,GO]

bar函数执行上下文创建完毕,开始执行代码
var a = 3
此时bAO:{ a:3 }
继续执行foo()
这里要注意,foo并不是在bar函数中声明的,bAO中没有foo属性,那么JS引擎就会顺着作用域链去GO中查找,找到foo并执行

创建foo函数执行上下文,并压入栈
1.产生AO对象(简称fAO)
2.查找形参、变量声明:无
3.查找函数声明:无
fAO:{}
创建作用域链:[fAO,GO]

foo函数执行上下文创建完毕,开始执行代码
console.log(a)
由于fAO中并没有a属性,所以沿着作用域链查找GO,发现GO中的a为2,所以输出打印2而不是3
...(销毁阶段省略)
*/

如果用图像表示:

  1. 创建全局执行上下文并压入栈;产生 GO 对象;查找变量声明:a,并初始化为 undefined;查找函数声明:foo、bar,并初始化为 function。foo、bar 函数的[[scope]]值为[GO],另外全局执行上下文的作用域链就是[GO]

    作用域链-8

  2. 全局执行上下文创建完毕,开始执行代码 var a = 2

    作用域链-9

  3. 继续执行 bar()

  4. 函数被调用,创建 bar 函数执行上下文;产生 AO 对象(图中的 bAO);有一个变量声明 a,初始化为 undefined,没有形参,也没有函数声明

    作用域链-10

  5. 接着创建 bar 函数执行上下文的作用域链,bar 函数的[[scope]]为[GO],所以 bar 函数执行上下文的作用域链为[bAO, GO]

    作用域链-11

  6. bar 函数执行上下文创建完毕,开始执行代码 var a = 3

    作用域链-12

  7. 继续执行 foo()。因为 bAO 中并没有 foo 属性,JS 引擎将顺着作用域链查找,在 GO 中查找到 foo 并调用

  8. 创建 foo 函数执行上下文;产生 AO 对象(图中的 fAO);没有形参、变量声明、函数声明

    作用域链-13

  9. 接着创建 foo 函数执行上下文的作用域链,foo 函数的[[scope]]为[GO],所以 foo 函数执行上下文的作用域链为[fAO, GO]

    作用域链-14

  10. foo 函数执行上下文创建完毕,开始执行代码 console.log(a)。由于 fAO 中并没有 a 属性,所以沿着作用域链查找,找到 GO 中有 a 属性,输出打印 2

  11. …(销毁阶段省略)

从上面的图可以看出来,JS 引擎并非按照执行上下文的调用顺序形成作用域链(如果是按照调用顺序决定作用域链,那 foo 函数执行上下文的作用域链应该是 fAO->bAO->GO,显然并不是),而是根据函数创建时的执行环境形成作用域链。最直观的,我们可以直接通过函数的书写位置来判断作用域链。因此 JS 的作用域被称为词法作用域

词法作用域


3.总结

作用域和作用域链的部分讲完了。这部分比较难理解,概念比较多,需要花时间消化。

另外,不同人对于相同的词可能有不同的理解,这就导致很多博客和教学视频对于执行上下文、作用域和作用域链等概念解释得并不相同。上文仅代表我个人理解,对你或许会有帮助。

练习:

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
26
// 第一题
var foo = 1;
function bar() {
console.log(foo);
var foo = 10;
console.log(foo);
}
bar();

// 第二题
var foo = 1;
function bar() {
console.log(foo);
foo = 2;
}
bar();
console.log(foo);

// 第三题
var foo = 1;
function bar(foo) {
console.log(foo);
foo = 234;
}
bar(123);
console.log(foo);

三.闭包

1.概念

这部分应该是 JS 当中最难理解的一部分,但有了之前学到的基础,这部分理解起来就会容易一些。

还是通过一个例子引出:

1
2
3
4
5
function test() {
var i = 0;
console.log(i);
}
test();

如果用图像来表示:

  1. …(全局执行上下文的创建阶段和执行阶段省略)

  2. …(test 函数执行上下文的创建阶段和执行阶段省略)

    闭包-1

  3. 当函数执行完时,test 函数执行上下文从栈中弹出并销毁,对应的 AO 对象会被释放(图中蓝色框部分),那么我们将不再能够访问到变量 i

    闭包-2

那么我们想一想,有没有办法将这个 AO 对象保存下来呢?

答案肯定是有的。如果我们在函数内部再去声明一个函数,那么这个函数对象的[[scope]]是不是就保留了当前的执行环境,也就是说它的体内存在对当前 AO 对象的引用

1
2
3
4
5
6
7
function test() {
var i = 0;
function inner() {
console.log(i);
}
}
test();

闭包-3

但是这样还不够,当 test 函数执行上下文弹出并销毁时,图中的蓝色框部分一样会被释放掉,因为没有存在外部引用,这一部分不可被访问到

闭包-4

关于垃圾回收机制,可以参考这篇博客:

https://segmentfault.com/a/1190000018605776

解决办法就是在全局环境下声明一个变量,并将 test 函数内的 inner 函数返回给它,那么就可以通过全局变量去访问内部函数,进而可以访问到 AO 对象。这种情况下,AO 对象不会被释放。以下面的代码为例:

1
2
3
4
5
6
7
8
9
function test() {
var i = 0;
function inner() {
console.log(i);
}
return inner;
}
var a = test();
a(); // 输出打印0
  1. 创建全局执行上下文,并压入栈;产生 GO 对象;查找变量声明:a,初始化为 undefined;查找函数声明:test,初始化为 funticon,test.[[scope]] = [GO];创建作用域链:[GO]

    闭包-5

  2. 全局执行上下文创建完毕,开始执行代码 var a = test()

  3. test 函数被调用,创建 test 函数执行上下文,并压入栈;产生 AO 对象:tAO;查找形参:无;查找变量声明:i,初始化为 undefined;查找函数声明:inner,初始化为 function,inner.[[scope]] = [tAO,GO];创建作用域链:[tAO,GO]

    闭包-6

  4. test 函数执行上下文创建完毕,开始执行代码 var i = 0,return inner

    闭包-7

  5. test 函数执行完毕,函数上下文从执行栈中弹出并销毁,注意全局执行上下文中 var a = test()还未执行完,需要将 test 函数的返回值赋值给变量 a,a 保存对 inner 函数对象的引用

    闭包-8

    这时候由于 tAO 仍能被访问,所以它不会被释放

  6. 继续执行全局代码 a(),也就是调用 inner 函数

  7. 创建 inner 函数执行上下文,并压入栈;产生 AO 对象:iAO;查找形参:无;查找变量声明:无;查找函数声明:无;创建作用域链:因为 inner 函数[[scope]]为[tAO,GO],所以作用域链为[iAO,tAO,GO]

    闭包-9

  8. inner 函数执行上下文创建完毕,开始执行代码 console.log(i)。JS 引擎会顺着作用域链查找变量,在 tAO 中找到,输出打印 0

  9. inner 函数执行完毕,从执行栈中弹出并销毁,对应的 iAO 被释放

    闭包-10

这就是闭包。百度百科上的解释是:闭包就是能够读取其他函数内部变量的函数。按照这样的解释,我们上面代码中的 inner 函数就是闭包。由于在 javascript 中,只有函数内部的子函数才能读取局部变量,所以说,闭包可以简单理解成“定义在一个函数内部的函数”。所以在本质上,闭包是将函数内部和函数外部连接起来的桥梁


2.作用

闭包最大的作用有两个:

  1. 可以让外部环境读取到函数内部的变量

    刚刚的例子中,我们在全局执行上下文中,可以读取到 test 函数内的变量 i。

  2. 可以让这些变量的值始终保存在内存中,延长它们的生命周期

    刚刚的例子中,变量 i 始终没有被释放。


3.问题

凡事都有两面性,闭包带来好处的同时,必然会带来一些问题。其实我们刚刚讲到的闭包作用之一:延迟局部变量的生命周期。这就容易导致内存泄漏,将不必要的局部变量保存在内存当中。

解决方案:在不使用局部变量后将其释放。如果是刚刚的例子,可以在代码最后加上a = null释放掉。


4.总结

在这一章节中,我们介绍了闭包的概念、作用和带来的问题。我们总结一下闭包形成的条件:

  1. 函数嵌套

    上面的例子中,test 函数嵌套了 inner 函数,test 函数称为外部函数,inner 函数称为内部函数。为什么需要返回一个函数?因为函数是个对象,如果返回的仅仅是原始数据类型,变量 a 只会简单复制返回的值,而不是对一个对象的引用。

  2. 内部函数使用了外部函数的变量

    闭包的目的就是为了让外部环境读取到函数内部的变量,如果内部函数不使用外部函数的变量,那返回内部函数也没有意义。

  3. 内部函数需要被外部环境引用

    如果不被引用,内部函数将在外部函数运行完被释放。

练习:

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
26
27
28
29
30
31
32
33
34
35
// 第一题
var a = 1;
function foo() {
var a = 2;
return function () {
console.log(a);
};
}
var bar = foo();
bar();

// 第二题
function foo() {
var a = 0;
return function () {
console.log(a++);
};
}
var bar = foo();
bar();
bar();
bar();

// 第三题
function a() {
function b() {
var i = 0;
return function () {
console.log(i++);
};
}
var c = b();
c();
}
a();

四.ES5 标准下的执行上下文

从 ES5 标准开始,执行上下文与 ES3 有了很大的变化,最大的改变就是去除了变量对象的概念,以词法环境组件变量环境组件替代。

在 ES5 中执行上下文创建阶段主要做三件事:

  • 创建词法环境组件
  • 创建变量环境组件
  • 绑定 this

接下来我们聊一聊这些新名词。


1.词法环境/变量环境

(1) 词法环境

词法环境(Lexical Environment),是一种持有标志符—变量的映射的结构(标志符就是指变量/函数名,变量是对实际对象或原始数据的引用)。看不懂没关系,我们先继续。

词法环境的内部有两个组件:

  • 环境记录器(Environment Record):是存储变量和函数声明的实际位置
  • 外部环境的引用(outer):意味着它可以访问其父级词法环境

如果用伪代码看起来是这样的:

1
2
3
4
LexicalEnvironment: {       // 词法环境
EnvironmentRecord: {}, // 环境记录器
outer: <null>, // 外部环境的引用
}

这么一看,词法环境中的环境记录器像不像我们 ES3 中的变量对象,因为它们的作用是一样的。如果用图像表示,大概就像这样:

ES5-词法环境

另外,我们会发现在 ES5 规范的执行上下文的创建阶段,没有了 ES3 时候的创建作用域链,这其实是将作用域链的概念转移到了 outer 中。


(2) 变量环境

变量环境(Variable Environment),它也是一个词法环境,所以它有着词法环境的所有特性,包括环境记录器和对外部环境的引用。

1
2
3
4
VariableEnvironment: {      // 变量环境
EnvironmentRecord: {}, // 环境记录器
outer: <null>, // 外部环境的引用
}

既然它就是一个词法环境,为什么要单独提出一个变量环境的概念呢?之所以在 ES5 的规范里要单独分出一个变量环境的概念是为 ES6 服务的:在 ES6 中词法环境用来存储let、const 变量声明,而变量环境用来存储var 变量声明函数声明。如果用图像表示,大概这样:

在 ES5 中,还没有 let、const 声明,以及待会要讲到的块级作用域,在 ES6 中它们才出现。但是这些概念已经提出来了,ES5 中执行上下文的很多内容其实是在为 ES6 服务。

ES5-变量环境


(3) 环境记录器

环境记录器(Environment Record),它是存储变量和函数声明的实际位置。

在全局环境和函数环境中,环境记录器又有所区别,它有两种类型:

  • 对象环境记录器(在全局环境中)
  • 声明式环境记录器(在函数环境中)

声明式环境记录器相比对象环境记录器,还包含一个传递给函数的arguments对象和传递给函数的参数的length


(4) 外部环境的引用

外部环境的引用(outer),它的作用是访问父级词法环境。

  • 在全局执行上下文中,该值为 null
  • 在函数执行上下文中,该值为全局对象,或者为父级词法环境(作用域)

2.执行上下文的生命周期

ES5 标准中执行上下文的执行阶段和销毁阶段与 ES3 基本无异,故重点还是关注创建阶段。

  • 全局执行上下文的创建阶段,JS 引擎会做这些事情:
  1. 创建词法环境,对外部环境的引用为null
    1. 查找顶级let、const 变量声明,存入词法环境的环境记录器,但不初始化
  2. 创建变量环境,对外部环境的引用为null
    1. 查找非函数内的 var 声明,存入变量环境的环境记录器,初始化为 undefined
    2. 查找顶级函数声明,存入变量环境的环境记录器,初始化为 function
    3. 查找代码块中的与上述几条非重名的函数声明,存入变量环境的环境记录器,初始化为 undefined
  3. 绑定 this(这点在本文中不介绍)
  • 函数执行上下文的创建阶段,JS 引擎会做这些事情:
  1. 创建词法环境,对外部环境的引用为父级词法环境
    1. 查找函数内非块中的 let、const 变量声明,存入词法环境的环境记录器,但不初始化
  2. 创建变量环境,对外部环境的引用为父级词法环境
    1. 查找函数内的 var 声明,存入变量环境的环境记录器,初始化为 undefined
    2. 查找形参,存入变量环境的环境记录器,并将实参赋值给形参
    3. 查找函数内非块中的函数声明,存入变量环境的环境记录器,初始化为 function
    4. 查找代码块中的与上述几条非重名的函数声明,存入变量环境的环境记录器,初始化为 undefined
  3. 绑定 this(这点在本文中不介绍)

注意:

  1. 函数执行上下文和全局执行上下文的最主要区别就是对形参的处理,其他都是一致的。

  2. let、const 声明的变量,是不初始化的。所以下面的代码会报错。之所以说 let、const 声明的变量没有变量提升,就是这个原因。

    1
    2
    console.log(a);
    let a;

    但是,如果两行代码如果换一下位置,是不会报错的。这是因为在执行上下文的执行阶段,当 JS 引擎运行到let a这行代码,发现 a 未初始化,就会初始化为 undefined。

    1
    2
    let a;
    console.log(a); // undefined
  3. 不允许同一作用域下出现和 let、const 声明的变量同名的变量或函数,否则会报错。

  4. 在执行上下文的创建阶段过程中,我非常强调顶级、非函数内、代码块、函数内非块中等词语,这是因为 let、const 声明的变量存在块级作用域的概念,而 var 声明的变量并没有,而对代码块中的函数声明和非代码块中的函数声明,JS 引擎又有不同的处理方式。这些内容我将在下一小节中讲解。


3.块级作用域

(1) 概念

从 ES6 开始,加入了块级作用域。从代码编写的角度来看,块级作用域可以这么理解:

ES5-块级作用域

蓝色框表示的就是块级作用域。用{}包裹起来的代码称之为代码块,每个代码块都有一个块级作用域。


(2) let、const 变量

我们知道 var 声明的变量是没有块级作用域的概念的,而 let、const 声明的变量存在块级作用域。为了更好理解 let 和 var 的不同,我们可以看下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
var a = "global a";
let b = "global b";
{
let b = "block b";
var c = "block c";
let d = "block d";
console.log(a);
console.log(b);
}
console.log(b);
console.log(c);
console.log(d);

为了方便理解和观看,我将不再细分环境记录器和外部环境的引用,直接将词法环境当做整体来使用。

ES5-块级作用域-1

同样用画图来表示上面的代码:

  1. 创建全局执行上下文,并压入栈。

  2. 创建词法环境,查找顶级const、let 变量声明。

    所谓顶级的 const、let 变量声明,指的是不在代码块中且不在函数中的 const、let 变量声明。

    找到变量 b,加入词法环境,但不初始化

    ES5-块级作用域-2

  3. 创建变量环境,查找非函数内的 var 变量声明。

    因为 var 变量没有块级作用域的概念,所以无论是否在代码块中声明,都需要找出来。

    找到变量 a、c,加入变量环境,初始化为 undefined。

    ES5-块级作用域-3

  4. 没有函数声明。全局执行上下文创建完毕,开始执行代码。

  5. 执行var a = "global a"let b = "global b"

    ES5-块级作用域-4

  6. 继续执行代码,这时遇到了代码块。

    注意:

    • 执行代码块是不创建执行上下文的!
    • JS 引擎会将代码块中声明的 let、const 变量和函数存储在一个单独的区域。这块特殊区域就可以理解成块级作用域
    • 当代码块中的代码执行完,如果不存在外部引用,这块区域将被释放
    • 块级作用域也是一个词法环境。

    ES5-块级作用域-5

  7. 查找代码块中的 let、const 变量声明。

    找到变量 b、d,加入到块级作用域,但不初始化。

    ES5-块级作用域-6

  8. 没有函数声明。继续执行代码。

  9. 执行完let b = "block b",执行var c = "block c"

    注意:

    • 在一个执行上下文中,JS 引擎的访问顺序是从词法环境到变量环境。
    • 当执行到代码块中的语句时,则从块级作用域到词法环境再到变量环境。
    • 如果存在代码块嵌套,则从最内部代码块生成的块级作用域到上一层代码块生成的块级作用域,一直到变量环境。
    • 当代码块执行完毕,当前执行上下文将不再访问该块级作用域。

    在变量环境中找到变量 c 并赋值。

    ES5-块级作用域-7

  10. 继续执行let d = "block d"

    ES5-块级作用域-8

  11. 然后开始输出打印,按照上面所说的访问顺序查找变量 a、b,输出打印global ablock b

  12. 代码块执行完毕,由于不存在外部引用,块级作用域被释放。

    ES5-块级作用域-9

  13. 继续执行console.log(b),输出打印global b。执行console.log(c),输出打印block c。执行console.log(d),由于找不到 d,所以这一步会报错。最终结果:

    ES5-块级作用域-10


(3) 块级作用域中的函数声明

JS 引擎在创建执行上下文时,对于代码块中的函数声明和非代码块中的函数声明有不同的处理方式:

  • 非代码块中的函数声明:

    将函数名添加到变量环境,并初始化为 function。

  • 代码块中的函数声明:

    如果当前执行上下文中的词法环境和变量环境中存在同名变量或函数,则不进行任何操作。否则,将函数名添加到变量环境,并初始化为undefined

我们用几个例子演示一下:

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
26
27
28
29
30
31
32
33
34
35
36
// 示例1
console.log(test); // 输出的是undefined
var test = 0;
if (true) {
function test() {
console.log(1);
}
}

// 示例2
console.log(test); // 报错,test未初始化
let test = 0;
if (true) {
function test() {
console.log(1);
}
}

// 示例3
console.log(test); // 输出的是代码块外面的函数
function test() {
console.log(2);
}
if (true) {
function test() {
console.log(1);
}
}

// 示例4
console.log(test); // 输出的是undefined
if (true) {
function test() {
console.log(1);
}
}

接着在执行上下文的执行阶段,当执行到代码块时,查找代码块中的函数声明,添加到块级作用域,并初始化为 function。当代码块执行完,查找变量环境中是否存在同名变量,如果存在,让它引用该函数对象

用画图的方式解释一下:

1
2
3
4
5
6
7
if (true) {
let i = 0;
function test() {
console.log(i);
}
}
test();
  1. 创建全局执行上下文并压入栈。

  2. 创建词法环境,查找顶级 let、const 声明的变量:无。

  3. 创建变量环境,查找非函数体内 var 声明的变量:无。查找函数声明:找到块中的 test 函数,由于处于代码块当中,所以将函数名添加到变量环境中,并初始化为 undefined。

    ES5-块级作用域-11

  4. 全局执行上下文创建完毕,开始执行代码。

  5. 执行到代码块,生成块作用域。查找代码块中的 let、const 声明:i,但不初始化;查找函数声明,找到 test 函数,初始化为 function。

    ES5-块级作用域-12

  6. 继续执行代码let i = 0

    ES5-块级作用域-13

  7. 代码块执行完毕,查找变量环境中是否存在 test 变量,存在则让其引用 test 函数对象。

    注意:由于函数创建时会保存当时的执行环境,所以 test 函数体内存在对 if 块作用域的引用,而变量环境中的 test 又引用了 test 函数对象,所以即使这个代码块执行完,if 块作用域也不会被释放。这就和我们上面讲闭包时一样。

    ES5-块级作用域-14

  8. 继续执行代码,调用 test 函数。

    注意:代码块执行完后,全局上下文就不能访问块级作用域了,所以这个 test 函数是变量环境中的 test 函数,而不是 if 块作用域中的 test 函数。

  9. 创建 test 函数执行上下文,并压入栈。

  10. …(test 函数执行上下文创建过程省略)

    ES5-块级作用域-15

  11. 开始执行代码console.log(i),在 if 块作用域中找到变量 i,输出打印0

  12. …(test 函数执行上下文销毁阶段省略)

有兴趣的可以同样用画图的方式,理解一下以下代码:

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
26
27
28
29
30
// 示例1
console.log(test); // 输出的是undefined
var test = 0;
if (true) {
function test() {
console.log(1);
}
}
console.log(test); // 输出的是function

// 示例2
let test = 0;
if (true) {
function test() {
console.log(1);
}
}
console.log(test); // 输出的是0

// 示例3
console.log(test); // 输出的是代码块外面的函数
function test() {
console.log(2);
}
if (true) {
function test() {
console.log(1);
}
}
console.log(test); // 输出的是代码块内部的函数

4.总结

我们会发现,ES5 标准的执行上下文与 ES3 标准并无太多区别,只是用词法环境/变量环境去替代了变量对象,多了一个针对 let、const 声明的变量的处理,以及块级作用域的处理。在 ES5 标准中,可以将作用域理解成词法环境。

随着 ES 标准的不断更新,执行上下文的内容也在发生变化,比如:在最新的 ES6 标准中,词法环境用于存储函数声明和 const、let 声明的变量,而变量环境只存储 var 声明的变量;而 ES9 中的执行上下文又与上文有所区别。感兴趣的可以自行了解。以上内容仅供理解,如有错误,欢迎指正。


五.Q&A

  1. 作用域和执行上下文有什么区别?

    答:我个人认为这两个是包含关系,执行上下文中包含了作用域,同时还有其他像 this 绑定等内容。

  2. 什么是作用域?

    答:根据不同的标准,对作用域的理解是不同的,ES3 的标准中,作用域可以理解为变量对象;而在 ES5 标准中,作用域可以理解为词法环境。

  3. 函数的作用域链是什么时候建立的?

    答:我认为,函数没有作用域链的概念,作用域链是执行上下文中的内容。而函数只有一个[[scope]]属性,它在函数创建的时候就保存了当时的执行环境。当函数被调用时,函数执行上下文会根据[[scope]]创建作用域链。这个问题中的作用域链,应该指的是函数[[scope]]属性,它是在函数创建的时候建立的。


参考视频:

ES3 标准:https://www.bilibili.com/video/BV1C54y1r7VS

ES5 标准:https://www.bilibili.com/video/BV1wD4y1D7Pp

参考文章:

https://www.cnblogs.com/echolun/p/11438363.html

https://juejin.cn/post/7043408377661095967

https://blog.csdn.net/feral_coder/article/details/106447013

https://www.zhihu.com/question/36751764

http://www.ruanyifeng.com/blog/2009/08/learning_javascript_closures.html

https://segmentfault.com/a/1190000018605776