JS 的执行过程

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
    }
  }
}
1
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()
1
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: {...}
  }
}
1
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 文件),运行时不需要重新编译,直接用编译后的文件就行了。

  • 优点:执行效率高
  • 缺点:跨平台性差

解释型语言

程序不需要编译,程序在运行的过程中才用解释器编译成机器语言,边解释边执行

  • 优点:跨平台性好
  • 缺点:执行效率低
Last Updated: 10/21/2024, 4:15:17 PM