JavaScript 执行上下文和执行栈

Author: Guang

JavaScript作用域与声明提升一文中我们谈论了很多作用域与声明提升的表现行为,文中也提到了执行上下文的概念。JavaScript执行上下文是抽象的概念,它是JavaScript引擎解析JavaScript代码的一种结果,是代码运行时环境和状态的一种表述。执行上下文包含变量声明、函数声明、函数定义、调用参数(arguments),以及this绑定等信息。JavaScript是一门高级解释型语言(目前流行的V8 JavaScript引擎引入了即时编译,但JavaScript仍然被认为是解释型语言),在JavaScript引擎逐行解析并运行JavaScript代码的时候,JavaScript解释器创建并管理着这些JavaScript执行上下文。深入JavaScript执行栈理解JavaScript执行上下文,有助于我们理解作用域、声明提升、闭包等JavaScript概念,真正做到知其然知其所以然。

执行上下文类型

JavaScript执行上下文由JavaScript引擎决定,它是JavaScript引擎对JavaScript代码进行解析(编译)和运行的模型,主要目的是按照ECMAScript标准定义的行为去运行JavaScript程序。随着ECMAScript的发展,JavaScript执行上下文模型也不尽相同。

JavaScript中有三种执行上下文类型。它们分别是全局执行上下文(英文:Global Execution Context,缩写GEC)、函数执行上下文(英文:Functional Execution Context,缩写FEC)、Eval 函数执行上下文(Eval Function Execution Context)。

  1. 全局执行上下文。一个JavaScript程序中会创建也只会创建一个全局执行上下文,它是JavaScript代码执行的默认执行上下文,任何不在函数内部运行的代码都会运行在全局执行上下文中。在浏览器环境下,全局执行上下文创建一个window对象,如果是非严格模式,绑定全局执行上下文的thiswindow对象上,在Node.js中,全局上下文会创建一个全局对象global,并将全局执行上下文的thisglobal对象上。
  2. 函数执行上下文。当一个函数被调用的时候,JavaScript引擎会首先为这次函数调用创建执行上下文。一个函数的函数执行上下文可以有多次,取决于它被调用多少次。
  3. Eval函数执行上下文。当eval()被调用解析并执行JavaScript代码片段时,会创建单独的执行上下文。因为eval()函数的调用有严重的安全问题和性能问题,一般情况下开发人员不应该使用它,在此我们也不讨论Eval函数执行上下文。如果有执行用户输入或者其他不确定JavaScript代码片段的需求,请使用Function函数。

JavaScript执行上下文的生命周期是一样的,分为创建阶段、执行阶段、销毁阶段。全局执行上下文会贯穿整个JavaScript程序的声明周期,直到程序退出。在函数被调用的时候,JavaScript引擎会创建函数执行上下文,在当前执行上下文上执行函数逻辑,函数逻辑执行完毕会推退出当前执行上下文并销毁执行上下文。在介绍执行上下文包含哪些信息之前,思考一个问题:JavaScript引擎是怎么管理这些执行上下文的呢?答案就是执行上下文栈。

执行栈

执行栈,也称为调用栈。操作系统通过栈和堆来管理计算机程序内存分配和回收,运行计算机程序逻辑。栈是一种后进先出(LIFO)的数据结构,JavaScript程序运行时通过执行栈来管理所有执行上下文。

当JavaScript程序开始执行时,JavaScript引擎会首先创建一个全局执行上下文,并将全局执行上下文压入执行栈,所有代码逻辑均运行在全局执行上下文中。因为JavaScript时单线程编程语言,所以一个JavaScript程序只有一个执行栈,只有一个全局执行上下文。当程序遇到函数调用时,会创建函数执行上下文,并将函数执行上下文压入执行栈顶部,基于执行栈运行代码逻辑,当函数逻辑执行完毕后,JavaScript引擎将函数执行结果返回给上一层执行上下文,并销毁当前执行上下文,上一层执行上下文变成顶层执行上下文。让我们通过代码示例来理解。

var globalScope = 'global scope';

function First() {
    var functionScope1 = 'function scope';
    console.log('Inside first function');
    Second();
    console.log('Again inside first function');
}

function Second() {
    var functionScope2 = 'function scope';
    console.log('Inside second function');
}

First();
console.log('Inside Global Execution Context');

上述代码的执行栈及栈变化如下图所示。 执行栈变化

当代码开始在浏览器中执行时,JavaScript引擎创建了一个全局执行上下文并把它压入执行栈,全局执行上下文信息中包含globalScope变量声明、函数First()声明及定义、函数Second()声明及定义。

