执行上下文

当 JS 引擎处理一段脚本内容的时候,它是以怎样的顺序解析和执行的?脚本中的那些变量是何时被定义的?它们之间错综复杂的访问关系又是怎样创建和链接的?要解释这些问题,就必须了解 JS 执行上下文的概念。

什么是执行上下文

JS 引擎解析到可执行代码片段(通常是函数调用阶段)的时候,就会先做一些执行前的准备工作,这个 “准备工作”,就叫做 "执行上下文(execution context 简称 EC)" 或者也可以叫做执行环境

执行上下文 为我们的可执行代码块提供了执行前的必要准备工作,例如变量对象的定义、作用域链的扩展、提供调用者的对象引用等信息。

ES3 执行上下文的类型

javascript 中有三种执行上下文类型,分别是:

  • 全局执行上下文——这是默认或者说是最基础的执行上下文,一个程序中只会存在一个全局上下文,

    它在整个 javascript 脚本的生命周期内都会存在于执行堆栈的最底部不会被栈弹出销毁。全局上下文会生成一个全局对象(以浏览器环境为例,这个全局对象是 window),并且将 this 值绑定到这个全局对象上。

  • 函数执行上下文——每当一个函数被调用时,都会创建一个新的函数执行上下文(不管这个函数是不是被重复调用的)

  • Eval 函数执行上下文—— 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但由于并不经常使用 eval,所以在这里不做分析。

ES3 执行上下文的内容

执行上下文是一个抽象的概念,我们可以将它理解为一个 object ,一个执行上下文里包括以下内容:

  1. 变量对象

  2. 活动对象

  3. 作用域链

  4. 调用者信息

变量对象(variable object 简称 VO

原文:Every execution context has associated with it a variable object. Variables and functions declared in the source text are added as properties of the variable object. For function code, parameters are added as properties of the variable object.

每个执行环境文都有一个表示变量的对象——变量对象,全局执行环境的变量对象始终存在,而函数这样局部环境的变量,只会在函数执行的过程中存在,在函数被调用时且在具体的函数代码运行之前,JS 引擎会用当前函数的参数列表arguments)初始化一个 “变量对象” 并将当前执行上下文与之关联 ,函数代码块中声明的 变量 函数 将作为属性添加到这个变量对象上。

有一点需要注意,只有函数声明function declaration)会被加入到变量对象中,而函数表达式function expression)会被忽略。

// 这种叫做函数声明,会被加入变量对象
function a () {}

// b 是变量声明,也会被加入变量对象,但是作为一个函数表达式 _b 不会被加入变量对象
var b = function _b () {}

全局执行上下文和函数执行上下文中的变量对象还略有不同,它们之间的差别简单来说:

  1. 全局上下文中的变量对象就是全局对象,以浏览器环境来说,就是 window 对象。

  2. 函数执行上下文中的变量对象内部定义的属性,是不能被直接访问的,只有当函数被调用时,变量对象(VO)被激活为活动对象(AO)时,我们才能访问到其中的属性和方法。

