执行上下文栈

78 阅读7分钟

执行上下文

在说到js执行上下文前先看一个例子

function f1() {
    console.log('听雨是雨');
};
f1();  // echo

function f1() {
    console.log('echo');
};
f1();  // echo

换种写法,输出结果又会改变

var f1 = function () {
    console.log('听雨是雨');
};
f1();  // 听雨是雨

var f1 = function () {
    console.log('echo');
};
f1();  // echo

以上例子说明代码在执行前发生了某些微妙的变化,JS引擎究竟做了啥呢?这就不得不提JS执行上下文的了。

执行上下文就是当前JavaScript代码被解析和执行时所在环境的抽象概念,JavaScript中运行任何的代码都是在执行上下文中运行。
执行上下文总共有三种类型:全局执行上下文,函数执行上下文,Eval函数执行上下文(一般不会使用)。

全局执行上下文

全局执行上下文只有一个,在客户端中一般由浏览器创建,也就是我们熟知的window对象,我们能通过this直接访问它。
全局对象window上预定义了大量的方法和属性,我们在全局环境的任意处都能直接访问这些属性方法,同时window对象还是var声明的全局变量的载体。我们通过var创建的全局变量,都可以通过window对象来获取。(通过let 和 const声明的变量不可以)

var a = 1;
console.log(window.a);  // 1

let b = 3;
console.log(window.b);  // undefined

函数执行上下文

函数执行上下文存在无数个,每当一个函数被调用的时候都会创建一个函数上下文;需要注意的是,同一个函数被多次调用,都会创建一个新的上下文。

执行上下文栈

执行栈,在其他编程语言中也被叫做调用栈,具有先进后出结构,用于存储在代码执行期间创建的所有执行上下文。当同一函数被多次调用,就是通过执行上下文栈来管理的。

JS代码首次运行,都会先创建一个全局执行上下文并压入到执行栈,之后每当有函数被调用,都会创建一个新的函数执行栈上下文并压入栈内;由于执行栈LIFO(last in first out后进先出)的特性,所以可以理解为,JS代码执行完毕前在执行栈底部永远有个全局执行上下文。

function f1() {
    f2();
    console.log(1);
};
function f2() {
    f3();
    console.log(2);
};
function f3() {
    console.log(3);
};
f1();   // 3 2 1

通过执行栈与上下文的关系来解释上述代码的执行过程,为了方便理解,我们假想执行栈是一个数组,在代码执行初期一定会创建全局执行上下文并压入栈。

//代码执行前创建全局执行上下文
ECStack = [globalContext];
// f1调用
ECStack.push('f1 functionContext');
// f1又调用了f2,f2执行完毕之前无法console 1
ECStack.push('f2 functionContext');
// f2又调用了f3,f3执行完毕之前无法console 2
ECStack.push('f3 functionContext');
// f3执行完毕,输出3并出栈
ECStack.pop();
// f2执行完毕,输出2并出栈
ECStack.pop();
// f1执行完毕,输出1并出栈
ECStack.pop();
// 此时执行栈中只剩下一个全局执行上下文

执行上下文创建阶段

执行上下文创建分为创建阶段和执行阶段,较为难理解的是创建阶段,我们先说创建阶段。
JS执行上下文的创建阶段主要负责三件事:确定this---创建词法环境组件(LexicalEnvironment)---创建变量环境组件(VariableEnvironment)
创建过程:

ExecutionContext = {
    // 确定this的值
    ThisBinding = <this value>,
    // 创建词法环境组件
    LexicalEnvironment = {},
    // 创建变量环境组件
    VariableEnvironment = {}
};

确定this

官方称呼为This Binding,在全局执行上下文中,this总是指向全局对象,例如浏览器环境下this指向window对象。而在函数执行上下文中,this的值取决于函数的调用方式,如果被一个对象调用,那么this指向这个对象,否则this一般指向全局对象window或者undefined(严格模式)

词法环境组件

词法环境是一个包含标识符变量映射的结构,这里的标识符表示变量/函数的名称,变量是对实际对象【包括函数类型对象】或原始值的引用。
词法环境由环境记录和外部环境引入记录两个部分组成。其中环境记录用于存储当前环境中的变量和函数声明的实际位置;外部环境引入记录很好理解,它用于保存自身环境可以访问的其他外部环境。
词法环境也分为全局词法环境和函数词法环境两种。

  1. 全局词法环境组件
    对外部环境的引入记录为null,因为它本身就是最外层环境,除此之外它还记录了当前环境下的所有属性、方法位置。
  2. 函数词法环境组件
    包含了用户在函数中定义的所有属性方法外,还包含了一个arguments对象。函数词法环境的外部环境引入可以是全局环境,也可以是其它函数环境。
