执行上下文和作用域链

1,245 阅读14分钟

在写本篇文章之前,本人阅读了一些文章和书籍(文末),每篇文章都写的合理且准确,但是在脑子中联系起来就很抽象,所以在此先根据自己的理解做一些流程讲解,有不足或不准确之处敬请指出

总体概述

此部分只为了先从大体上了解执行上下文大致的框架是怎样的,具体的名词细节下文讲解。

  1. 执行上下文是一个抽象的概念,当 js 引擎解析到可执行代码片段时,会做一些准备工作,这个准备工作就叫执行上下文
  2. 执行上下文类型分为三种:
    • 全局执行上下文 —— 文件第一次加载的默认执行上下文
    • 函数执行上下文 —— 函数调用生成的上下文,每次调用都会创建
    • eval 函数执行上下文 —— eval 函数内的执行上下文,不常用,所以暂不涉及
  3. 执行上下文栈是用来管理执行上下文的一个栈结构,遵循 LIFO(后进先出)特性,程序的执行流就是通过这个上下文栈进行控制的。
    • 文件第一次加载,则全局执行上下文入栈
    • 遇到函数调用,则函数执行上下文入栈
    • 函数调用完毕,函数执行上下文出栈
    • 文件执行完毕或关闭浏览器,全局执行上下文出栈

image.png

  1. 在这里涉及到执行上下文的生命周期,分为三个阶段
    1. 创建阶段
      a. 全局执行上下文:执行全局代码之前,创建一个全局的执行上下文
      b. 函数执行上下文:在调用函数,但并未执行函数体之前,创建对应的函数执行上下文
    2. 执行阶段 —— 逐条执行 JS 代码
    3. 销毁阶段
      a. 全局执行上下文:程序退出前(关闭网页或退出浏览器)
      b. 函数上下文:当前函数执行完毕,该函数上下文弹出栈

image.png

  1. 顺理成章,就想要知道执行上下文里到底是什么内容,在 ES3 和 ES5 中,对执行上下文内容的描述略有不同,但是这些概念大同小异。
    • ES3 中,执行上下文包括:变量对象(活动对象)作用域链this
    • ES5 中,执行上下文包括:this词法环境变量环境

image.png

  1. 接下来,就要对执行上下文的内容做一个详细的讲解了,下文的前半部分是对上述的一些简述概念的详解,后面还有完整的示例解析。

1 执行上下文

1.1 什么是执行上下文

执行上下文(Execution context 简称 EC)就是 js 的执行环境,它包括 this 的值、变量、对象和函数。它是一个抽象的概念。

变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。

例如,当执行到可执行代码片段(通常是函数调用)的时候,JS 引擎会做一些“准备工作”,而这个“准备工作”,就叫做 执行上下文

1.2 执行上下文的类型

Javascript 中有三种执行上下文类型:

  1. 全局执行上下文
    • Global execution context (GEC)
    • 是默认的执行上下文,文件第一次加载到浏览器中,js 代码在默认执行上下文中开始执行
    • 一个程序中只会存在一个全局上下文
    • 全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器
    • 全局上下文是 最外层的上下文,根据 ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一样:1. 浏览器中,全局上下文是 window 对象,因此通过 var 定义的全局变量和函数都会成为 window 对象的属性和方法;2. node 环境中,全局上下文是 global
  2. 函数执行上下文
    • Functional execution context (FEC)
    • 函数调用时,会创建一个函数执行上下文
    • 函数被重复调用,每次调用都会创建一个新的执行上下文
  3. Eval 函数执行上下文 ———— eval 函数内的执行上下文

2 执行上下文栈

执行上下文栈(Execution context stack 简称 ECS)是执行 js 代码时创建的执行栈结构,遵循 LIFO(后进先出)特性,用于管理在代码执行期间创建的执行上下文。

  • GEC(全局执行上下文)在栈最底层
  • 当 JS 引擎发现一处函数调用,会创建一个 FEC(函数执行上下文),并 push 进栈,将控制权交给该上下文
  • 在函数执行完之后,上下文栈弹出(pop)该 FEC(函数执行上下文),并将控制权返还给之前的执行上下文

例如:下面这段代码

var a = 10;

function funcA() {
    console.log("Start function A");
    
    function funcB(){
        console.log("In function B");
    }
    
    funcB();
}

funcA();
console.log("GlobalContext");
  1. 代码执行之前,JS 引擎将全局执行上下文推入执行上下文栈
  2. funcA 在全局上下文中被调用,funcA 的执行上下文入栈,并运行该函数
  3. 在 funcA 的上下文中,调用了 funcB 函数,funcB 的执行上下文入栈,并运行该函数
  4. funcB 函数中所有代码执行完毕,funcB 函数的上下文出栈,继续执行 funcA 函数
  5. funcA 函数中所有代码执行完毕,funcA 函数的上下文出栈,继续执行全局上下文中的代码
  6. 所有代码执行完毕,则全局上下文弹出

入栈和出栈示意图如下:

image.png

image.png

3 执行上下文的生命周期

