Excerpt

本节内容会先讲解 JavaScript 的执行过程,再来学习作用域、作用域链等核心概念。

当理解了这些内容后,就可以很好的理解闭包、闭包的内存泄漏等。


一. 全局代码的执行过程

1.1. ECMA 的版本说明

在 ECMA 早期的版本中(ECMAScript3),代码的执行流程的术语和 ECMAScript5 以及之后的术语会有所区别:

  • 目前网上大多数流行的说法都是基于 ECMAScript3 版本的解析,并且在面试时问到的大多数都是 ECMAScript3 的版本内容;

  • 但是 ECMAScript3 终将过去, ECMAScript5 必然会成为主流,所以最好也理解 ECMAScript5 甚至包括 ECMAScript6 以及更好版本的内容;

  • 事实上在 TC39( ECMAScript5 )的最新描述中,和 ECMAScript5 之后的版本又出现了一定的差异;

那么我们课程按照如下顺序学习:

  • 通过 ECMAScript3 中的概念学习 JavaScript 执行原理、作用域、作用域链、闭包等概念;

  • 通过 ECMAScript5 中的概念学习块级作用域、let、const 等概念;

事实上,它们只是在对某些概念上的描述不太一样,在整体思路上都是一致的。

1.2. 全局代码初始化

假如我们有下面一段代码,它在 JavaScript 中是如何被执行的呢?

1
2
3
4
5
6
7
8
9
10
var name = "why";

function foo() {
var name = "foo";
console.log(name);
}

var num1 = 20;
var num2 = 30;
var result = num1 + num2foo();

1.2.1. 初始化全局对象

js 引擎会在执行代码之前,会在堆内存中创建一个全局对象:

  • Global Object(GO) 该对象 所有的作用域(scope)都可以访问;

  • 里面会包含 Date、Array、String、Number、setTimeout、setInterval 等等;

  • 其中还有一个 window 属性指向自己;

全局对象

内存中创建一个全局对象

1.2.2. 执行上下文(EC)

在 JavaScript 引擎内部,有一个执行上下文栈(Execution Context Stack,简称 ECS),它是用于执行代码的调用栈,存储着当前正在执行的执行上下文。

当 JavaScript 引擎开始执行代码时,它会先创建一个全局执行上下文(Global Execution Context,简称 GEC),并将其推入 ECS 中,表示当前正在执行全局的代码块。

GEC 包含了两部分内容:

  • 第一部分是在代码执行前,JavaScript 引擎会扫描全局作用域下的变量和函数,将它们添加到全局对象(Global Object)中,并分配内存空间,但是并不会赋值。这个过程称为变量的作用域提升(hoisting)。

  • 第二部分是在代码执行中,JavaScript 引擎会对变量进行赋值,并执行其他的函数。

执行上下文

1.3. 全局代码的执行

1.3.1. VO 和 GO 的理解

每一个执行上下文会关联一个 VO(Variable Object,变量对象),变量和函数声明会被添加到这个 VO 对象中。

VO 的描述

变量对象是一个抽象的概念,在 JavaScript 引擎内部,它被实现为一个与当前执行上下文相关联的对象。

  • 在全局执行上下文中,变量对象是全局对象(Global Object);

  • 在函数执行上下文中,变量对象是一个称为活动对象(Activation Object)的对象。

当全局代码被执行的时候,VO 就是 GO 对象了

  • 当全局代码被执行时,变量对象(Variable Object,简称 VO)就是全局对象(Global Object,简称 GO)了。

  • 全局对象是在 JavaScript 引擎内部创建的一个唯一对象,它在全局作用域下被访问,并且可以存储全局变量、函数、内置对象和浏览器提供的 API 等等。

全局代码执行

1.3.2. 全局代码执行过程(执行前)

GO 对象在执行全局代码之前就已经被创建,并且包含了一些内置对象和方法,例如 Object、Array、String、Number、Math、JSON、setTimeout、setInterval 等等。

当全局执行上下文被创建时,变量对象 VO 会被设置为 GO 对象,并且全局作用域下的变量和函数会被添加到变量对象中。

全局代码执行前

1.3.3. 全局代码执行过程(执行后)

代码执行后就会给变量进行复制操作:

全局代码执行后

二. 函数代码的执行过程

2.1. 函数代码的执行过程

2.1.1. 函数执行的概念描述

在 JavaScript 中,当函数被调用时,会创建一个函数执行上下文(Functional Execution Context,简称 FEC),并将其压入执行上下文栈(Execution Context Stack,简称 ECS)中,表示当前正在执行该函数的代码块。

函数执行上下文关联的变量对象(Variable Object,简称 VO)是一个称为活动对象(Activation Object,简称 AO)的对象。

  • 在进入函数执行上下文时,JavaScript 引擎会创建一个 AO 对象,并将其关联到当前的执行上下文中。

  • AO 对象是一个包含函数的参数、函数声明和变量声明的列表。

  • 它的初始值使用函数的参数列表作为参数并进行初始化。

  • 这个 AO 对象会作为执行上下文的变量对象(VO)来存储变量的初始化。

当函数执行完毕后,其执行上下文会从 ECS 中弹出,AO 对象会被垃圾回收,所以不能在函数执行完毕后再访问该函数的 AO 对象。

2.1.2. 函数代码执行过程(执行前)

函数代码执行前

2.1.3. 函数代码执行过程(执行后)

函数代码执行后

2.2. 作用域、作用域链

2.2.1. 作用域、作用域链的理解