当遇到First()函数调用时,JavaScript引擎为First()函数创建函数执行上下文并将其压入执行栈顶部,First()函数执行上下文信息包含functionScope1变量声明。在执行First()函数逻辑时,程序遇到Second()函数调用,JavaScript引擎为Second()函数创建函数执行上下文并将其压入执行栈顶部,Second()函数执行上下文信息包含functionScope2变量声明。当Second()函数执行完毕后,JavaScript引擎会弹出并销毁Second()函数执行上下文,此时First()函数执行上下文处在执行栈顶部,继续First()函数逻辑的执行。

First()函数逻辑执行完毕后,JavaScript引擎会弹出并销毁First()函数执行上下文。此时全局执行上下文处在执行栈顶部,直到JavaScript程序退出(用户关闭网页或者网页崩溃)。

一般情况下,函数的执行上下文一旦进入执行上下文栈,便会被执行直到函数逻辑执行完成后销毁函数执行上下文,生成器函数是个例外。引擎为生成器函数创建的执行上下文是可暂停的执行上下文,生成器函数的执行上下文会在yield声明处暂停,执行上下文被移出执行栈,当生成器函数创建的迭代器方法next()被调用时,生成器函数的执行上下文重新压入执行栈,并从暂停处恢复运行。再遇到yield声明会重复上述暂停-恢复的流程,直至整个函数执行完毕,执行上下文才会被销毁。

由是,JavaScript引擎完成了一次完整的执行上下文管理周期。那么执行上下文上面的信息具体有哪些,又是如何创建的呢,这个和JavaScript引擎的实现有关,大体上可以分为ECMAScript 3版本、ECMAScript 5版本,和ECMAScript 2018版本。

JavaScript执行上下文模型

JavaScript执行上下文模型一直在发展变化,从ECMAScript 3到ECMAScript 5,再到ECMAScript 2018,《重学前端》的作者 winter在书中对JavaScript执行上下文整理如下。

执行上下文在 ES3 中,包含三个部分。

  • variable object(VO):变量对象,变量对象,用于存储被定义在执行上下文中的变量 (variables) 和函数声明 (function declarations) 。
  • scope:作用域,也常常被叫做作用域链,是一个对象列表 (list of objects) ,用以检索上下文代码中出现的标识符 (identifiers) 。
  • this value:this 值,也被称之为上下文对象。

在 ES5 中,我们改进了命名方式,把执行上下文最初的三个部分改成下面这个样子

  • lexical environment:词法环境,当获取变量时使用
  • variable environment:变量环境,当声明变量时使用
  • this value:this 值,也被称之为上下文对象。

在 ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical environment,但是增加了不少内容

  • lexical environment:词法环境,当获取变量或者 this 值时使用
  • variable environment:变量环境,当声明变量时使用
  • code evaluation state: 用于恢复代码执行位置
  • Function:执行的任务是函数时使用,表示正在被执行的函数
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码
  • Realm:使用的基础库和内置对象实力
  • Generator:仅生成器上下文有这个属性,表示当前生成器

JavaScript执行上下文模型在ES2018中较ES5中丰富了很多。ES5版本较ES3版本变化比较大,之所以ES5版本有这么些的变化,主要是为了ECMAScript 6标准的推出做准备。本文我们简单聊聊旧的(ES3中)JavaScript执行上下文模型,以及梗概版的较新的JavaScript执行上下文模型。

ES3 中的JavaScript执行上下文创建及执行

在ES3年代,还没有块级作用域,没有letconstclass声明,可以用下列伪代码来表示执行上下文对象。

const ExecutionContextObj = {
    VO: {}, // 变量对象
    scope: {}, // 作用域链
    this: window
};

JavaScript引擎创建执行上下文时会先后创建VO、scope,以及绑定this值,流程如下。

1. 创建变量对象(VO)

创建变量对象分为三步。分别是参数arguments对象初始化、扫描函数声明和定义、扫面变量声明。

  1. 参数arguments对象初始化。检查当前执行上下文中的参数,建立该对象下的属性与属性值。如果是全局执行上下文,则不会有arguments对象的初始化。
  2. 扫描函数声明和定义。检查当前执行上下文的函数声明,也就是使用function关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果该属性之前已经存在,那么该属性将会被新的引用所覆盖。
  3. 扫面变量声明。检查当前执行上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值为undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。 可以用下列伪代码来表示变量对象。
VO = {
    Arguments: {}, //实参
    Param_Variable: <具体值>, //形参
    Function: <function reference>, //函数的引用
    Variable: undefined //其他变量
};