执行上下文的生命周期有三个阶段,分别是

  • 创建阶段
  • 执行阶段
  • 销毁阶段

3.1 创建阶段

3.1.1 创建时机

  • 全局上下文的创建时机为 执行全局代码之前

  • 函数执行上下文的创建时机为,在 调用函数时,但还未开始执行函数体内的具体代码之前

3.1.2 创建内容

  • 全局上下文:对全局数据进行预处理,包括:变量初始化声明、函数初始化声明、this 赋值。这些行为都包含在全局上下文的 变量对象作用域链this 的值中。

  • 函数上下文:对局部数据进行预处理,不仅包括全局上下文中提到的 变量初始化声明、函数初始化声明、this 赋值,还有函数上下文独有的参数列表 arguments。这些行为都包含在函数上下文的 变量对象作用域链this 的值中。

3.2 执行阶段

  • 在这个阶段,JS 代码开始逐条执行
  • JS 引擎开始对定义的变量赋值(初始化时,由于变量提升,各变量的值均为 undefined),开始顺着作用域链访问变量
  • 如果内部有函数调用,就创建一个新的执行上下文压入执行栈,并把控制权交出

3.3 销毁阶段

  • 全局上下文:在应用程序退出前会被销毁,比如关闭网页或退出浏览器。

  • 函数上下文:一般来讲,当函数执行完毕之后,当前函数执行上下文就会被弹出执行上下文栈,并被销毁,控制权被重新交给之前的上下文。(但这只是一般情况,当有闭包的时候,销毁时机有所不同,此文中不探讨)

4 执行上下文的内容

执行上下文是一个抽象的概念,可以用一个对象的数据结构来模拟。

而 ES3 和 ES5 中的执行上下文中包括的内容略有不同,ES3 中的变量对象与活动对象的概念,在 ES5 之后由词法环境和变量环境来解释,两者概念不冲突,后者理解更为通俗易懂。

4.1 ES3 中执行上下文的内容

ES3 中的每一个执行上下文都有三个重要的属性:

  1. 变量对象 VO
  2. 作用域链
  3. this 的指向

4.1.1 创建变量对象

每个执行上下文都有一个表示变量的对象 ———— 变量对象(variable object 简称 VO),全局上下文的变量对象始终存在,而函数上下文只会在函数执行的过程中存在

  • 全局上下文:其中的变量对象就是全局对象,在浏览器中是 window 对象,在 node 环境中,是 global 对象
  • 函数上下文:其中的变量对象我们用 活动对象 AO(activation object) 来表示,二者的区别就是:活动对象就是变量对象,只不过处于不同的状态和阶段而已。函数没有被调用的时候,我们不能访问变量对象中的属性和方法,而当函数被调用,变量对象被激活为活动对象,这些属性和方法可以被访问。

变量对象包含的内容有

  • 根据当前函数的 参数列表(arguments)初始化一个 arguments 对象(全局上下文没有 arguments )
  • 根据函数声明生成对应的属性,其值为一个指向内存中函数的引用指针,如果函数名已存在,则覆盖
  • 根据变量声明生成对应的属性,由于变量声明提升,此时初始值为 undefined,需要等到执行阶段才会有确定的值。如果变量名已声明,则忽略该变量声明
示例
var c = 1;

function funA (a, b) {
  var c = 3;
  
  return a - b - c;
}

funA(7, 1);

调用 funcA执行 funcA 前 的这段时间(也就是创建阶段),JS 引擎为 funcA 创建了一个变量对象如下:

VO = {
  argumentObject : {
    0: a,
    1: b,
    length: 2
  },
  a: 7,
  b: 1
  c: undefined // 由于变量提升,此阶段 c 的值为 undefined
}
  1. argumentObject 代表入参,funcA 有两个入参,因此 length 属性值为 2。0 和 1 分别对应着两个入参 a 和 b
  2. a 和 b 两个入参的值已经确定,因此 a 初始化为 7,b 初始化为 1
  3. 函数中的变量 c 由于变量提升会被初始化为 undefined,可以用下面代码来解释:
var c;  // 变量声明提升
c = 1;

function funA (a, b) {
  var c; // 变量声明提升
  
  // 函数执行(函数上下文的执行阶段)才会给变量赋值
  c = 3;
  
  return a - b - c;
}

funA(7, 1);

4.1.2 创建作用域链

  • 由多个执行上下文的 变量对象 构成的链表叫做作用域链。
  • js 通过作用域及作用域链来进行变量查询。
  • 当查找变量时,首先从 当前上下文的变量对象 中查找,如果没有,就会从 父级(词法层面的父级)的执行上下文的变量对象 中查找,一直找到全局上下文的变量对象,也就是全局对象。

这里需要对几个概念进行讲解:

  1. 词法作用域
  2. 作用域链是如何构成的
4.1.2.1 词法作用域

JavaScript 采用词法作用域(lexical scoping),也就是静态作用域

其核心概念就是:函数的作用域在函数定义的时候就确定了,与函数在哪里调用或被谁调用都没有关系。

如下示例:

var a = 1;

