文章目录
  1. 1. 代码运行的各个阶段
  2. 2. 执行上下文的创建
    1. 2.1. 创建变量对象
      1. 2.1.1. 什么是作用域
    2. 2.2. 建立作用域链
      1. 2.2.1. 通过外部词法环境引用来创建作用域
      2. 2.2.2. VO -> AO 过程
      3. 2.2.3. 闭包的产生
    3. 2.3. 确定 this 的指向
  3. 3. 小结

上一篇《前端基础补齐–有关 JavaScript 继承》我们介绍了 JavaScript 的原型和继承,JavaScript 作为基于对象的编程语言,原型和继承是它的一大特点,理解 JavaScript 的继承方式有助于我们对代码进行更好的设计和优化。

除此之外,JavaScript 在执行过程中也与其他语言有所不一样,如果不理解 JavaScript 的词法环境、执行上下文等内容,很容易便会在开发过程中埋下“莫名奇妙”的 bug,比如this指向和预期不一致、某个变量不知道为什么被改了等等。

我们在谈论 JavaScript 的时候,常常会提到执行环境、词法环境、作用域、执行上下文、闭包等内容,要搞清楚它们之间的区别,首先我们需要理解 JavaScript 引擎在执行代码的过程。

代码运行的各个阶段

相信大家都知道,JavaScript 是弱类型语言,运行时才能确定变量类型,即使是如今流行的 Typescript,也只是增加了编译时(编译成 JavaScript)的类型检测。

对于编译器相信大家都有所了解,代码编译过程中会包括语法解析、语义分析、生成 AST 等过程。同样的,JavaScript 引擎在执行 JavaScript 代码的时候,会从上到下进行分词(Tokenizing)、词法分析(Lexing),并在代码解析完成后生成 AST(抽象语法树),最终根据 AST 生成 CPU 可以执行的机器码并执行。

以 V8 引擎为例,我们能看到 JavaScript 运行过程主要分成三个阶段:

语法分析阶段:
(1) 该阶段会对代码进行语法分析,检查是否有语法错误(SyntaxError)。如果发现语法错误,会在控制台抛出异常,并终止执行。
编译阶段。
(2) 该阶段会进行执行上下文(Execution Context)的创建,包括创建变量对象、建立作用域链、确定 this 的指向等。每进入一个不同的运行环境时,V8 引擎都会创建一个新的执行上下文。

执行阶段:
(3) 将步骤 (2) 中创建的执行上下文压入执行栈,并成为正在运行的执行上下文。
(4) 执行代码。
(5) 在代码执行结束后,将其弹出执行栈。

在了解完 JavaScript 执行的各个阶段之后,下面我们来进一步了解整个代码执行的过程,包括从中产生的执行上下文、词法环境、变量环境等内容。

其中,关于执行栈的内容我们会在下一讲详细讲解,目前我们只需要知道 JavaScript 在运行过程中会产生一个调用栈,调用栈遵循 LIFO(先进后出,后进先出)原则。
语法分析阶段比较简单,本文我们会重点介绍编译阶段,其核心便是执行上下文的创建。

执行上下文的创建

执行上下文的创建离不开 JavaScript 的运行环境。我们知道,JavaScript 运行环境包括全局环境、函数环境和eval,其中:

  1. 第一次载入 JavaScript 代码时,首先会创建一个全局环境。全局环境位于最外层,直到应用程序退出后(例如关闭浏览器和网页)才会被销毁。
  2. 每个函数都有自己的运行环境。当函数被调用时,则会进入该函数的运行环境,当该环境中的代码被全部执行完毕后,该环节会被销毁。不同的函数运行环境不一样,即使是同一个函数,在被多次调用时也会创建多个不同的函数环境。

要知道,在不同的运行环境中,变量和函数可访问的其他数据范围不同,各种的行为也有所区别。

每进入一个不同的运行环境时,JavaScript 都会创建一个新的执行上下文,该过程包括:建立作用域链(Scope Chain)、创建变量对象(Variable Object,简称 VO)以及确定 this 的指向。

由于建立作用域链过程中会涉及变量对象的概念,因此我们先来看看变量对象的创建。

创建变量对象

什么是变量对象呢?每个执行上下文都会有一个关联的变量对象,该对象上会保存在这个上下文中定义的所有变量和函数。

在浏览器中,全局环境的变量对象是window对象,因此所有的全局变量和函数都是作为window对象的属性和方法创建的。相对应的,在 Node 中全局环境的变量对象则是global对象。

创建变量对象过程将会创建arguments对象(仅函数环境下),同时会检查当前上下文的函数声明和变量声明:

  • 对于变量声明:此时会给变量分配内存,并将其初始化为undefined(该过程只进行定义声明,执行阶段才执行赋值语句)
  • 对于函数声明:此时会在内存里创建函数对象,并且直接初始化为该函数对象

上述过程便是我们常说的变量提升和函数提升,其中函数声明提升会优先于变量声明提升。变量提升容易带来变量在预期外被覆盖掉的问题,同时还可能导致本应该被销毁的变量没有被销毁等情况,因此 ES6 中引入了letconst关键字,从而使 JavaScript 也拥有了块级作用域。