当执行上下文进入执行阶段后,变量对象会变为活动对象(Active Object,AO)。此时原先声明的变量会被赋值。变量对象和活动对象都是指同一个对象,只是处于执行上下文的不同阶段。

由此,JavaScript引擎实现了var声明提升和function声明提升,function声明提升的优先级高于var声明且会被覆盖。

2. 创建作用域链(scope)

作用域链是在变量对象之后创建的,作用域链本身包含变量对象。作用域链用于解析变量。当被要求解析变量时,JavaScript始终从代码嵌套的最内层开始,如果最内层没有找到变量,就会跳转到上一层父作用域中查找,直到找到该变量时停止并返回,或者一直查找到全局作用域(顶层作用域)下还没找到,就会返回未定义报错(XX is not defined)。作用域链的最顶端一定是当前作用域(local scope)对应的变量对象,最底端一定是全局作用域对应的变量对象(全局VO)。 可以将下列伪代码理解为作用域对象。

Scope = [
    { //当前作用域对应的VO
        实参,
        形参,
        变量,
        函数
    },
    { //第二个作用域对应的VO
        实参,
        形参,
        变量,
        函数
    },
    ...
    { //全局作用域对应的VO
        变量,
        函数
    }
];

由此,JavaScript引擎实现了作用域的查找机制。

3. 绑定this对象

JavaScript中this对象的绑定是运行时决定的,它取决于函数是怎么调用的,每次函数调用this值都可能不同且不可在函数内部更改,我们可以在函数调用时使用apply()call()方法显示设置this值。后面我们会讨论this的动态性。浏览器环境下JavaScript引擎会绑定全局执行上下文的thiswindow对象上。

当执行上下文完成创建之后,就会开始执行代码逻辑,在执行阶段,会完成当前作用域下的变量赋值、函数调用、以及其他代码逻辑,直到退出当前执行上下文。然后进入执行上下文销毁阶段。

较新的JavaScript执行上下文创建及运行

ECMAScript 3到ECMAScript 5是一个比较大的变化,ECMAScript 2018较ECMAScript 5更多的是内容的丰富,并且调整了this值的使用。我们在此讨论简略版的JavaScript执行上下文创建,把注意力放到变量提升和作用域实现上。本小节内容主要来源于Sukhjinder Arora的博客:Understanding Execution Context and Execution Stack in JavaScript

较之于ECMAScript 3,新的的JavaScript引擎要满足ECMAScript 6标准需求,需要支持了let声明、const声明以及class声明。执行上下文对象变成了如下对象。

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

创建阶段

在创建执行上下文时,JavaScript引擎会做如下事情:

  1. 创建词法环境(Lexical Environment)组件。
  2. 创建变量环境(Variable Environment)组件。

词法环境(Lexical Environment)

官方的ES6文档把词法环境定义为

词法环境(Lexical Environments)是一种规范类型,用于根据ECMAScript代码的词法嵌套结构来定义标识符与特定变量和函数的关联。词法环境由一个环境记录(Environment Record)和一个可能为null的外部词法环境(outer Lexical Environment)引用组成。

简单来说,词法环境就是一种标识符—变量映射的结构(这里的标识符指的是变量/函数的名字,变量是对实际对象[包含函数和数组类型的对象]或基础数据类型的引用)。

举个例子,看看下面的代码:

var a = 20;
var b = 40;

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

上面代码的词法环境类似这样:

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

每一个词法环境由下面三部分组成:

  1. 环境记录;
  2. 外部环境引用;
  3. this绑定;
1. 环境记录(Environment Record)

环境记录是变量和函数声明在词法环境中存储的地方。有两种类型的环境记录:

  • 声明式环境记录(Declarative environment record):就像它名字所表明的那样,就是存储变量和函数声明的。函数代码的词法环境包含一个声明式的环境记录。
  • 对象环境记录(Object environment record):全局代码的词法环境包含一个对象环境记录。除了变量和函数声明之外,对象环境记录中还存储了全局绑定对象(浏览器中是windows对象)。所以,对于绑定对象的每个属性(property),都会在对象环境记录中创建一个新的条目(entry)(浏览器中,绑定对象会包含浏览器提供给windows对象的属性和方法)。

注意:对于函数代码而言,环境记录还包含一个参数对象,其中包括了传递给函数的参数和索引的映射,以及参数的长度。比如,下面这个函数的参数对象看起来像这样:

function foo(a, b) {
    var c = a + b;
}
foo(2, 3);

// argument object
Arguments: {0: 2, 1: 3, length: 2},
2. 外部词法环境的引用(outer)

