toFrankie / blog

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

细读 JS | 执行上下文、作用域 #263

Open toFrankie opened 1 year ago

toFrankie commented 1 year ago

配图源自 Freepik

本文主要是深入 JavaScript 执行过程,覆盖了执行上下文、变量对象、作用域链、This、闭包等内容。

但文中可能参杂了很多其他内容,看起来没有那么地清晰。内容主要整合了:《JavaScript 高级程序设计(第四版)》、冴羽大佬的 JavaScript 专题系列、Veda 专题、以及本人此前写过的一些文章。

其实我建议你们以下专题会更好:

一、JavaScript

先讲一些不是很重要的“废话”,可直接跳到第二节。

就个人而言,自从喜欢上写文章之后,我经常会在一些概念性上的东西纠结,本着认真负责任的态度,理应如此。举个例子,JavaScript 是什么类型的语言?

说实话,虽然作为一个 JSer,我至今也没弄明白 JavaScript 是什么语言。我不知道你们会不会有这些纠结点,反正我会,我想要一个官方且权威的解释,并将它作为我此后的观点。假设将来某一天有人问我:JavaScript 是什么?我可以只字不差地跟他说道。

JavaScript 与 ECMAScript 的历史关系

我们知道,JavaScript 语言的创造者是 Brendan Eich,也被称为 JavaScript 之父。1995 年任职于 Netscape 公司的 Brendan Eich 接到一项任务:负责开发一个在浏览器上运行的编程语言。没错,那就是“后来”的 JavaScript 语言。

其实,起初 JavaScript 语言的名称是 “LiveScript”。由于当时 Java 语言如日中天,为了蹭热度而更名为 JavaScript。原本 JavaScript 这个项目就是 Netscape 公司和 Sun 公司合作开发,用这个名字也无可厚非。

在 1996 年 Netscape 公司决定将 JavaScript 提交给行业标准组织 ECMA,希望这种语言能够成为国际标准。次年 6 月发布第一版,即 ECMA-262 号标准。其中 ECMA-262 是一个编号,对应的标准名称为 ECMAScript,换句话说 ECMA-262 和 ECMAScript 是同一个东西的两种不同表达方式。就好比如身份证号码和姓名的关系,只是 EMCA 组织的标准名称不会重名罢了。

请注意,ECMAScript 是一种规范和标准,而不是一门编程语言。只是我们习惯将符合 ECMAScript 标准的语言统称为 “JavaScript” 罢了。严谨地的说法应该是:JavaScript 是 ECMAScript 的一种实现。

扯了那么多,那 JavaScript 究竟是什么类型的语言?

由于 JavaScript 编程语言并没有严格意义上的官网,因此并没有一个明确的说法。相对来说,由 Mozilla 基金会维护的 MDN Web Docs 相对权威一些。

  • JavaScript (JS) 是一种具有函数优先的轻量级,解释型或即时编译型的编程语言。(源自 MDN)

  • 通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。(源自《你不知道的 JavaScript》书本)

因此,我将会理解为:JavaScript 是一门编译型的语言。当你对 JavaScript 有基础的认知之后,自然就想着深入地了解这门语言,于是第一站来到了 JavaScript 引擎。

JavaScript 是怎样解析代码的呢?

由于蹩脚的英语水平,没办法直接去看类似 V8 引擎等官网,有点吃力。于是全网搜索各类文章,于是又出现了很多名词/术语,比如:解析器、编译器、预编译、词法分析等等等...

由于各类文章参差不齐,而且从英文翻译成中文,可能语义上也会有偏差。加之每个人对 JavaScript 相关术语的理解或认知水平又不一样,有时候真的很难去考究谁对谁错,唯有更相信一些大佬的文章。从这个心路历程来看,学好英文真的很重要。

举个例子,我看到很多文章描述 JS 引擎去执行代码的过程,有的说是解析器、有的又称为编译器。当初看到这些真的好烦,究竟谁对谁错呢?谁更严谨呢?由于我又是强迫症,又特想搞清楚。

于是我不停地找答案......终于找到了这篇文章:我们应该如何去了解 JavaScript 引擎的工作原理,并截取文中一段话:

因此,我也会开始认同:不需要过分去强调 JavaScript 解析引擎到底是什么,了解它究竟做了什么事情就可以了。由于前面把 JavaScript 更倾向于定义为编译型语言,因此,下文将涉及的相关内容称为“编译”、“编译器”、“编译过程”等。当然用“解析”也不能说它错。

二、JavaScript 编译与执行

举个例子,再简单不过了。

var a = 1;

通常情况下,我们习惯将 var a = 1; 看作一个声明,而实际上 JS 引擎并不这么认为。它将 var aa = 1 当作两个单独的声明,第一个是编译阶段的任务,而第二个则是执行阶段的任务。

在 JavaScript 中,编译阶段和执行阶段包括以下过程:

词法分析 -> 语法分析 -> 代码生成 -> 解释执行

1. 编译阶段

简单地说,编译阶段就是将源代码转换成可执行代码。大致包括以下过程:

词法分析 -> 语法分析 -> 代码生成

首先,在词法分析阶段将源代码 var a = 1; 转换为 tokens 如下:

[
  {
    "type": "Keyword",
    "value": "var"
  },
  {
    "type": "Identifier",
    "value": "a"
  },
  {
    "type": "Punctuator",
    "value": "="
  },
  {
    "type": "Numeric",
    "value": "1"
  },
  {
    "type": "Punctuator",
    "value": ";"
  }
]

接着语法分析阶段,将 tokens 转换为 AST(Esprima Parser),结构如下:

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "script"
}

假设在语法分析阶段,若 tokens 无法构成合法的语句时,将会抛出语法错误(SyntaxError),例如:

var a = () // SyntaxError: Unexpected token ')'

然后,如果前面的过程都没有问题,将会代码生成阶段,来生成可执行代码。

上述三个过程是传统编译语言在执行源代码之前经历的三个步骤,统称为“编译”。而 JS 引擎要复杂得多,例如在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化。对此,就本文讨论范围有个大致的认知就行。如有兴趣另行搜索 V8 性能优化的策略。

JavaScript 的编译过程,它发生在代码运行之前,它主要做的事情有这些:

  • 确定函数、变量的词法作用域(由编写的位置来决定)。除了 eval、with 会欺骗词法作用域之外,其他情况词法分析器会保持其作用域不变。
  • 检查语法是否有误
  • 为变量声明、函数声明分配内存空间。

说那么多,大家更关心的可能是这句话:包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理

另外,JavaScript 是按块(<script>)编译的,其中语法分析是对当前块进行通篇的语法检查,若有误则抛出语法错误(SyntaxError),因此也不会执行当前块的任何代码了。接着再对下一个 <script> 进行编译执行。

<script>
  console.log('script1')
  if (true) else false // 这里语法有误
</script>
<script>
  console.log('script2')
</script>

最终的打印结果是 "script2"。但编译器对第一个 <script> 块语法分析阶段就抛出错误:SyntaxError: Unexpected token 'else'。接着执行下一个块,于是打印出 "script2"

<script>
  function fn1() {
    console.log('fn1')
  }
  fn2() // ReferenceError: fn2 is not defined
</script>
<script>
  function fn2() {
    console.log('fn2')
  }
  fn1() // "fn1"
</script>

上述这个例子,也可以证明 <script> 是按块编译执行的。

2. 执行阶段

编译阶段完成之后,接着就到代码执行阶段。这个阶段主要涉及到执行上下文的内容。下午再详说...

三、JavaScript 作用域

1. 作用域是什么?

在编写 JavaScript 程序时,例如一个简单的声明语句:

var a = 1

我们可能会问这些问题:

疑问先保留着,再看个例子:

var a = 1

function foo() {
  var a = 'local'
  consol.log(a)
}

foo()

如果函数 foo 内部有一个同名变量 a,那么调用 foo() 时,语句 console.log(a) 中的标识符 a 是指哪一个?

上述示例非常简单,但往往在写项目的时候,我们会定义很多变量和函数,以及嵌套使用等场景。那么这一连串的复杂问题,JS 引擎是怎么去处理它们的呢?它肯定需要约定好一套规则,开发者在编写代码的时候,唯有老老实实地按照这套规则去编写程序,才会得到预期结果。

这套规则被称为“作用域”,它约定了如何存储变量以及查找变量。然后各家 JS 引擎将会按照这套规则去实现,所以同一份代码在各浏览器下都可以得到相同的效果。