或许你会感到疑惑,JavaScript 是怎么支持块级作用域的呢?这就涉及到了作用域的概念。

什么是作用域

在各类编程语言中,作用域分为静态作用域和动态作用域。JavaScript 采用的是词法作用域(Lexical Scoping),也就是静态作用域。词法作用域中的变量,在编译过程中会产生一个确定的作用域。

这里面涉及到很多概念,包括词法作用域、执行上下文、词法环境等等,它们之间是什么关系呢?

要知道,我们常说的作用域即当前的执行上下文,在 ES5 后我们使用词法环境(Lexical Environment)替代作用域来描述该执行上下文。因此,词法环境可理解为我们常说的作用域,同样也指当前的执行上下文(注意,是当前的执行上下文)。

在 JavaScript 中,词法环境分为词法环境(Lexical Environment)和变量环境(Variable Environment),其中:

  • 变量环境用来记录var/function等变量声明
  • 词法环境是用来记录let/const/class等变量声明

回到前面的问题,JavaScript 是怎么支持块级作用域的呢?便是通过这两个词法环境。使用两个词法环境(而不是一个)是为了实现块级作用域的同时,不影响var变量声明和函数声明。

我们继续来看执行上下文的创建过程,变量对象的创建过程会进行函数提升和变量提升,下面我们来看看作用域链的建立。

建立作用域链

什么是作用域链呢?顾名思义,作用域链就是将各个作用域通过某种方式连接在一起,具体是怎样的方式呢?下面我会把作用域的建立过程进行详细的介绍。

前面说过,作用域也就是词法环境,词法环境由两个成员组成:

  • 环境记录(Environment Record):用于记录自身词法环境中的变量对象
  • 外部词法环境引用(Outer Lexical Environment):记录外层词法环境的引用

其中,当某个变量无法在自身词法环境记录时,可以根据外部词法环境引用向外层寻找,这便是作用域链的变量查询。在最外层的全局词法环境中,外部词法环境引用为null

那么,作用域链是通过怎样的方式建立起来的呢?这个外部词法环境引用又是怎样指向外层呢?

通过外部词法环境引用来创建作用域

为了方便描述,我们将 JavaScript 代码执行过程分为定义期和执行期,前面提到的编译阶段则属于定义期。

来看一个例子,我们定义了全局函数foo,并在该函数中定义了函数bar

1
2
3
4
5
6
7
8
9
function foo() {
console.dir(bar);
var a = 1;
function bar() {
a = 2;
}
}
console.dir(foo);
foo();

前面我们说到,JavaScript 使用的是静态作用域,因此函数的作用域在定义期已经决定了。在上面的例子中,全局函数foo创建了一个foo[[scope]]属性,包含了全局[[scope]]

1
foo[[scope]] = [globalContext];

而当我们执行foo()时,也会分别进入foo函数的定义期和执行期。同样,在foo函数的定义期时,函数bar[[scope]]将会包含全局[[scope]]foo[[scope]]

1
bar[[scope]] = [fooContext, globalContext];

运行上述代码,我们可以在控制台看到符合预期的输出:
图片
也就是说,当代码进入执行阶段后,会通过外部词法环境引用来创建变量对象的一个作用域链,从而保证对执行环境有权访问的变量和函数的有序访问

在这个过程中,前面我们在编译阶段创建的变量对象(VO)中变量属性会进行赋值,变量对象会转为活动对象(Active Object,简称 AO),此时活动对象才可被访问,这是 VO -> AO 的过程,我们来详细进行分析。

VO -> AO 过程

为了更好地理解这个过程,我们来看个例子:

1
2
3
4
5
6
7
function foo(a) {
 var b = 2;
 function c() {}
 var d = function() {};
}

foo(1);

在执行foo(1)时,首先进入定义期,此时:

1
2
3
4
5
6
7
8
9
10
AO = {
 arguments: {
   0: 1,
   length: 1
},
 a: 1,
 b: undefined,
 c: reference to function c(){},
 d: undefined
}

前面我们也有提到,在执行期执会执行赋值语句进行赋值,此时:

1
2
3
4
5
6
7
8
9
10
AO = {
 arguments: {
   0: 1,
   length: 1
},
 a: 1,
 b: 2,
 c: reference to function c(){},
 d: reference to FunctionExpression "d"
}

其实,变量对象和活动对象本质上为同一个对象:

  • 在定义期(编译阶段):该对象值仍为undefined,且处于不可访问的状态
  • 进入执行期(执行阶段):VO 被激活,其中变量属性会进行赋值

实际上在执行的时候,除了 VO 被激活,活动对象还会添加函数执行时传入的参数和arguments这个特殊对象,因此 AO 和 VO 的关系可以理解为:

1
AO = VO + function parameters + arguments

