「JS-Learning」理解JS执行环境

本篇涉及到的名词:执行环境(执行上下文),变量环境(变量对象/活动对象),词法环境,执行栈(调用栈)等。

一、引言

  • 《JavaScript高级程序设计(第3版)》
    • 执行环境(execution context)定义了变量或函数有权访问的其他数据,决定了他们各自的行为。(P73)
    • 每个函数都有自己的执行环境。(P73)
    • 全局执行环境是最外围的一个执行环境。(P73)
  • 《ES5标准文档》
    • 执行环境包括:词法环境、变量环境、this绑定。其中执行环境的词法环境和变量环境组件始终为词法环境对象。当创建一个执行环境时,其词法环境组件和变量环境组件最初是同一个值。在该执行环境相关联的代码的执行过程中,变量环境组件永远不变,而词法环境组件有可能改变。(ES5标准文档
  • 《【译】理解 Javascript 执行上下文和执行栈》
    • 执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。(原文

二、执行环境是什么

我们知道 JavaScript 是单线程语言,也就是同一时间只能执行一个任务。当 JavaScript 解释器初始化代码后,默认会进入全局的执行环境,之后每调用一个函数, JavaScript 解释器会创建一个新的执行环境,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

执行环境的分类

  • 全局执行环境:简单的理解,一个程序只有一个全局对象即 window 对象,全局对象所处的执行环境就是全局执行环境。
  • 函数执行环境:函数调用过程会创建函数的执行环境,因此每个程序可以有无数个函数执行环境。
  • Eval执行环境:eval 代码特定的环境(永远不要使用 eval!—— MDN)。

一言以蔽之:执行环境是 JavaScript 执行一段代码时的运行环境

三、执行环境的产生

1. 执行环境是什么时候产生的

一段 JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段。大致流程可以参考下图:

JavaScript的执行流程图

JavaScript 的执行流程图

接下来看看一段代码经过编译后会生成什么。

首先输入下面的代码:

sayHello()
console.log(myName)
var myName = '文渊博客'
function sayHello() {
   console.log('欢迎访问我的博客');
}

经过编译后生成两部分:

第一部分:变量提升部分的代码。

var myName = undefined
function sayHello() {
    console.log('欢迎访问我的博客');
}

第二部分:执行部分的代码。

sayHello()
console.log(myName)
myName = '文渊博客'

下面我们就可以把 JavaScript 的执行流程细化,如下图所示:

JavaScript的执行流程图

JavaScript 执行流程细化图

从上图可以看出,输入一段代码,经过编译后,会生成两部分内容:执行环境(Execution context)可执行代码

2. 执行环境是如何创建的

上面是宏观介绍了执行环境是什么时候产生的:每次调用函数时,JavaScript 引擎都会创建一个新的执行环境;
接下来从微观角度简单描述下执行环境是如何创建的:答案是执行器会分为两个阶段来完成, 分别是创建阶段和激活(执行)阶段。而即使步骤相同但是由于规范的不同,每个阶段执行的过程有很大的不同。

ES3 规范

  • 创建阶段:
    ① 创建作用域链。
    ② 创建变量对象 VO(包括参数,函数,变量)。
    ③ 确定 this 的值。
  • 激活/执行阶段:
    完成变量分配,执行代码。

ES5 规范

  • 创建阶段:
    ① 确定 this 的值。
    ② 创建词法环境(LexicalEnvironment)。
    ③ 创建变量环境(VariableEnvironment)。
  • 激活/执行阶段:
    完成变量分配,执行代码。

我们从规范上可以知道,ES3 和 ES5 在执行环境的创建阶段存在差异,当然他们都会在这个阶段确定 this 的值。
但为了避免混淆,后面我们主要围绕 ES5 规范来讲解。

四、执行环境中的组成成分

ES5 标准规定,执行环境包括:词法环境、变量环境、this绑定。

  • 词法环境
  • 变量环境
  • this绑定

在《JavaScript高级程序设计(第3版)》(P73)中介绍执行环境及作用域时,多次提到了变量对象活动对象,而较新的材料里用的都是词法环境变量环境

我在阅读相关资料也产生了这个疑问,一番查阅得到的答案是:变量对象与活动对象的概念是 ES3 提出的老概念,从 ES5 开始就用词法环境和变量环境替代了。

ES3 的变量对象、活动对象为什么可以被抛弃?个人认为有两个原因,第一个是在创建过程中所执行的创建作用域链和创建变量对象(VO)都可以在创建词法环境的过程中完成。第二个是针对 ES6 中存储函数声明和变量(letconst)以及存储变量(var)的绑定,可以通过两个不同的过程(词法环境,变量环境)区分开来。

1. 词法环境(LexicalEnvironment)

词法环境由两个部分组成:

  • 环境记录(enviroment record),存储变量和函数声明
  • 对外部环境的引用(outer),可以通过它访问外部词法环境(作用域链)

环境记录分两部分

  • 声明性环境记录(declarative environment records):存储变量、函数和参数,但是主要用于函数 、catch 词法环境。
    注意:函数环境下会存储arguments的值。
  • 对象环境记录(object environment records),主要用于 with 和全局的词法环境。

伪代码如下:

// 全局环境
GlobalExectionContext = {  
// 词法环境
  LexicalEnvironment: {  
    EnvironmentRecord: {
        ···
    }
    outer: <null>  
  }  
}

// 函数环境
FunctionExectionContext = {  
// 词法环境
  LexicalEnvironment: {  
    EnvironmentRecord: {  
        ···
        // 包含argument
    }
    outer: <Global or outer function environment reference>  
  }  
}

2. 变量环境(ObjectEnvironment)

变量环境也是个词法环境,主要的区别在于 LexicalEnvironment 用于存储函数声明和变量(通过 letconst 声明的变量),而 ObjectEnviroment 仅用于存储变量(通过 var 声明的变量)

3. this 绑定

每个执行环境中都有一个 this,前面提到过执行环境主要分为三种 —— 全局执行环境、函数执行环境和 Eval执行环境,所以对应的 this 也只有这三种 —— 全局执行环境中的 this、函数中的 thiseval 中的 this

不过由于 eval 我们使用的不多,所以对此就不做介绍了。关于 this 可以参考《理解this关键字》这篇文章。

4. 伪代码展示

ES5 规范下的整个创建过程可以参考下方的伪代码:

let a = 20;  
const b = 30;  
var c;

function d(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = d(20, 30);
// 全局环境
GlobalExectionContext = {

  this: <Global Object>,
    // 词法环境
  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  // 环境记录分类: 对象环境记录
      a: < uninitialized >,  // 未初始化
      b: < uninitialized >,  
      d: < func >  
    }  
    outer: <null>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  // 环境记录分类: 对象环境记录
      c: undefined,  // undefined
    }  
    outer: <null>  
  }  
}
// 函数环境
FunctionExectionContext = {  

  this: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  // 环境记录分类: 声明环境记录
      Arguments: {0: 20, 1: 30, length: 2},  // 函数环境下,环境记录比全局环境下的环境记录多了argument对象
    },  
    outer: <GlobalLexicalEnvironment>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  // 环境记录分类: 声明环境记录
      g: undefined  
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}

