三、深入JavaScript的运行原理
Lyk 2022/7/23 JS高级JS代码执行V8执行原理V8引擎架构JS代码执行过程GO全局对象VO变量环境/变量对象AO活动对象环境记录执行上下文栈堆内存函数执行上下文全局执行上下文作用域作用域链
# 1、JavaScript代码的执行
- JavaScript代码下载好之后,是如何一步步被执行的呢?
- 我们知道,浏览器内核是由两部分组成的,以webkit为例:
- WebCore:负责HTML解析、布局、渲染等等相关的工作;(渲染引擎)
- JavaScriptCore:解析、执行JavaScript代码;(JS引擎)
- 另外一个强大的JavaScript引擎就是V8引擎。
# 2、V8引擎的执行原理
- 我们来看一下官方对V8引擎的定义:
- V8是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
- 它实现ECMAScript和WebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32,ARM或MIPS处理器的Linux系统上运行。
- V8可以独立运行,也可以嵌入到任何C ++应用程序中。
- V8引擎浅析 (opens new window)和V8执行js的过程 (opens new window)
# 3、V8引擎的架构
V8引擎本身的源码非常复杂,大概有超过100w行C++代码,通过了解它的架构,我们可以知道它是如何对JavaScript执行的:
Parse模块会将JavaScript代码通过词法分析,语法分析后转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
- 如果函数没有被调用,那么是不会被转换成AST的;
- Parse的V8官方文档:Parse的V8官方文档 (opens new window)
Ignition是一个解释器,会将AST转换成ByteCode(字节码)
- 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
- 如果函数只调用一次,Ignition会解释执行ByteCode;
- Ignition的V8官方文档:Ignition的V8官方文档 (opens new window)
TurboFan是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;
- 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成可直接执行优化的机器码,提高代码的执行性能;
- 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
- TurboFan的V8官方文档:TurboFan的V8官方文档 (opens new window)
# 4、V8引擎的解析图(官方)
词法分析(英文lexical analysis)
- 将字符序列转换成token序列的过程。
- token是记号化(tokenization)的缩写
- 词法分析器(lexical analyzer,简称lexer),也叫扫描器(scanner)
语法分析(英语:syntactic analysis,也叫 parsing)
- 语法分析器也可以称之为parser。
- V8引擎的解析图
# 5、JavaScript代码执行原理 - 版本说明
在ECMA早期的版本中(ECMAScript3),代码的执行流程的术语和ECMAScript5以及之后的术语会有所区别:
- 目前网上大多数流行的说法都是基于ECMAScript3版本的解析,并且在面试时问到的大多数都是ECMAScript3的版本内容。
- 但是ECMAScript3终将过去, ECMAScript5必然会成为主流,所以最好也理解ECMAScript5甚至包括ECMAScript6以及更好版本的内容;
- 事实上在TC39( ECMAScript5 )的最新描述中,和ECMAScript5之后的版本又出现了一定的差异;
那么我们按照如下顺序学习:
- 通过ECMAScript3中的概念学习JavaScript执行原理、作用域、作用域链、闭包等概念
- 通过ECMAScript5中的概念学习块级作用域、let、const等概念;
事实上,它们只是在对某些概念上的描述不太一样,在整体思路上都是一致的。
# 6、JavaScript的执行过程
# 6.1.js代码执行思考
# 编写一段JavaScript代码,它是如何执行的呢?
- 简单来说,JS引擎在执行JavaScript代码的过程中需要先解析再执行。那么在解析阶段JS引擎又会进行哪些操作,接下来就一起来了解一下JavaScript在执行过程中的详细过程,包括执行上下文、GO、AO、VO和VE等概念的理解。
# 6.2.初始化全局对象
- 首先,JS引擎会在执行代码之前,也就是解析代码时,会在我们的堆内存创建一个全局对象:Global Object(简称GO),观察以下代码,在全局中定义了几个变量:
var name = 'curry'
var message = 'I am a coder'
var num = 30
1
2
3
2
3
- JS引擎内部在解析以上代码时,会创建一个全局对象(伪代码如下):
- 所有的**作用域(scope)**都可以访问该全局对象;
- 对象里面会包含一些全局的方法和类,像Math、Date、String、Array、setTimeout等等;
- 其中有一个window属性是指向该全局对象自身的;
- 该对象中会收集我们上面全局定义的变量,并设置成undefined;
- 全局对象是非常重要的,我们平时之所以能够使用这些全局方法和类,都是在这个全局对象中获取的;
var GlobalObject = {
Math: '类',
Date: '类',
String: '类',
setTimeout: '函数',
setInterval: '函数',
window: GlobalObject,
...
name: undefined,
message: undefined,
num: undefined
}
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
# 6.3.执行上下文栈(调用栈)
了解了什么是全局对象后,下面就来聊聊代码具体执行的地方。JS引擎为了执行代码,引擎内部会有一个执行上下文栈(Execution Context Stack,简称ECS),它是用来执行代码的调用栈。`
ECS如何执行?先执行谁呢?
- 无疑是先执行我们的全局代码块;
- 在执行前全局代码会构建一个全局执行上下文(Global Execution Context,简称GEC);
- 一开始GEC就会被放入到ECS中执行;
那么全局执行上下文(GEC)包含那些内容呢?
- 第一部分:执行代码前。
- 在转成抽象语法树之前,会将全局定义的变量、函数等加入到Global Object中,也就是上面初始化全局对象的过程;
- 但是并不会真正赋值(表现为undefined),所以这个过程也称之为变量的作用域提升(hoisting);
- 第二部分:代码执行。
- 对变量进行赋值,或者执行其它函数等;
- 第一部分:执行代码前。
下面就通过一幅图,来看看GEC被放入ECS后的表现形式:
# 6.4.调用栈调用GEC的过程
# 接下来,将全局代码复杂化一点,再来看看调用栈调用全局执行上下文(GEC)的过程。实例代码如下:
var name = 'curry'
console.log(message)
var message = 'I am a coder'
function foo() {
var name = 'foo'
console.log(name)
}
var num1 = 30
var num2 = 20
var result = num1 + num2
foo()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 调用栈调用过程:
- 1、初始化全局对象。
- 这里需要注意的是函数存放的是地址,会指向函数对象,与普通变量有所不同;
- 从上往下解析JS代码,当解析到foo函数时,因为foo不是普通变量,并不会赋为undefined,JS引擎会在堆内存中开辟一块空间存放foo函数,在全局对象中引用其地址;
- 这个开辟的函数存储空间最主要存放了该函数的父级作用域和函数的执行体代码块;
- 2、构建一个全局执行上下文(GEC),代码执行前将VO的内存地址指向GlobalObject(GO)。
3、将全局执行上下文(GEC)放入执行上下文栈(ECS)中。
4、从上往下开始执行全局代码,依次对GO对象中的全局变量进行赋值。
- 当执行
var name = 'curry'
时,就从VO(对应的就是GO)中找到name属性赋值为curry; - 接下来执行
console.log(message)
,就从VO中找到message,注意此时的message还为undefined,因为message真正赋值在下一行代码,所以就直接打印undefined(也就是我们经常说的变量作用域提升); - 后面就依次进行赋值,执行到
var result = num1 + num2
,也是从VO中找到num1和num2两个属性的值进行相加,然后赋值给result,result最终就为50; - 最后执行到
foo()
,也就是需要去执行foo函数了,这里的操作是比较特殊的,涉及到函数执行上下文,下面来详细了解;
- 当执行
# 6.5.函数执行上下文
# 在执行全局代码遇到函数如何执行呢?
- 在执行的过程中遇到函数,就会根据函数体创建一个函数执行上下文(Functional Execution Context,简称FEC),并且加入到执行上下文栈(ECS)中。
- 函数执行上下文(FEC)包含三部分内容:
- AO:在解析函数时,会创建一个Activation Objec(AO);
- 作用域链:由函数VO和父级VO组成,查找是一层层往外层查找;
- this指向:this绑定的值,在函数执行时确定;
- 其实全局执行上下文(GEC)也有自己的作用域链和this指向,只是它对应的作用域链就是自己本身,而this指向为window。
# 继续来看上面的代码执行(6.4中的代码还有foo执行没有讲完)--->当执行到foo()
时:
- 先找到foo函数的存储地址,然后解析foo函数,生成函数的AO;
- 根据AO生成函数执行上下文(FEC),并将其放入执行上下文栈(ECS)中;
- 开始执行foo函数内代码,依次找到AO中的属性并赋值,当执行
console.log(name)
时,就会去foo的VO(对应的就是foo函数的AO)中找到name属性值并打印;
# 6.6.变量环境和记录
# 上文中提到了很多次VO,那么VO到底是什么呢?下面从ECMA新旧版本规范中来谈谈VO。
- 在早期ECMA的版本规范中:每一个执行上下文会被关联到一个变量环境(Variable Object,简称VO),在源代码中的变量和函数声明会被作为属性添加到VO中。对应函数来说,参数也会被添加到VO中。
- 也就是上面所创建的GO或者AO都会被关联到变量环境(VO)上,可以通过VO查找到需要的属性;
- 规定了VO为Object类型,上文所提到的GO和AO都是Object类型;
- 在最新ECMA的版本规范中:每一个执行上下文会关联到一个变量环境(Variable Environment,简称VE),在执行代码中变量和函数的声明会作为**环境记录(Environment Record)**添加到变量环境中。对于函数来说,参数也会被作为环境记录添加到变量环境中。
- 也就是相比于早期的版本规范,对于变量环境,已经去除了VO这个概念,提出了一个新的概念VE;
- 没有规定VE必须为Object,不同的JS引擎可以使用不同的类型,作为一条环境记录添加进去即可;
- 虽然新版本规范将变量环境改成了VE,但是JavaScript的执行过程还是不变的,只是关联的变量环境不同,将VE看成VO即可;
# 7、全局代码执行过程(函数嵌套):加深理解js执行过程
# 7.1.了解了上面相关的概念和调用流程之后,就来看一下存在函数嵌套调用的代码是如何执行的,以及执行过程中的一些细节,以下面代码为例:
var message = 'global'
function foo(m) {
var message = 'foo'
console.log(m)
function bar() {
console.log(message)
}
bar()
}
foo(30)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
# 7.2.初始化全局对象(GO),执行全局代码前创建GEC,并将GO关联到VO,然后将GEC加入ECS中:
- foo函数存储空间中指定的父级作用域为全局对象;
# 7.3.开始执行全局代码,从上往下依次给全局属性赋值:
- 给message属性赋值为global;
# 7.4.执行到foo函数调用,准备执行foo函数前,创建foo函数的AO:
- bar函数存储空间中指定父级作用域为foo函数的AO;
# 7.5.创建foo函数的FEC,并加入到ECS中,然后开始执行foo函数体内的代码
- 根据foo函数调用的传参,给形参m赋值为30,接着给message属性赋值为foo;
- 所以,m打印结果为30;
# 7.6.执行到bar函数调用
- 执行到bar函数调用,准备执行bar函数前,创建bar函数的AO
- bar函数中没有定义属性和声明函数,以空对象表示;
# 7.7.创建bar函数的FEC
- 创建bar函数的FEC,并加入到ECS中,然后开始执行bar函数体内的代码:
- 执行
console.log(message)
,会先去bar函数自己的VO中找message,没有找到就往上层作用域的VO中找; - 这里bar函数的父级作用域为foo函数,所以找到foo函数VO中的message为foo,打印结果为foo;
- 全局中所有代码执行完成,bar函数执行上下文出栈,bar函数AO对象失去了引用,进行销毁。
- 接着foo函数执行上下文出栈,foo函数AO对象失去了引用,进行销毁,同样,foo函数AO对象销毁后,bar函数的存储空间也失去引用,进行销毁。
# 7.8.概念名词总结:
- 函数在执行前就已经确定了其父级作用域,与函数在哪执行没有关系,以函数声明的位置为主;
- 执行代码查找变量属性时,会沿着作用域链一层层往上查找(沿着VO往上找),如果一直找到全局对象中还没有该变量属性,就会报错未定义;
名词 | 解释 |
---|---|
ECS | 执行上下文栈(Execution Context Stack),也可称为调用栈,以栈的形式调用创建的执行上下文 |
GEC | 全局执行上下文(Global Execution Context),在执行全局代码前创建 |
FEC | 函数执行上下文(Functional Execution Context),在执行函数前创建 |
VO | Variable Object,早期ECMA规范中的变量环境,对应Object |
VE | Variable Environment,最新ECMA规范中的变量环境,对应环境记录 |
GO | 全局对象(Global Object),解析全局代码时创建,GEC中关联的VO就是GO |
AO | 函数对象(Activation Object),解析函数体代码时创建,FEC中关联的VO就是AO |
# 8、作用域和作用域链(Scope Chain)
- 当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
- 作用域链是一个对象列表,用于变量标识符的求值;
- 当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;
# 9、作用域提升面试题
// 面试题一
var n = 100
function foo() {
n = 200
}
foo()
console.log(n)//200
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
// 面试题二
function bar() {
console.log(n)//undefined
var n = 200
console.log(n)//200
}
var n = 100
bar()
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
// 面试题三
var n = 100
function foo() {
console.log(n)//100
}
function bar() {
var n = 200
console.log(n)//200
foo()
}
bar()
console.log(n)//100
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
// 面试题四
var a = 100
function foo() {
console.log(a)//undefined
return
var a = 100
}
foo()
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
// 面试题五
function foo() {
var a = b =100
/*上面代码相当于:
var a = 100
b = 100
*/
}
foo()
console.log(b)//100
console.log(a)//报错:a is not defined
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13