// 全局环境
GlobalExectionContext = {
    // 全局词法环境
    LexicalEnvironment: {
        // 环境记录
        EnvironmentRecord: {
            Type: 'Object',  // 类型为对象环境记录
            // 标识符绑定在这里
        },
        outer: < null >
    }
};
// 函数环境
FunctionExectionContext = {
    // 函数词法环境
    LexicalEnvironment: {
        // 环境记录
        EnvironmentRecord: {
            Type: 'Declarative',  // 类型为声明性环境记录
            // 标识符绑定在这里
        },
        outer: < Global or outerfunction environment reference >
    }
};

变量环境组件

变量环境可以说也是词法环境,它具备词法环境所有属性,一样有环境记录和外部环境引入。在ES6中唯一的区别在于词法环境用于存储函数声明和let/const声明的变量,而变量环境仅仅存储var声明的变量。

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

c = multiply(20, 30);

创建过程

// 全局执行上下文
GlobalExectionContext = {
    // this绑定为全局对象
    ThisBinding: <Global Object>,
    // 词法环境
    // 词法环境组件
    LexicalEnvironment: {
        // 环境记录
        EnvironmentRecord: {
            Type: "Object",  // 对象环境记录
            // 标志符绑定在这里 let const 创建的变量 a b在这
            a: <uninitialized>,
            b: <uninitialized>,
            multiply: <func>
        },
        // 全局环境外部环境引入为null
        outer: <null>
    },
    // 变量环境组件
    VariableEnvironment: {
        EnvironmentRecord: {
            Type: "Object",  // 对象环境记录
            // 标识符绑定在这里  var创建的c在这
            c: undefined,
        },
        // 全局环境外部环境引入null
        outer: <null>
    }
}

// 函数执行上下文
FunctionExectionContext = {
    // 由于函数是默认调用this绑定同样是全局对象
    ThisBinding: <Global Object>,
    // 词法环境
    // 词法环境组件
    LexicalEnvironment: {
        Environment: {
            Type: "Declarative",  // 声明性环境记录
            // 标识符绑定在这里 arguments对象在这
            Arguments: {0: 20, 1: 30, length:2}
        },
        // 外部环境引入记录为</Global>
        outer: <GlobalEnvironment>
    }
}

小结:在执行上下文创建阶段,函数声明与var声明的变量在创建阶段已经被赋予了一个值,var声明被设置为了undefined,函数被设置为了自身函数, 而let const被设置为未初始化。这也就是为什么var定义的变量具有变量提升的特点,函数声明也具有函数提升的特点。上下文执行阶段,会根据之前的环境记录对应赋值,比如早期var在创建阶段为undefined,如果有值就对应赋值,像let const值为未初始化,如果有值就赋值,无值则赋予undefined。
函数提升优先级高于变量提升。

总结

有的人理解上下文都是谈作用域,变量对象和活动对象,变量对象和活动对象是ES3提出的概念,从ES5开始就用词法环境和变量环境替代了。变量对象和活动对象都是变量对象,变量对象是执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。而在函数上下文中,我们用活动对象(activation object, AO)来表示变量对象。这正好对应了全局词法记录和函数词法记录

利用堆栈信息快速定位问题

在前端,我们会经常通过window.onerror事件来捕获未处理的异常。假设捕获了一个异常,上报了一个异常如下:

TypeError: Cannot read property 'module' of undefined
    at Object.exec (https://my.cdn.com/dest/app.efe91e855d7432e402545e7d6c25d2d9.js:16:29828)
    at HTMLLIElement.<anonymous> (https://my.cdn.com/dest/app.efe91e855d7432e402545e7d6c25d2d9.js:25:6409)
    at HTMLDivElement.dispatch (https://my.cdn.com/dest/vendor.eb28ded1876760b8e90973c9f4813a2c.js:1:248887)
    at HTMLDivElement.y.handle (https://my.cdn.com/dest/vendor.eb28ded1876760b8e90973c9f4813a2c.js:1:245631)

然后查找为什么会出现undefined的情况。

需要注意的是:代码顺序不代表执行顺序,只有在调用时才有执行上下文栈。

常见面试题:

  1. 以下代码打印结果
console.log('global begin: ' + i);
var i = 1;
foo(1);
function foo(i) {
  if (i == 4) {
      return;
  }
  console.log('foo() begin: ' + i);
  foo(i+1);
  console.log('foo() end: ' + i);
}
console.log('global end: ' + i);
// 执行结果
global begin: undefined
foo() begin: 1
foo() begin: 2
foo() begin: 3
foo() end: 3
foo() end: 2
foo() end: 1
global end: 1