function funA() {
    console.log(a);
}

function funB() {
    var a = 2;
    funA();
}

funB(); // 此时应该打印 1 还是 2 呢?
  • 由于 JS 采用词法作用域,所以 funA() 函数的作用域在定义的时候就已经确定了,是 全局作用域
  • 在执行 funA() 时,会查找打印变量 a
  • 虽然是在 funB() 中调用的 funA(),但是前面说过,与被谁调用无关。所以会先在 funA() 本身内部 查找是否存在变量 a,答:不存在。那么会去 funA() 的词法层面的父级作用域 查找,也就是 全局作用域 ,全局作用域中是否存在变量 a,答:存在,为 1,所以打印结果为 1
4.1.2.2 作用域链是如何构成的

作用域链是由作用域构成的链式结构。

用与上面同样的示例来讲解一下作用域链是如何构建的:

1    var a = 1;
2
3    function funA() {
4        console.log(a);
5    }
6
7    function funB() {
8        var a = 2;
9        funA();
10   }
11
12   funB(); // 此时应该打印 1 还是 2 呢?

在 funA() 函数声明(3 行)的时候,内部会绑定一个 [[scope]] 的内部属性:

funA.[[scope]] = [
    globalContext.VO
]

在调用了 funA() 但并未执行 funA() 中代码之前(9 行),会创建 funA() 的执行上下文,并入上下文栈。创建上下文时会创建作用域链,流程如下:

  • 复制函数的 [[scope]] 属性初始化作用域链
  • 创建变量对象
  • 将变量对象压入作用域链的最顶端

1. 初始化作用域链

// 初始化作用域链
funAContext = {
    scope: funA.[[scope]]
}

2. 创建变量对象

// 创建变量对象
funAContext = {
    scope: funA.[[scope]],
    VO = {
        arguments: {
            length: 0
        }
    }
}

3. 将变量对象压入作用域链的最顶端

// 将变量对象压入作用域链的最顶端
funAContext = {
    scope: [VO, funA.[[scope]]],
    VO = {
        arguments: {
            length: 0
        }
    }
}
4.1.2.3 示例
var c = 1;

function funA (a, b) {
  var c = 3;
  
  return a - b - c;
}

funA(7, 1);

image.png

结果输出 3

4.1.3 确定 this 的值

  • 全局执行上下文中,this 的值指向全局对象
  • 函数执行上下文中,this 的值取决于函数的调用方式,谁调用它 this 就指向谁(除非用 bind call applyAPI 进行委托调用,this 会指向通过 API 传入的对象)

4.2 用一个示例来演示 ES3 中执行上下文的创建流程

image.png

image.png

image.png

image.png

image.png

image.png

4.3 ES5 中执行上下文的内容

ES5 规范去除了 ES3 中变量对象和活动对象,以 词法环境组件( LexicalEnvironment component) 和 变量环境组件( VariableEnvironment component) 替代。

上下文的内容包含三部分:

  1. this 的指向
  2. 词法环境(LexicalEnvironment) 组件
  3. 变量环境(VariableEnvironment) 组件

伪代码大概如下:

ExecutionContext = {  
  ThisBinding = <this value>,     // 确定this 
  LexicalEnvironment = { ... },   // 词法环境
  VariableEnvironment = { ... },  // 变量环境
}

4.3.1 this 的指向

与 ES3 中的 this 的指向(绑定)没什么区别。

4.3.2 创建词法环境组件

词法环境有 两种类型

  • 全局环境:是一个没有外部环境的词法环境,外部环境引用为 null
  • 函数环境:用户在函数中定义的变量被存储在环境记录中,包含了 arguments 对象。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。

词法环境有 两个组成部分

  • 环境记录:存储变量和函数声明的实际位置
  • 对外部环境的引用:它指向作用域链的下一个对象,可以访问其父级词法环境(作用域),作用与 es3 的作用域链相似

伪代码如下:

// 词法环境类型一:全局环境
GlobalExectionContext = {         // 全局执行上下文
  LexicalEnvironment: {    	  // 词法环境
    EnvironmentRecord: {   	  // 词法环境组成部分一:环境记录
      Type: "Object",      	  // 全局环境
      // 标识符绑定在这里(变量等)
    }
    outer: <null>  	   	  // 词法环境组成部分二:对外部环境的引用
  }  
}

// 词法环境类型二:函数环境
FunctionExectionContext = {       // 函数执行上下文
  LexicalEnvironment: {  	  // 词法环境
    EnvironmentRecord: {  	  // 词法环境组成部分一:环境记录
      Type: "Declarative",  	  // 函数环境
      // 标识符绑定在这里(变量等)   
    }
    outer: <Global or outer function environment reference>  // 词法环境组成部分二:对外部环境的引用
  }  
}

4.3.3 创建变量环境组件

变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

在 ES6 中,词法环境与变量环境的区别在于:

  • 词法环境:存储函数声明和使用 let const 关键字绑定的变量,以此来实现函数级作用域
  • 变量环境:存储 var 声明的变量

image.png

参考文章