现在,我们知道作用域链是在进入代码的执行阶段时,通过外部词法环境引用来创建的。但前面我们又说过,在创建执行上下文的时候会创建作用域链,而创建执行上下文又是在编译阶段,这是否有些矛盾呢?
其实并不会,比如上面例子中bar函数的编译阶段,其实是在foo函数的执行阶段中。JavaScript 代码虽然可以将其分为语法分析阶段、编译阶段和执行阶段,但实际上在 JavaScript 引擎中是通过调用栈的方式来运行的,因此并不存在“整个 JavaScript 运行过程只会在某个阶段中”这一说法。

相信大家现在已经掌握了作用域链的建立过程,那么作用域链的用途想必大家也已经了解,比如在函数执行过程中变量的解析:

  • 从当前词法环境开始,沿着作用域链逐级向外层寻找环境记录,直到找到同名变量为止
  • 找到后不再继续遍历,找不到就报错

一般来说,当函数执行结束之后,执行期上下文将被销毁(作用域链和激活对象均被销毁)。但在某些场景下,我们还需要使用到其中的一些变量对象,此时会使用到闭包。

闭包的产生

我们已经知道,通过作用域链,我们可以在函数内部可以直接读取外部以及全局变量。但在 JavaScript 中,只有函数内部的子函数才能读取局部变量。我们看下面的例子:

1
2
3
4
5
function foo() {
var a = 1;
}
foo();
console.log(a); // undefined

在全局环境下无法访问函数foo内的变量,这是因为全局函数的作用域链里,不含有函数foo内的作用域。现在如果我们想要访问内部函数的变量,可以这样做:

1
2
3
4
5
6
7
8
9
function foo() {
var a = 1;
function bar() {
return a;
}
return bar;
}
var b = foo();
console.log(b()); // 1

前面我们说到,当函数执行结束之后,执行期上下文将被销毁,其中包括作用域链和激活对象。那么,在这个例子中,当b()执行时,foo函数上下文包括作用域都已经被销毁了,为什么foo作用域下的a依然可以被访问到呢?

这是因为bar函数引用了foo函数变量对象中的值,此时即使创建bar函数的foo函数执行上下文被销毁了,但它的变量对象依然会保留在 JavaScript 内存中,bar函数依然可以通过bar函数的作用域链找到它,并进行访问。这便是我们常说的闭包,即使创建它的上下文已经销毁,它仍然被保留在内存中。

闭包则使得我们可以从外部读取局部变量,在大多数项目中都会被使用到,常见的用途包括:

  • 用于从外部读取其他函数内部变量的函数
  • 可以使用闭包来模拟私有方法
  • 让这些变量的值始终保持在内存中

需要注意的是,我们在使用闭包的时候,需要及时清理不再使用到的变量,否则可能导致内存泄露问题。

下面我们继续来看,执行上下文的创建过程中还会做的一件事:确定this的指向。

确定 this 的指向

在 JavaScript 中,this指向执行当前代码的对象的所有者,可简单理解为this指向最后调用当前代码的那个对象。

相信大家都很熟悉this,因此这里我就进行结论性的简单总结。根据 JavaScript 中函数的调用方式不同,this的指向分为以下情况:

  • 在全局环境中,this指向全局对象(在浏览器中为 window
  • 在函数内部,this的值取决于函数被调用的方式
    • 函数作为对象的方法被调用,this指向调用这个方法的对象
    • 函数用作构造函数时(使用new关键字),它的this被绑定到正在构造的新对象
    • 在类的构造函数中,this是一个常规对象,类中所有非静态的方法都会被添加到this的原型中
  • 在箭头函数中,this执行它被创建时的环境
  • 使用applycallbind等方式调用:根据 API 不同,可切换函数执行的上下文环境,即this绑定的对象

由于this指向的场景较复杂,在 ES6 箭头函数还没出现之前,为了能正确获取某个运行环境下this对象,我们常常会使用var that = this;var self = this;这样的代码将变量分配给this,便于使用。如今这种做法不再被提倡,通过正确使用箭头函数,我们可以更好地管理作用域。

到这里,围绕 JavaScript 的编译阶段和执行阶段中执行上下文创建相关的内容已经介绍完毕。

小结

本文主要介绍了 JavaScript 代码的执行过程,该过程分为语法分析阶段、编译阶段、执行阶段三个阶段,主要围绕着编译阶段和执行阶段进行描述,但 JavaScript 代码运行过程远比这复杂。

实际上,关于 JavaScript 代码执行这块,越研究越深,还是有很多东西可以学习的。

码生艰难,写文不易,给我家猪囤点猫粮了喵~

B站: 被删

查看Github有更多内容噢:https://github.com/godbasin
更欢迎来被删的前端游乐场边撸猫边学前端噢

如果你想要关注日常生活中的我,欢迎关注“牧羊的猪”公众号噢

作者:被删

出处:https://godbasin.github.io

本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。

文章目录
  1. 1. 代码运行的各个阶段
  2. 2. 执行上下文的创建
    1. 2.1. 创建变量对象
      1. 2.1.1. 什么是作用域
    2. 2.2. 建立作用域链
      1. 2.2.1. 通过外部词法环境引用来创建作用域
      2. 2.2.2. VO -> AO 过程
      3. 2.2.3. 闭包的产生
    3. 2.3. 确定 this 的指向
  3. 3. 小结