活动对象(activation object 简称 AO

原文:When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below. The activation object is then used as the variable object for the purposes of variable instantiation.

函数进入执行阶段时,原本不能访问的变量对象被激活成为一个活动对象,自此,我们可以访问到其中的各种属性。

其实变量对象和活动对象是一个东西,只不过处于不同的状态和阶段而已。

作用域链(scope chain

作用域 规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做 作用域链

函数的作用域在函数创建时就已经确定了。当函数创建时,会有一个名为 [[scope]] 的内部属性保存所有父变量对象到其中。当函数执行时,会创建一个执行环境,然后通过复制函数的 [[scope]] 属性中的对象构建起执行环境的作用域链,然后,变量对象 VO 被激活生成 AO 并添加到作用域链的前端,完整作用域链创建完成:

Scope = [AO].concat([[Scope]]);

当前可执行代码块的调用者(this)

如果当前函数被作为对象方法调用或使用 bind call applyAPI 进行委托调用,则将当前代码块的调用者信息(this value)存入当前执行上下文,否则默认为全局对象调用。

关于 this 的创建细节,有点烦,有兴趣的话可以进入 传送门 学习。

执行上下文数据结构模拟

如果将上述一个完整的执行上下文使用代码形式表现出来的话,应该类似于下面这种:

executionContext:{
    [variable object | activation object]:{
        arguments,
        variables: [...],
        funcions: [...]
    },
    scope chain: variable object + all parents scopes
    thisValue: context object
}

ES3 执行上下文的生命周期

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

  • 创建阶段

  • 执行阶段

  • 销毁阶段

创建阶段

函数执行上下文的创建阶段,发生在函数调用时且在执行函数体内的具体代码之前,在创建阶段,JS 引擎会做如下操作:

  • 用当前函数的参数列表arguments)初始化一个 “变量对象” 并将当前执行上下文与之关联 ,函数代码块中声明的 变量 函数 将作为属性添加到这个变量对象上。在这一阶段,会进行变量和函数的初始化声明,变量统一定义为 undefined 需要等到赋值时才会有确值,而函数则会直接定义

    有没有发现这段加粗的描述非常熟悉?没错,这个操作就是 变量声明提升(变量和函数声明都会提升,但是函数提升更靠前)。

  • 构建作用域链(前面已经说过构建细节)

  • 确定 this 的值

执行阶段

执行阶段中,JS 代码开始逐条执行,在这个阶段,JS 引擎开始对定义的变量赋值、开始顺着作用域链访问变量、如果内部有函数调用就创建一个新的执行上下文压入执行栈并把控制权交出……

销毁阶段

一般来讲当函数执行完成后,当前执行上下文(局部环境)会被弹出执行上下文栈并且销毁,控制权被重新交给执行栈上一层的执行上下文。

注意这只是一般情况,闭包的情况又有所不同。

闭包的定义:有权访问另一个函数内部变量的函数。简单说来,如果一个函数被作为另一个函数的返回值,并在外部被引用,那么这个函数就被称为闭包。

function funcFactory () {
    var a = 1;
    return function () {
        alert(a);
    }
}

// 闭包
var sayA = funcFactory();
sayA();

当闭包的父包裹函数执行完成后,父函数本身执行环境的作用域链会被销毁,但是由于闭包的作用域链仍然在引用父函数的变量对象,导致了父函数的变量对象会一直驻存于内存,无法销毁,除非闭包的引用被销毁,闭包不再引用父函数的变量对象,这块内存才能被释放掉。过度使用闭包会造成 内存泄露 的问题,这块等到闭包章节再做详细分析。

ES3 执行上下文总结

对于 ES3 中的执行上下文,我们可以用下面这个列表来概括程序执行的整个过程:

  1. 函数被调用

  2. 在执行具体的函数代码之前,创建了执行上下文

  3. 进入执行上下文的创建阶段:

    1. 初始化作用域链

    2. 创建 arguments object 检查上下文中的参数,初始化名称和值并创建引用副本

    3. 扫描上下文找到所有函数声明:

      1. 对于每个找到的函数,用它们的原生函数名,在变量对象中创建一个属性,该属性里存放的是一个指向实际内存地址的指针

      2. 如果函数名称已经存在了,属性的引用指针将会被覆盖

    4. 扫描上下文找到所有 var 的变量声明:

      1. 对于每个找到的变量声明,用它们的原生变量名,在变量对象中创建一个属性,并且使用 undefined 来初始化

      2. 如果变量名作为属性在变量对象中已存在,则不做任何处理并接着扫描

    5. 确定 this

  4. 进入执行上下文的执行阶段:

    1. 在上下文中运行/解释函数代码,并在代码逐行执行时分配变量值。

ES5 中的执行上下文

ES5 规范又对 ES3 中执行上下文的部分概念做了调整,最主要的调整,就是去除了 ES3 中变量对象和活动对象,以 词法环境组件( LexicalEnvironment component变量环境组件( VariableEnvironment component替代。所以 ES5 的执行上下文概念上表示大概如下:

ExecutionContext = {
  ThisBinding = <this value>,
  LexicalEnvironment = { ... },
  VariableEnvironment = { ... },
}

ES5 中的词法环境

ES6 官方 中的词法环境定义:

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

简单来说 词法环境 是一种持有 标识符—变量映射 的结构。这里的 标识符 指的是变量/函数的名字,而 变量 是对实际对象(包含函数类型对象)或原始数据的引用。

这块看不懂没关系,你可以把它理解为 ES3 中的 变量对象,因为它们本质上做的是类似的事情,这里只是先把官方给出的定义放上来。这块概念比较烦:词法环境还分为两种,然后内部有个环境记录器还分两种,,这些概念在后面会用列表的形式归纳整理出来详细说明。

ES5 中的变量环境

变量环境 它也是一个 词法环境 ,所以它有着词法环境的所有特性。

之所以在 ES5 的规范力要单独分出一个变量环境的概念是为 ES6 服务的: 在 ES6 中,词法环境组件和 变量环境 的一个不同就是前者被用来存储函数声明和变量(letconst)绑定,而后者只用来存储 var 变量绑定。

在上下文创建阶段,引擎检查代码找出变量和函数声明,变量最初会设置为 undefinedvar 情况下),或者未初始化(letconst 情况下)。这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 letconst 的变量会得到一个引用错误。

ES5 执行上下文总结

对于 ES5 中的执行上下文,我们可以用下面这个列表来概括程序执行的整个过程:

  1. 程序启动,全局上下文被创建

    1. 创建全局上下文的 词法环境

      1. 创建 对象环境记录器 ,它用来定义出现在 全局上下文 中的变量和函数的关系(负责处理 letconst 定义的变量)

      2. 创建 外部环境引用,值为 null

    2. 创建全局上下文的 变量环境

      1. 创建 对象环境记录器,它持有 变量声明语句 在执行上下文中创建的绑定关系(负责处理 var 定义的变量,初始值为 undefined 造成声明提升)

      2. 创建 外部环境引用,值为 null

    3. 确定 this 值为全局对象(以浏览器为例,就是 window

  2. 函数被调用,函数上下文被创建

    1. 创建函数上下文的 词法环境

      1. 创建 声明式环境记录器 ,存储变量、函数和参数,它包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。(负责处理 letconst 定义的变量)

      2. 创建 外部环境引用,值为全局对象,或者为父级词法环境(作用域)

    2. 创建函数上下文的 变量环境

      1. 创建 声明式环境记录器 ,存储变量、函数和参数,它包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。(负责处理 var 定义的变量,初始值为 undefined 造成声明提升)

      2. 创建 外部环境引用,值为全局对象,或者为父级词法环境(作用域)

    3. 确定 this

  3. 进入函数执行上下文的执行阶段:

    1. 在上下文中运行/解释函数代码,并在代码逐行执行时分配变量值。

关于 ES5 中执行上下文的变更,个人感觉就是变了个概念,本质和 ES3 差别并不大。至于变更的目的,应该是为了 ES6 中的 letconst 服务的。

执行上下文栈

当一段脚本运行起来的时候,可能会调用很多函数并产生很多函数执行上下文,那么问题来了,这些执行上下文该怎么管理呢?为了解决这个问题,javascript 引擎就创建了 “执行上下文栈” (Execution context stack 简称 ECS)来管理执行上下文。

顾名思义,执行上下文栈是栈结构的,因此遵循 LIFO(后进先出)的特性,代码执行期间创建的所有执行上下文,都会交给执行上下文栈进行管理。

当 JS 引擎开始解析脚本代码时,会首先创建一个全局执行上下文,压入栈底(这个全局执行上下文从创建一直到程序销毁,都会存在于栈的底部)。

每当引擎发现一处函数调用,就会创建一个新的函数执行上下文压入栈内,并将控制权交给该上下文,待函数执行完成后,即将该执行上下文从栈内弹出销毁,将控制权重新给到栈内上一个执行上下文。

递归和栈溢出

在了解了调用栈的运行机制后,我们可以考虑一个问题,这个执行上下文栈可以被无限压栈吗?很显然是不行的,执行栈本身也是有容量限制的,当执行栈内部的执行上下文对象积压到一定程度如果继续积压,就会报 “栈溢出(stack overflow)” 的错误。栈溢出错误经常会发生在 递归 中。

程序调用自身的编程技巧称为递归( recursion

递归的使用场景,通常是在运行次数未知的情况下,程序会设定一个限定条件,除非达到该限定条件否则程序将一直调用自身运行下去。递归的适用场景非常广泛,比如累加函数:

// 求 1~num 的累加,此时 num 由外部传入,是未知的
function recursion (num) {
    if (num === 0) return num;
    return recursion(num - 1) + num;
}

recursion(100) // => 5050
recursion(1000) // => 500500
recursion(10000) // => 50005000
recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded

从代码中可以看到,这个递归的累加函数,在计算 1 ~ 100000 的累加和的时候,执行栈就崩不住了,触发了栈溢出的错误。

尾递归优化

针对递归存在的 “爆栈” 问题,我们可以学习一下 尾递归优化。“递归” 我们已经了解了,那么 “尾” 是什么意思呢?“尾” 的意思是 “尾调用(Tail Call)”,即函数的最后一步是返回一个函数的运行结果:

// 尾调用正确示范1
function a(x){
  return b(x);
}

// 尾调用正确示范2
// 尾调用不一定要写在函数的最后为止,只要保证执行时是最后一部操作就行了。
function c(x) {
  if (x > 0) {
    return d(x);
  }
  return e(x);
}

尾调用之所以与其他调用不同,就在于它的特殊的调用位置。尾调用由于是函数的最后一步操作,所以不需要保留外层函数的相关信息,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以了,这样一来,运行尾递归函数时,执行栈永远只会新增一个上下文。

我们可以使用尾调用的方式改写下上面的累加递归:

// 尾递归优化
function recursion (num, sum = 0) {
    if (num === 0) return sum;
    return recursion(num - 1, sum + num);
}

recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded

运行之后怎么还是报错了 😳 ??裂开了呀。。。

其实,尾递归优化这种东西,现在没有任何一个浏览器是支持的(据说 Safari 13 是支持的),babel 编译也不支持。那 nodejs 里的 V8 引擎呢?它做好了,但是不给你用,官方回答如下:

Proper tail calls have been implemented but not yet shipped given that a change to the feature is currently under discussion at TC39.

理由呢,它也很有道理:

  • 有可能由于开发者水平原因,自己写的尾递归是死循环,然后引擎层面它优化掉了,又不报错,就裂开。

  • 堆栈信息会在优化的过程中丢失,开发者调试非常困难,就很烦。

总之,尾递归优化这个东西暂时还是不要想用到了,不过先了解个概念也是好的。

小试牛刀

在网上找了几条执行上下文比较典型的面试题,大家可以试一试:

第一题:

var foo = function () {
    console.log('foo1');
}

foo();

var foo = function () {
    console.log('foo2');
}

foo();

第一题没什么,应该能写出来。

第二题:

foo();

var foo = function foo() {
    console.log('foo1');
}

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

foo();

全局执行环境自动创建,过程中生成了变量对象进行函数变量的属性收集,造成了函数声明提升、变量声明提升。由于函数声明提升更加靠前,且如果 var 定义变量的时候发现已有同名函数定义则跳过变量定义,上面的代码其实可以写成下面这样:

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

foo();

foo = function foo() {
    console.log('foo1');
};

foo();

第三题:

var foo = 1;
function bar () {
    console.log(foo);
    var foo = 10;
    console.log(foo);
}

bar();

bar 函数运行,内部变量申明提升,当执行代码块中有访问变量时,先查找本地作用域,找到了 fooundefined ,打印出来。然后 foo 被赋值为 10 ,打印出 10

第四题:

var foo = 1;
function bar () {
    console.log(foo);
    foo = 2;
}
bar();
console.log(foo);

这题也是考察的作用域链查找,bar 里操作的 foo 本地没有定义,所以应该是上层作用域的变量。

第五题:

var foo = 1;
function bar (foo) {
    console.log(foo);
    foo = 234;
}
bar(123);
console.log(foo);

运行 bar 函数的时候将 123 数字作为实参传入,所以操作的还是本地作用域的 foo

第六题:

var a = 1;

function foo () {
    var a = 2;
    return function () {
        console.log(a);
    }
}

var bar = foo();
bar();

这道题目主要考察闭包和函数作用域的概念,我们只要记住:函数能够访问到的上层作用域,是在函数声明时候就已经确定了的,函数声明在哪里,上层作用域就在哪里,和拿到哪里执行没有关系。这道题目中,匿名函数被作为闭包返回并在外部调用,但它内部的作用域链引用到了父函数的变量对象中的 a ,所以作用域链查找时,打印出来的是 2

第七题:

var a = 1;

function foo () {
    var a = 2;
    return function () {
        console.log(this.a);
    }
}

var bar = foo().bind(this);
bar();

这题考察的是执行环境中的 this 指向的问题,由于闭包内明确指定访问 this 中的 a 属性,并且闭包被 bind 绑定在全局环境下运行,所以打印出的是全局对象中的 a

总结

  • 当函数运行的时候,会生成一个叫做 “执行上下文” 的东西,也可以叫做执行环境,它用于保存函数运行时需要的一些信息。

  • 所有的执行上下文都会被交给系统的 “执行上下文栈” 来管理,它是一个栈结构数据,全局上下文永远在该栈的最底部,每当一个函数执行生成了新的上下文,该上下文对象就会被压入栈,但是上下文栈有容量限制,如果超出容量就会栈溢出。

  • 执行上下文内部存储了包括:变量对象作用域链this 指向 这些函数运行时的必须数据。

  • 变量对象构建的过程中会触发变量和函数的声明提升。

  • 函数内部代码执行时,会先访问本地的变量对象去尝试获取变量,找不到的话就会攀爬作用域链层层寻找,找到目标变量则返回,找不到则 undefined

  • 一个函数能够访问到的上层作用域,在函数创建的时候就已经被确定且保存在函数的 [[scope]] 属性里,和函数拿到哪里去执行没有关系。

  • 一个函数调用时的 this 指向,取决于它的调用者,通常有以下几种方式可以改变函数的 this 值:对象调用、callbindapply

相关参考

最后更新于