JS 的执行过程
JS 是动态类型的 解释型 语言,它的执行共分为两个阶段:编译阶段、执行阶段。这两个阶段都是在 JS 引擎中进行的。
# 编译阶段
这个阶段会经历词法分析、语法分析、代码生成步骤最终生成可被 JS 引擎执行的代码
# 词法分析
JS 引擎会将我们写的代码当成字符串分解成词法单元(token)。例如,var a = 2
,这段程序会被分解成:var、a、=、2
。
Token 是能拆分的最小单位,固定 type 表述类型/属性,value 表示对应的值。
可以试试这个网站地址查看 token :Esprima (opens new window)
# 语法分析
在进行词法分析转为 Token 之后,解析器(Parser)
继续根据生成的 Token 生成对应的 AST
# 生成可执行代码
对于 CPU 来说,它只认识机器码(二进制),但是 JS 引擎在经历过语法分析生成 AST 之后,并不会根据 AST 直接生成机器码,而是由 解释器(Ignition)
通过 AST 生成一种介于 AST 和 机器码之间的字节码(又叫中间码)
。字节码需要通过解释器将其转换为机器码然后执行。
这么做的原因主要是因为,直接将 JS 编译生成机器码的效率很低,而且体积很大(相较而言,字节码就轻量很多,如下图),对于像 web 应用这样高频访问低频更新的场景来说,js 文件的变更并不是那么频繁,但是如果用户每次打开网页都需要重新去将 js 文件编译成机器码就会影响到性能和体验。
除了引入字节码这种中间状态之外,为了提升 JS 的执行效率,JS 引擎还引入了 即时编译
技术,即 JIT
# JIT
在计算机技术中,即时编译(英语:just-in-time compilation,缩写为 JIT (opens new window);又译及时编译[1]、实时编译[2]),也称为动态翻译或运行时编译[3],是一种执行计算机代码的方法,这种方法涉及在程序执行过程中(在运行期)而不是在执行之前进行编译。
它是如何工作的呢?
# 监视器
在 JavaScript 引擎中增加一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息,如果同一行代码运行了几次,这个代码段就被标记成了 warm
,如果运行了很多次,则被标记成 hot
。
# 基线编译器
如果一段代码变成了 warm
,那么 JIT 就把它送到基线编译器去编译,并且把编译结果存储起来。
# 优化编译器
如果一个代码段变得 hot
,监视器会把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,并且存储。
TurboFan(译:涡轮风扇)
编译器 是 JIT 优化编译器,用于优化在解释器中生成的字节码。在某些时候,引擎确定代码很热并启动 TurboFan 前端,这是 TurboFan 的一部分,它处理集成分析数据和构建代码的基本机器表示。然后将其发送到另一个线程上的 TurboFan,以进一步改进代码。V8 引擎是多线程的,TurboFan 编译和生成字节码不在同一个线程上。
# 去(反)优化
由解释器收集的分析数据被 TurboFan 使用,主要是通过一种称为推测优化(Speculative Optimization)
的技术生成高度优化的机器码。TurboFan 会查看过去看到的值类型,并假设将来我们将看到相同类型的值,这可以使得 TurboFan 省去很多不需要处理的情况。如果假设失败了,那么就会返回到解析字节码,这也就是反优化(deoptimization)。
JS 语言是动态类型语言,对象的结构和属性在运行时是可以发生改变的。设想一个问题,如果热代码在某次执行的时候,突然其中的某个属性被修改了,那么编译成机器码的热代码肯定不能继续执行。这个时候就要使用到优化编译器的反优化了,他会将热代码退回到 AST 这一步,这个时候解释器会重新解释执行被修改的代码,如果代码再次被标记为热代码,那么会重复执行优化编译器的这个步骤。
# 执行阶段
执行程序需要有执行环境, Java 需要 Java 虚拟机,同样解析 JavaScript 也需要执行环境,我们称它为执行上下文
。
# 执行上下文
当执行 JS 代码时,会产生三种执行上下文:
# 全局执行上下文
当 JS 引擎执行全局代码的时候,会编译全局代码并创建执行上下文,它会做两件事:
- 创建一个全局的 window 对象(浏览器环境下)
- 将 this 的值设置为该全局对象;全局上下文在整个页面生命周期有效,并且只有一份
# 函数执行上下文
当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
# eval 执行上下文
调用 eval (opens new window) 函数也会创建自己的执行上下文。但 eval 函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢(因为它必须调用 JS 解释器),所以一般不推荐使用
这里只需关注全局以及函数执行上下文。
全局执行上下文会创建全局环境记录,而函数执行上下文创建时会同时创建 This Binding
、变量环境(VariableEnvironment)
、词法环境(LexicalEnvironment)
。
全局环境记录
、变量环境(VariableEnvironment)
和 词法环境(LexicalEnvironment)
都是一种环境记录。
# 环境记录仅做了解
根据最新 tc39 规范文档Environment Records (opens new window)
- 环境记录是一种规范类型,用于记录代码中的标识符与变量和函数的映射关系,类似一个对象或者 map。
- 环境记录分为
声明式环境记录(Declarative Environment Record)
函数式环境记录(Function Environment Record)
模块式环境记录(Module Environment Record)
对象环境记录(Object Environment Record)
全局环境记录(Global Environment Record)
- 每个环境记录都有一个
outerEnv
字段,outerEnv 可能为 null(全局环境记录的 outerEnv 为 null) 或者 是对外部环境记录的引用- 对外部词法环境的引用将一个词法环境和其外部词法环境链接起来,外部词法环境又拥有对其自身的外部词法环境的引用。这样就形成一个链式结构,这里我们称其为环境链(即 ES6 之前的作用域链),全局环境是这条链的顶端
# 声明式环境记录
声明式环境记录是用来定义那些直接将标识符与语言值绑定的 ES 语法元素,例如变量,常量,let,class,module,import 以及函数声明等。
# 函数式环境记录
函数环境记录用于体现一个函数的顶级作用域,有 arguments 对象,且如果函数不是箭头函数,还会提供一个 this 的绑定。
# 模块式环境记录
模块环境记录用于体现一个模块的外部作用域(即模块 export 所在环境),除了正常绑定外,也提供了所有引入的其他模块的绑定(即 import 的所有模块,这些绑定只读),因此我们可以直接访问引入的模块
# 对象式环境记录
每个对象式环境记录都与一个对象相关联,这个对象叫做对象式环境记录的 binding object
。可以理解为对象式环境记录就是基于这个 binding object,以对象属性的形式进行标识符绑定,标识符与 binding object 的属性名一一对应。
是对象就可以动态添加或者删除属性,所以对象环境记录不存在不可变绑定。
对象式环境记录用来定义那些将标识符与某些对象属性相绑定的 ES 语法元素,例如 with 语句
、全局 var 声明
和函数声明
。
# 全局环境记录
上面提到全局执行上下文会创建全局环境记录,它实际上是由声明式记录和对象式记录组合而成,两者都包含。
全局环境记录伪代码表示
GlobalEnvironmentRecords: {
outerEnv: null, // 全局环境 的外部引用为null
GlobalThisValue: // this的绑定 如 window
// 词法环境
LexicalEnvironment: {
ObjectRecord: {
// 包含了全局下var、function、generator、async声明的标识符 还有其他内置对象 如Math、Date
// 用全局对象(如window)作为绑定对象,所以在全局下用var、function...声明的变量可以通过window[变量名] 访问(或window.变量名)
[变量名]: undefined
},
},
// 变量环境
VariableEnvironment: {
DeclarativeRecord: {
// 除了var、function、generator、async声明的标识符保存在这里,如let、const
[变量名]: uninitialized // 在编译阶段为uninitialized
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关于全局环境记录,它的内部属性
[[ObjectRecord]]
指向对象式记录
之前说过每个对象式环境记录都有一个 binding object,全局环境记录的对象式环境记录的 binding object 就是全局对象,在浏览器内,全局的 this 及 window 绑定都指向全局对象。
全局环境记录的对象式环境记录组件,绑定了所有内置全局属性、全局的函数声明以及全局的 var 声明,所以这些绑定我们可以通过 window.xx 或 this.xx 获取到。
[[DeclarativeRecord]]
指向声明式记录
全局代码的其他声明(如 let、const、class 等)则绑定在声明式环境记录组件内,由于声明式环境记录组件并不是基于简单的对象形式来实现绑定,所以这些声明我们并不能通过全局对象的属性来访问。
let a = 1
const b = 2
class C {}
console.log(window.a) // undefined
console.log(window.b) // undefined
console.log(window.C) // undefined
var d = 3
function bar() {}
console.log(window.d) // 3
console.log(window.bar) // f()
2
3
4
5
6
7
8
9
10
11
12
13
# 函数执行上下文的创建
上面说到函数执行上下文会创建时会同时创建 This Binding
以及变量环境(VariableEnvironment)
和词法环境(LexicalEnvironment)
两个环境记录组件,如下图:
函数执行上下文创建的环境记录的伪代码表示
FunctionExecutionContext = {
this: ...,
// 变量环境
VariableEnvironment: {
// 保存var声明的标识符、function
outerEnv: GlobalExecutionContext // 外部环境记录
},
// 词法环境
LexicalEnvironment: {
// 保存let、const声明的标识符
outerEnv: GlobalExecutionContext // 外部环境记录
arguments: {...}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 词法环境 VS 变量环境
当将一个词法环境的 var 和 let 声明的标识符分开存储时,词法环境也就有了区分
- 一个称之为变量环境(VariableEnvironment),用于封装 var、函数声明的标识符(会提升)
- 另一个则称之为词法环境(LexicalEnvironment),用于封装非 var 声明的标识符(不会提升)
正因为如此, 变量环境只有全局作用域和函数作用域,同理,词法环境则是三种作用域:全局、块、函数。
# 总结
- 环境记录是用于记录代码中变量和函数标识符的映射
- 执行上下文分为全局执行上下文、函数执行上下文和 eval 创建的执行上下文
- 全局上下文会创建全局环境记录,函数执行上下会创建词法环境组件和变量环境组件
- 词法环境组件用于保存let、const 标识符,变量环境组件用于保存var、函数声明的标识符
- 作用域是变量查找的规则,可以简单理解作用域就是环境记录,环境记录的 outerEnv 所在的链表形成了作用域链
- js 是采用词法(静态)作用域模型,即环境记录以及所指向的外部环境记录由你代码书写的位置决定
如果你了解 ES5 版本的有关执行上下文的内容,会感到奇怪为啥有关 VO、AO、作用域、作用域链等内容没有提及。其实两者概念并不冲突,一个是 ES3 规范中的定义,而词法环境则是 ES6 规范的定义。不同时期,不同称呼。
- 作用域 <=> 词法环境
- 作用域链 <=> outerEnv 引用
- VO|AO <=> 环境记录
# 附一
编译型语言
程序在执行之前需要一个专门的编译过程,把程序编译成为机器语言的文件(如 exe 文件),运行时不需要重新编译,直接用编译后的文件就行了。
- 优点:执行效率高
- 缺点:跨平台性差
解释型语言
程序不需要编译,程序在运行的过程中才用解释器编译成机器语言,边解释边执行
- 优点:跨平台性好
- 缺点:执行效率低