2. 了解作用域

我们将“作用域”定义为一套规则,这套规则用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找。

作用域的概念,不是 JavaScript 语言特有的,每一门编程语言都存在的,只是规则不一样而已。作用域有两种工作模型:一是“词法作用域”,二是“动态作用域”。前者也称为“静态作用域”,被大多数编程语言(包括 JavaScript)所采用。词法作用域是在编写代码或者说定义时确定的,而动态作用域是在运行时确定的。

需要明确的是,JavaScript 只有词法作用域,并不具有动态作用域。请注意 this 机制只是“像”动态作用域而已。

词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。this 也是关注函数如何调用的,所以说它像动态作用域。

举个例子:

var a = 1

function foo() {
  console.log(a)
}

function bar() {
  var a = 2
  foo()
}

bar() // 打印 1

因此,无论函数 foo 在哪里被调用,都会打印出 1。执行 foo() 时,首先在函数内部查找是否存在(局部)变量 a,如无,再往上一层作用域查找,结果找到了且值为 1,因此会打印出 1。假设一层一层往上查找,直至全局作用域也找不到变量时,就会抛出 ReferenceType 错误。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。

四、执行上下文栈

我们书写的(源)代码会被编译器进行处理,并生成“可执行代码”,接着去执行它们。整个 JavaScript 程序的编译及执行过程,都由 JS 引擎来协调处理。

前面提到,函数的作用域是由其声明的位置决定的。那么我们一个项目中编写的函数会非常多,那么 JS 引擎如何管理它们呢?

JS 引擎在执行代码之前,会创建一个执行上下文栈,将用于管理执行上下文。

名词说明:

  • 执行上下文栈,英文 Execution Context Stack,ECS。有些文章称为“调用栈”。
  • 执行上下文(Execution Context,EC),简称上下文。执行上下文主要分为两类,一个是全局执行上下文(简称全局上下文),一个是函数执行上下文(简称函数上下文)。

我们知道,栈的特点是先进后出、后进先出,而且栈只能在栈顶进行插入(push)、删除(pop)操作。

在 JavaScript 中,代码执行及执行上下文栈的变化过程,大致如下:

(1)当 JS 引擎开始执行代码的时候,总会最先遇到全局代码,并产生一个全局上下文(GlobalContext,浏览器环境为 window 对象),并在执行上下文栈的栈顶插入。

(2)接着,每执行一个函数时,就会创建一个函数上下文(FunctionalContext),并插入栈顶。

(3)当一个函数执行完毕,该函数上下文就会从栈顶删除。

(4)往后代码执行就是 2、3 步骤周而复始的过程...

(5)请注意,在页面销毁之前,执行上下文栈栈底永远会保留着一个 GlobalContext。直至页面销毁(关闭页面或退出浏览器)才会被删除,自然执行上下文栈也消失了。

举个例子 🌰:

function baz() {
  console.log('baz')
}

function bar() {
  console.log('bar')
  baz()
}

function foo() {
  console.log('foo')
  bar()
}

foo()

然后代码执行过程,执行上下文栈变化如下:

// 请注意,如下是伪代码,栈跟数组是两种不同的结构。
// 假设用一个数组来模拟执行上下文栈,数组的第一项为栈底,最后一项为栈顶。
// 恰好数组的 push()、pop() 方法分别是在末尾添加一项、删除最后一项。刚好符合栈操作特点。

// 1. 代码执行之前,JS 引擎创建一个执行上下文栈(用 ECStack 表示)
ECStack = []

// 2. 执行全局代码,在 ECStack 插入全局上下文
ECStack.push({ <GlobalContext>: window })

// 3. 当调用函数 foo(),往 ECStack 插入 foo 函数上下文
ECStack.push({ <FunctionalContext>: foo })

// 4. 执行 foo() 函数,里面又会调用函数 bar(),又插入 bar 函数上下文
ECStack.push({ <FunctionalContext>: bar })

// 5. 执行 bar() 函数,里面又调用了函数 baz(),又插入 baz 函数上下文
ECStack.push({ <FunctionalContext>: baz })

// 6. 由于 baz 内没有调用其他函数了,当它执行完后,它的函数上下文会被从栈顶删除
ECStack.pop({ <FunctionalContext>: baz })

