logo

JavaScript 中的执行上下文和执行栈

Thu Apr 14 2022 Posted 2 years ago

其实这篇文章更像是个读书笔记,只不过掺杂了一点点个人理解。文章讨论的东西并不深入,但单纯用来理解概念也还够用。虽然实际开发中不会用到这些概念,但是可以为后面理解闭包等概念铺路。

执行上下文的概念 #

简而言之,执行上下文(Execution Context)是评估和执行 JavaScript 代码的环境的抽象概念。每当 JavaScript 代码在运行的时候,它都是在执行上下文中运行。它有三种类型:

  • 全局执行上下文:这是基础的上下文,任何不再函数内部的代码都在全局上下文中。它会执行两件事:① 创建一个全局的 Window 对象。② 设置 this 的值等于这个全局对象。一个程序只能有一个全局上下文。
  • 函数执行上下文:每当函数被调用时都会给这个函数创建一个新的上下文。每个函数都有自己的执行上下文,它会在函数被调用时创建而不是被定义时创建。
  • Eval 函数执行上下文:执行在 eval 函数内部的代码也会有属于自己的执行上下文。

执行栈的概念 #

执行栈(Execution Stack),在其它编程语言中也会被称为“调用栈”,是一种拥有 LIFO(Last In First Out)数据结构的栈,被用来存储代码运行时创建的所有执行上下文

在 JS 引擎执行代码时,它会首先创建一个全局执行上下文并压入执行栈。每当调用一个函数时,它就会为该函数创建一个新的执行上下文并压入栈底。

JS 引擎会执行那些上下文位于栈顶的函数,当该函数执行完毕时,执行上下文会从栈中弹出,然后会再次重复上述动作,直到所有函数执行完毕。

const a = 'Hello World!'

function first() {
  console.log('Inside first function')
  second()
  console.log('Again inside first function')
}

function second() {
  console.log('Inside second function')
}

first()
console.log('Inside Global Execution Context')

image-20220414155143708

执行上下文是怎么被创建的? #

创建执行上下文有两个阶段:① 创建阶段执行阶段

创建阶段 #

执行上下文在创建阶段会发生两件事:

  • 词法环境组件被创建
  • 变量环境组件被创建

在概念上可以表现为以下代码:

ExecutionContext = {
  LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
  VariableEnvironment = <ref. to VariableEnvironment in  memory>,
}

词法环境 #

ES6 的文档中把词法环境定义为:

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。

简单来讲词法环境是一种持有标识符——变量映射的结构。标识符就是指变量/函数的名字,而变量则是对实际对象(也包括函数和数组)或原始数据的引用。

const a = 20
const b = 40
function foo() {
  console.log('bar')
}

上面的代码的词法环境大概长这样:

LexicalEnvironment = {
  a: 20,
  b: 40,
  foo: <ref. to foo function>
}

每个词法环境有三个组件:

  • 环境记录器
  • 外部环境的引用
  • this 绑定

环境记录器 #

环境记录器是词法环境内部存储变量和函数声明的地方。

环境记录器具有两种类型:

  • 声明式环境记录器——函数的词法环境会包含一个声明式环境记录器。用来存储变量和函数声明。
  • 对象环境记录器——全局代码的词法环境会包含一个对象环境记录器。除了变量和函数声明,对象环境记录器还会存储一个全局绑定对象(在浏览器中也就是 Window 对象)。所以对于每个绑定对象的属性(如果是在浏览器中,会包含一些浏览器提供的属性和方法,如BOM等)都会在记录器中创建一个新的条目(原文是 entry,不知道该怎么翻译)。

注意:对于函数代码,环境记录器还会包含一个参数(arguments)对象,它包含了传递给该函数的“索引--参数映射”以及参数的长度。例如下面的函数的参数对象大概长这样:

function foo(a, b) {
  var c = a + b;
}
foo(2, 3);
// argument object
Arguments: {0: 2, 1: 3, length: 2},

简而言之,

  • 全局环境中,环境记录器是对象环境记录器。
  • 函数环境中,环境记录器是声明式环境记录器。

外部环境的引用 #

外部环境的引用意味着一个词法环境可以访问到它外部的词法环境。也就是说如果 JS 引擎在当前的词法环境中无法找到一个变量,那么它可以去外部的词法环境中继续查找。我个人的理解是,这其实和作用域链很像,如果在当前作用域找不到一个变量的定义,那么会一层一层地向上查找,直到全局作用域。

this 绑定 #

这个组件就是用来进行 this 绑定的。

如果是全局执行上下文的话, this 会指向全局对象。(在浏览器中也就是 Window 对象)

如果是函数执行上下文的话,this 的值会取决于函数是如何被调用的(此处不包含箭头函数,箭头函数的 this 指向取决于函数是在哪里被定义的)。如果它是被一个引用对象调用,那么 this 会被设置为那个对象,否则 this 的值会被设置为全局对象。(严格模式下则是 undefined)以下面代码为例:

const person = {
  name: 'peter',
  birthYear: 1994,
  calcAge() {
    console.log(2018 - this.birthYear)
  }
}
person.calcAge()
// 'this' refers to 'person', because 'calcAge' was called with 'person' object reference
const calculateAge = person.calcAge
calculateAge()
// 'this' refers to the global window object, because no object reference was given

抽象地讲,词法环境的伪代码大概长这样:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
    }
    outer: <null>,
    this: <global object>
  }
}
FunctionExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
    }
    outer: <Global or outer function environment reference>,
    this: <depends on how function is called>
  }
}

变量环境 #

变量环境其实也是一个词法环境,它的环境记录器持有变量声明语句在执行上下文中创建的绑定关系。

所以变量环境有着词法环境的所有属性和组件。

在 ES6 中,词法环境和变量环境的唯一区别就是:词法环境会存储所有由 letconst 定义的变量绑定和函数声明。而变量环境只存储由 var 定义的变量绑定。

执行阶段 #

在执行阶段,完成对所有这些变量的分配后并最终执行代码。

例子 #

const a = 20
const b = 30
let c
function multiply(e, f) {
  const g = 20
  return e * f * g
}
c = multiply(20, 30)

当上面的代码被执行时,JS 引擎会创建一个全局执行上下文来执行全局代码。在创建阶段时全局执行上下文大概长这样:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

在执行阶段时全局执行上下文大概长这样:

GlobalExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      a: 20,
      b: 30,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

当代码执行到 multiply(20, 30) 时,JS 引擎会创建一个新的函数执行上下文来执行该函数。在创建阶段这个函数执行上下文大概长这样:

FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

完成创建阶段之后,函数执行上下文进入执行阶段,也就是说对函数内部的变量的赋值操作已经完成,所以在执行阶段该函数执行上下文大概长这样:

FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      g: 20
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

在这个函数执行完之后,函数的返回值被存储到了 c 中,对应地全局词法环境中的 c 的值也被更新了。至此,全局代码被全部运行,这段程序也就结束了。

**注意:**在上面的词法环境伪代码中,letconst 定义的变量在创建阶段都是 < uninitialized > ,而 var 定义的变量则被赋予了 undefined。这是因为在创建阶段,JS 引擎会检查代码中的变量和函数的声明。函数声明会被完整地存储在环境中,而变量则会被设置为 undefined (使用 var 定义)或者保持未初始化状态(使用 letconst 定义)。

这也就是为什么我们可以在声明之前访问到 var 定义的变量,但当我们尝试在声明之前访问 letconst 变量时会得到一个 ReferenceError

参考文章 #

[译] 理解 JavaScript 中的执行上下文和执行栈 以及它的原文 Understanding Execution Context and Execution Stack in Javascript