在 JavaScript 中,当进入一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)。

  • 作用域链是一个对象列表,用于变量标识符的查找和求值。

  • 当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;

作用域链是由当前执行上下文的变量对象(VO)和所有外部环境的作用域链组成的。

作用域链的描述

作用域链

具体内容可以查看视频中的讲解过程。

2.2.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
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
61
// 1.面试题一:
var n = 100;

function foo() { 
n = 200;
}

foo();
console.log(n);

// 2.面试题二:
var n = 100;

function foo() {  
console.log(n);
var n = 200;
console.log(n);
}

foo();

// 3.面试题三:
var n = 100;

function foo1() {  
console.log(n);
}

function foo2() {  
var n = 200;
console.log(n);
foo1();
}

foo2();

// 4.面试题四:
var n = 100;

function foo() {  
console.log(n);
return  var n = 200;
}

foo();

// 5.在开发中可能会出现这样错误的写法
function foo() {  
message = "Hello World";
}

foo();
console.log(message);

// 6.面试题五:
function foo() {  
var a = b = 100;
}

foo();
console.log(b);

2.3. 闭包和闭包的内存泄漏

2.3.1. 闭包的迷惑之处

闭包是 JavaScript 中一个非常容易让人迷惑的知识点:

  • 有同学在深入 JS 高级的交流群中发了这么一张图片(这张图来自你不知道的 JavaScript);

  • 并且闭包也是群里面大家讨论最多的一个话题;

你不知道的 JavaScript

闭包确实是 JavaScript 中一个很难理解的知识点,接下来我们就对其一步步来进行剖析,看看它到底有什么神奇之处。

2.3.2. 闭包的概念定义

这里先来看一下闭包的定义,分成两个:在计算机科学中和在 JavaScript 中。

在计算机科学中对闭包的定义(维基百科):

  • 闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures);

  • 是在支持 头等函数 的编程语言中,实现词法绑定的一种技术;

  • 闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表);

  • 闭包跟函数最大的区别在于,当捕捉闭包的时候,它的 自由变量 会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行;

闭包的概念出现于 60 年代,最早实现闭包的程序是 Scheme,那么我们就可以理解为什么 JavaScript 中有闭包:

  • 因为 JavaScript 中有大量的设计是来源于 Scheme 的;

我们再来看一下 MDN 对 JavaScript 闭包的解释:

  • 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure);

  • 也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域;

  • 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来;

那么我的理解和总结:

  • 一个普通的函数 function,如果它可以访问外层作用于的自由变量,那么这个函数就是一个闭包;

  • 从广义的角度来说:JavaScript 中的函数都是闭包;

  • 从狭义的角度来说:JavaScript 中一个函数,如果访问了外层作用于的变量,那么它是一个闭包;

2.3.3. 闭包的形成过程

当我们的代码没有闭包时,往往代码会有非常大的局限性:

  • 如果一个函数需要使用到外面作用域的变量,那么必须将所有的变量全部传入进去。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var name = "why";
var age = 18;
var height = 1.88;
var address = "广州市";
var intro = "了解真相, 你才能获得真正自由!";

function foo(name, age, height, address, intro, num1, num2) {
var message = "Hello World";
console.log(message, name, age, height, address, intro);
function bar() {
console.log(name);
}
bar();
}

foo(name, age, height, address, intro, 20, 30);

有了闭包之后,我们可以进行如下代码的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function createAdder(count) {
function adder(num) {
return count + num;
}

return adder;
}

var adder5 = createAdder(5);
adder5(100);
adder5(55);
adder5(12);

var adder8 = createAdder(8);
adder8(22);
adder8(35);
adder8(7);

console.log(adder5(24));
console.log(adder8(30));

2.3.4. 闭包的内存泄漏

使用闭包可能会导致内存泄漏:

  • 因为闭包会引用外部函数的变量对象,如果这个闭包被长期保存,那么外部函数的变量对象就会一直存在内存中,无法被垃圾回收。

  • 因此,在使用闭包时,需要注意内存的管理。

那么我们为什么经常会说闭包是有内存泄露的呢?

  • 在上面的案例中,如果后续我们不再使用 add10 函数了,那么该函数对象应该要被销毁掉,并且其引用着的父作用域 AO 也应该被销毁掉;

  • 但是目前因为在全局作用域下 add10 变量对 0xb00 的函数对象有引用,而 0xb00 的作用域中 AO(0x200)有引用,所以最终会造成这些内存都是无法被释放的;

  • 所以我们经常说的闭包会造成内存泄露,其实就是刚才的引用链中的所有对象都是无法释放的;

那么,怎么解决这个问题呢?

  • 因为当将 add8 设置为 null 时,就不再对函数对象 0xb00 有引用,那么对应的 AO 对象 0x200 也就不可达了;

  • 在 GC 的下一次检测中,它们就会被销毁掉;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function createAdder(count) {
function adder(num) {
return count + num;
}
return adder;
}

var adder5 = createAdder(5);
adder5(100);
adder5(55);
adder5(12);

var adder8 = createAdder(8);
adder8(22);
adder8(35);
adder8(7);

console.log(adder5(24));
console.log(adder8(30));

// 永远不会再使用adder8
// 内存泄漏: 对于那些我们永远不会再使用的对象, 但是对于GC来说, 它不知道要进行释放的对应内存会依然保留着

adder8 = null;

文章转载于coderwhy | JavaScript 高级系列(三) - JavaScript 的运行过程