外部词法环境的引用意味着可以通过它来访问外部的词法环境。这意味着如果有变量在当前的词法环境找不到的话,JavaScript 引擎可以在外部词法环境中继续寻找变量。根据这个机制,新引擎实现了和ES3行为一致的作用域查找机制。

3. this绑定

在词法环境组件中,this的值被确定或者设置。在全局执行上下文中,this的值指向全局对象(浏览器中,this指向windows对象)。在函数执行上下文中,this的值取决于函数是如何调用的。如果函数是被一个对象引用调用的,那么this会被设置成该对象,否则this的值会被设置成全局对象或者undefined(在 strict mode 下)。比如:

const person = {
    name: 'peter',
    birthYear: 1994,
    calcAge: function() {
        console.log((new Date()).getFullYear() - this.birthYear);
  }
}

person.calcAge();
// 'this' refers to 'person', because 'calcAge' was called with 'person' object reference
// 'this' 指的是 'person' 对象,因为 'calcAge' 是被 'person' 对象调用。

const calculateAge = person.calcAge;
calculateAge();
// 'this' refers to the global window object, because no object reference was given
// 'this' 指的是 global windows 对象,因为没有给出调用函数的对象。

抽象地讲,词法环境伪代码看起来是这样的:

GlobalExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Object",
            // Identifier bindings go here
            // 标识符在此绑定
        }
        outer: <null>,
        this: <global object>
    }
}

FunctionExecutionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative",
            // Identifier bindings go here
            // 标识符在此绑定
        }
        outer: <Global or outer function environment reference>,
        this: <depends on how function is called>
    }
}

变量环境(Variable Environment)

变量环境也是一个词法环境,它的环境记录持有变量声明语句在执行上下文中创建的绑定关系,它有着词法环境所有的属性和组件。在ES6中,词法环境和变量环境之间的一个区间是,词法环境用于存储函数声明和变量(letconst)绑定,变量环境只用于存储变量(var)的绑定。

执行阶段

在这个阶段,所有变量的赋值都会完成,代码最终被执行。让我们看一些代码示例来理解上面的概念:

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

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

c = multiply(20, 30);

当上面这段代码执行的时候,JavaScript引擎创建一个全局执行上下文来执行全局代码。所以全局执行上下文在创建阶段看起来是这样的::

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符在这里绑定
      a: < uninitialized >,
      b: < uninitialized >,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符在这里绑定
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

在执行阶段,将完成变量的赋值操作,因此在执行阶段全局执行上下文看起来会像下面这样:

GlobalExectionContext = {
  LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符在这里绑定
      a: 20,
      b: 30,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 标识符在这里绑定
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

当调用multiply(20, 30)时,会创建一个新的函数执行上下文来执行该函数代码。所以该函数执行上下文在创建阶段看起来是这样的:

FunctionExectionContext = {
	LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符在这里绑定
      Arguments: { 0: 20, 1: 30, length: 2 },
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
	VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
     	// 标识符在这里绑定
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

之后,函数执行上下文经历执行阶段,这意味着函数内部变量的赋值已经完成。所以该函数执行上下文在执行阶段看起来是这样的:

FunctionExectionContext = {
	LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符在这里绑定
      Arguments: { 0: 20, 1: 30, length: 2 },
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
	VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // 标识符在这里绑定
      g: 20
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

函数执行完成后,返回值存储在变量 c 中。所以全局执行上下文被更新。之后,全部代码执行完成,程序结束。

你可能已经注意到了,在创建阶段letconst变量为uninitialized,而var变量为undefined。这是因为在创建阶段时,引擎检查代码找出变量和函数声明,虽然函数声明完全存储在环境中,但是变量最初设置为undefinedvar情况下),或者未初始化(letconst情况下)。这就是为什么你可以在声明之前访问 var 定义的变量(虽然是undefined),但是在声明之前访问letconst的变量会得到一个引用错误。这就是我们说的变量声明提升

与ES3一样,当执行上下文完成创建之后,就会开始执行代码逻辑,执行完毕后进入销毁阶段。销毁阶段会进入JavaScript的垃圾回收流程,作为高级编程语言,JavaScript程序的垃圾回收是自动的,大部分时候不需要开发人员介入,但是当程序内存占用有问题,需要进行内存优化的时候,我们也需要对JavaScript垃圾回收机制有了解,后面我们专门讨论JavaScript垃圾回收机制。

行文至此,你已经对JavaScript程序内部是如何执行的有所了解。

发表留言

历史留言

--空--