toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
20 stars 1 forks source link

JavaScript 脚本编译与执行过程简述 #266

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

由于上一篇文章对作用域、执行上下文等写得过于详细,而且参杂了很多内容,看起来并不好理解,因此就简化一下。

在网页里,JavaScript 脚本是插入在 <script> 标签内的(内联或外部引入)。

<script>
  // some JavaScript code...
</script>

<script>
  // some JavaScript code...
</script>

<script src="xxx"></script>

一般情况下,脚本是按顺序一块一块地执行的(一个 <script> 标签为一块)。只有执行完一块,才会执行下一块的。如果当前块有语法错误或其他错误导致当前块终止执行,不会影响下一块的执行。

对于外部引入的 JavaScript 脚本,若设置 <script> 标签的 deferasync 属性会影响脚本的加载顺序,不会按着编写顺序执行。而这些属性对于内联脚本是没有作用的。

要让 JavaScript 代码运行起来,包括编译阶段执行阶段。每一块脚本的运行都包含这两个过程。请注意,不是将“所有块”编译完再执行的。

编译阶段:
  词法分析:将源码分解成很多个有意义的 token
  语法分析:通篇检查当前块的语法是否有误,若无,将 tokens 转换为 AST
  代码生成:将 AST 转换为 JS 引擎可执行代码

执行阶段:
  创建执行上下文
  初始化执行上下文
  代码执行

JS 引擎的优化工作是在编译阶段完成的,例如 V8 引擎。若编译阶段检查出语法有误,将会终止当前块的后续阶段。

待编译阶段完成后,接着进入执行阶段。而执行阶段可以分为两个步骤:

  1. 进入执行上下文
  2. 代码执行

进入执行上下文步骤,可以理解为,真正一行一行执行代码的前期准备工作:

  1. JS 引擎创建一个叫做:执行上下文栈(Execution Context Stack,ECS)的东西。顾名思义,就是用了维护执行上下文的。

  2. 最先进入的肯定是全局代码,这个执行上下文被称为:全局上下文。首先插入 ECS,因此 ECS 栈底永远是全局上下文。后面调用函数时,会进入函数上下文,该上下文也会插入 ECS。

  3. 每进入一个执行上下文,都会做一些初始化工作。

待准备工作完成后,就会进入代码执行步骤,即一行一行地去执行代码。

大家可能疑惑的地方,应该是“前期准备工作”的具体过程是怎样的?

执行上下文栈

进入全局代码,ECS 插入 GlobalContext,后面每调用一个函数就往 ECS 栈顶插入 FunctionalContext,函数执行完 FunctionalContext 又会从栈顶删除......周而复始的过程。ESC 栈底永远保留着 GlobalContext。直到页面销毁,它才会被删除,ESC 的使命也就结束了,被 JS 引擎清空。

ECS = [
  FunctionalContext // 栈顶
  ...
  FunctionalContext
  GlobalContext  // 栈底
]

前面提到上下文:GlobalContext(全局上下文)、FunctionalContext(函数上下文)。

执行上下文

其实 JavaScript 中有三种执行上下文:

全局上下文:
  全局下都是属于全局上下文。

函数上下文:
  顾名思义,就是 JavaScript 函数内的执行上下文

eval 上下文:
  就是 eval('some code...'),尽可能不要用,会影响性能、欺骗词法作用域

每个执行上下文,可以看作是一个 JavaScript 对象,它有三个重要属性:

Variable Object: 
  即变量对象,简称 VO。我们声明的变量、函数就是放在 VO 中的。

Scope Chain: 
  即作用域链,简称 Scope。它包含了当前上下文 VO,以及各父级的 VO。查找变量就是根据这链条去查询的。

This: 
  即 this 对象,这是一个很灵活的对象,跟函数调用相关。
}

执行上下文的初始化工作,就是确定这些属性的一些值。这个“初始化”的过程也常被称为“预编译”,网上很多文章都有这个说法。

举例

上面的执行阶段的过程,举例会更好地说明。

var a = 'global'
function foo() {
  var a = 'local'
  function bar() {
    console.log(a)
  }
  bar()
}
foo() // "local"

当编译过程之后,进入执行阶段。JS 创建一个执行上下文栈 ECStack,总是先进入全局上下文,全局上下文被插入到执行上下文栈:

ECStack = [ GlobalContext ]

接着对 GlobalContext 进行初始化:

GlobalContext = {
  VO: window, 
  Scope: [ GlobalContext.VO ],
  this: GlobalContext.VO
}
// 注意,不同宿主环境顶层对象是不一样的。
// 浏览器下为 window 对象,Node 中为 global 对象。
// 本文以浏览器为例,因此直接写了 window 对象。

初始化的同时 foo 函数也被创建,会保存当前上下文的作用域链到函数内部的 [[scope]] 属性中。该属性可以通过 console.dir(foo) 查看。

foo.[[scope]] = [ GlobalContext.VO ]

当全局上下文初始化完成之后,开始执行全局代码,当进行到 foo() 时,JS 引擎又会创建 foo 函数执行上下文,并插入 ECStack 栈顶。

ECStack = [ GlobalContext, FunctionalContext<foo> ]

跟着,会进入 foo 函数上下文,该上下文也会进行初始化工作:

  1. 复制 foo 函数 [[scope]] 属性创建作用域链。
  2. arguments 创建活动对象 AO。
  3. 初始化活动对象,即 AO 按顺序加入:形参、函数声明、变量声明。若存在同名情况,函数声明会覆盖形参,变量声明会被忽略。
  4. 将 AO 对象加入 foo 作用域链顶端。
FunctionalContext<foo> = {
  AO: {
    arguments: {
      length: 0
    },
    a: undefined,
    bar: ƒ bar()
  },
  Scope: [ GlobalContext.VO, AO ],
  this: undefined // this 是在函数执行的时候才确定下来的,foo 函数的 this 的值跟作用域链没有关系
}

初始化完成,开始执行 foo 函数体代码,AO 对象也会更新。当执行到 bar() 函数时,又将会进入 bar 函数上下文,并进行初始化工作:

ECStack = [ GlobalContext, FunctionalContext<foo>, FunctionalContext<bar> ]
FunctionalContext<bar> = {
  AO: {
    arguments: {
      length: 0
    }
  },
  Scope: [ GlobalContext.VO, FunctionalContext<foo>.VO, AO ],
  this: undefined
}

初始化完成,开始执行 bar 函数代码,在 console.log(a) 中,会查找 a 变量。

按作用域链顺序查找,先从当前上下文的 AO 中查找,若查找不到再往上一层 AO 中查找...直到全局上下文的 VO 中查找,若再查找不到就会抛出引用错误(ReferenceError)。

AO -> FunctionContext<foo>.AO -> GlobalContext.VO

很明显,a 变量将会在 FunctionContext<foo>.AO 中查找到,其值为 "local",因此打印结果就是 "local"

bar() 执行完毕,bar 函数上下文会被从栈顶删除。

ECStack.pop(FunctionalContext<bar>)

又将执行权交还给 foo 函数上下文,它也将会执行完毕,也会从栈顶移除。

ECStack.pop(FunctionalContext<foo>)

然后又交还给全局上下文

ECStack = [ GlobalContext ]

至此,这个程序执行完毕。当页面销毁时,ECStack 才会被清空。