五、执行栈:用来管理执行环境

函数多了,就有多个函数执行环境,每次调用函数创建一个新的执行环境,那如何管理创建的那么多执行环境呢?

执行栈(Execution Stack),也称为执行上下文栈(Execution Context Stack),在其他编程语言中也被叫做调用栈(Call Stack),在 ECMAScript 文档里是叫 Execution Context Stack,但本质上是同一个的东西的不同名称。

它是一种用来管理执行环境的数据结构,存储了在代码执行期间创建的所有执行环境。因为是栈,所以遵循 LIFO(后进先出)的原则。

当 JavaScript 引擎首次读取你的脚本时,它会创建一个全局执行环境并将其推入当前的执行栈底部。每当发生一个函数调用,引擎都会为该函数创建一个新的执行环境并将其推到当前执行栈的顶端。

引擎会运行执行环境在执行栈顶端的函数,当此函数运行完成后,其对应的执行环境将会从执行栈中弹出,上下文控制权将移到当前执行栈的下一个执行环境。

执行栈是有大小的,当入栈的执行环境超过一定数目,或达到最大调用深度,就会出现栈溢出(Stack Overflow)的问题,这在递归代码中很容易出现。

如下代码所示,会抛出错误信息:超过了最大栈调用大小(Maximum call stack size exceeded)。

function division(a,b){
    return division(a,b)
}
console.log(division(1,2))

栈溢出错误

栈溢出错误

那为什么会出现这个问题呢?这是因为当 JavaScript 引擎开始执行这段代码时,它首先调用函数 division,并创建执行环境,压入栈中;然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行环境,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

理解了栈溢出原因后,你就可以使用一些方法来避免或者解决栈溢出的问题,比如把递归调用的形式改造成其他形式,或者使用加入定时器的方法来把当前任务拆分为其他很多小任务。

六、总结

通过对 JavaScript 运行机制的学习,对一些 JavaScript 概念有了更深的认识,特别是对一些云里雾里的概念区别有了更深刻的理解。

只有理解了 JavaScrip 的执行环境,你才能更好地理解 JavaScript 语言本身,比如变量提升、作用域和闭包等。我相信这是理解 JavaScript 语言最重要的一步。


参考
《JavaScript高级程序设计(第3版)》
《JavaScript权威指南(第6版)》
《Understanding Execution Context and Execution Stack in Javascript》
ECMAScript Document
MDN


  目录