// 7. 同上
ECStack.pop({ <FunctionalContext>: bar })

// 8. 同上
ECStack.pop({ <FunctionalContext>: foo })

// 9. 当函数 foo 也执行完之后,全局上下文并不会被删除,ECStack 永远保留着全局上下文
ECStack = [{ <GlobalContext>: window }]

// 10. 直到将来某个时刻页面销毁,全局上下文从栈顶删除,ECStack 也会被 JS 引擎回收。

相信以上过程大家理解起来都应该很轻松。让大家困惑的可能是这些 ECStack 栈有什么用?不急,一步一步来...

三、执行上下文

前面提到,代码执行的时候会在执行上下文栈中插入执行上下文,那么这个执行上下文具体做了什么工作呢?

执行上下文,分为全局上下文和函数上下文两类。

我们可以将每个执行上下文,简单地理解为一个 JavaScript 对象,该对象有一系列的属性(称为上下文状态),主要有三个属性:

  • 变量对象(Variable Object,VO)
  • 作用域链(Scope Chain)
  • this

1. 变量对象

在变量对象上,存储了当前上下文中声明的变量或函数。全局上下文与函数上下文的变量对象,稍有不同。

全局上下文

全局上下文的变量对象,就是宿主环境的顶层对象 globalThis。比如,浏览器中为 window 对象,Node.js 中是 global 对象。

需要注意的是,一是全局上下文的变量对象不含 arguments 属性。二是通过 varfunction 关键字声明的变量或函数,都会成为 globalThis 对象的属性,而 letconst 则不会。

由于 letconst 全局下的特性,我对“全局的变量对象等于顶层对象”这句话有所保留。但我们只要知道,无论通过 varlet 还是 const 声明的变量,都包含在其变量对象中即可。

函数上下文

在函数上下文中,我们使用活动对象(Activation Object,AO)来表示变量对象。其实变量对象是无法通过代码来访问的,当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,也只有活动对象上的各种属性才能被访问。可以理解为 AO 就是 VO 的一个别名。

活动对象是在进入函数上下文的时候才会被创建的,它通过函数的 arguments 属性进行初始化,arguments 属性值是 Arguments 对象。

执行过程

执行上下文分成两个阶段进行处理:

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

当进入执行上下文时,这时还没执行代码。内部会创建一个活动对象,它包括:

AO = {
  1. 函数的所有形参(函数上下文):
    * 由形参名称和对应值组成的一个变量对象的属性被创建
    * 若没有实参,对应属性值设为 undefined

  2. 函数声明
    * 由函数名称和对应值组成一个变量对象的属性被创建
    * 若函数名称与形参名称重名,那么函数声明创建的属性将会覆盖形参所创建的属性

  3. 变量声明
    * 由变量名称与对应值(undefined)组成的一个变量对象的属性被创建
    * 若变量名称与形参名称或函数名称存在重名,那么此变量声明将会被忽略。
}

举个例子:

function foo(a) {
  var b = 2
  function c() { }
  var d = 3
}

foo(1)

进入 foo() 的执行上下文后,这时 AO 如下:

AO = {
  arguments: {
    0: 1,
    length: 1
  },
  a: 1,
  b: undefined,
  c: ƒ c(),
  d: undefined
}

“进入执行上下文”的过程完成之后,接着开始按顺序执行代码,并执行对应的操作。当函数 foo() 执行完之后,这时 AO 如下:

AO = {
  arguments: {
    0: 1,
    length: 1
  },
  a: 1,
  b: 2,
  c: ƒ c(),
  d: 3
}

请注意,在非严格模式下,arguments 会追踪变量的变化,而严格模式下则不会。例如:

function foo(a) {
  a = 2
}
foo(1)
// 当 foo 执行完之后,arguments 为 { 0: 2, length: 1 }
// 假设 foo 处于严格模式下,arguments 始终为 { 0: 1, length: 1 }

变量对象小结

  1. 进入全局上下文的变量对象是顶层对象。
  2. 进入函数上下文的变量对象(或活动对象)初始化只包括 Arguments 对象。
  3. 在进入执行上下文时,会给活动对象添加形参、函数声明、变量声明等初始的属性。
  4. 在代码执行阶段,会再次修改活动对象的属性值。

2. 作用域链

未完待续...