JavaScript 执行上下文(ES3版 与 ES5版)

1,962 阅读12分钟

一、什么是执行上下文(Excution Context)?

​ 执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念,JavaScript 中运行任何的代码都是执行上下文中运行。

执行上下文的类型

执行上下文总共有三种类型:

  • 全局执行上下文:默认的、最基础的执行上下文,不在任何函数中的代码都位于全局执行上下文中。一个程序中只能存在一个全局执行上下文。 它做了两件事:
      1. 创建一个全局对象,在浏览器中这个全局对象就是 window 对象,同时 window 对象是 var 声明的全局变量的载体。
      1. this 指针指向这个全局对象。
  • 函数执行上下文:每次调用函数(包括多次调用同一个函数)时,都会为该函数创建一个新的执行上下文。每个函数都拥有自己的执行上下文,但是只有在函数被调用时才会被创建。一个程序中可以存在任意数量的函数执行上下文。每当一个新的执行上下文被创建,它都会按照特定的顺序执行一系列步骤。
  • eval 函数执行上下文:运行在 eval 函数中的代码也获得了自己的执行上下文。很少用而且不建议使用。

执行上下文栈(Execution Context Stack)

​ 执行上下文栈,也叫调用栈,具有 LIFO(后进先出)结构,用于存储在代码执行期间创建的所有执行上下文。

​ 当 JavaScript 引擎首次读取代码时,会创建一个全局执行上下文并将 其推入到当前的执行栈。每当发生一个函数调用,引擎都会为函数创建一个新的执行上下文并将其推到当前执行栈的顶端。

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

代码示例:

function first(){
    second();
    console.log(1);
}
function second(){
    third();
    console.log(2);
}
function third(){
    console.log(3);
}

first(); // 3 2 1

执行过程大致如下:

// 1. 代码执行前创建全局执行上下文
ECStack = [globalContext];
// 2. first 调用
ECStack.push('first functionContext');
// 3. first 调用了 second ,等待 second 执行完毕再输出 1
ECStack.push('second functionContext');
// 4. second 调用了 third ,等待 third 执行完毕再输出 2
ECStack.push('third functionContext');
// 5. third 执行完毕,输出 3 并弹出栈
ECStack.pop();
// 6. second 执行完毕,输出 2 并弹出栈
ECStack.pop();
// 7. first 执行完毕,输出 1 并弹出栈
ECStack.pop();
// 此时执行栈中只剩下一个全局执行上下文

总结:

  • JS 执行在单线程上,所有的代码都是排队执行
  • 一开始浏览器执行全局的代码时,首先创建全局的执行上下文,压入执行栈的顶部。
  • 每当进入一个函数的执行就会创建函数的执行上下文,并且把它压入执行栈的顶部。当前函数执行完成后,当前函数的执行上下文出栈,并等待垃圾回收。
  • 浏览器的 JS 执行引擎总是访问栈顶的执行上下文。
  • 全局上下文只有唯一的一个,它在浏览器关闭时出栈。

可以通过设置断点或插入 debugger ,然后在开发者工具中,调试器会在那个位置暂停,同时会展示当前位置的调用列表,这就是调用栈。

二、执行上下文的生命周期

​ 执行上下文的生命周期包括三个阶段:创建阶段 -> 执行阶段 -> 回收阶段

由于ES3 和 ES5 规范所规定的不同,所以分为两个版本。

ES3版

创建阶段

在 JavaScript 代码执行前,执行上下文将经历创建阶段。在创建阶段会发生三件事:

  1. 创建变量对象(Variable object, VO)
  2. 创建作用域链(Scope chain)
  3. 确定 this 值

变量对象

​ 全局上下文中的变量对象就是全局对象。

​ 在函数上下文中,使用 活动对象(Activation object, AO) 来表示变量对象。

​ 由于变量对象是规范上的或者说是引擎实现的,不可在 JS 环境中访问,只有当进入一个执行上下文中,这个执行上下文的 变量对象(VO) 才会被 激活 变成 活动对象(AO)

​ 活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。

​ 当进入执行上下文时,这是还未执行代码:

​ 变量对象会包括:

  1. 函数的所有形参(如果是函数上下文)
    • 由名称和对应值组成的一个变量对象的属性。
    • 没有实参,属性值设为 undefined。
  2. 函数声明
    • 由名称和对应值(函数对象 ( function-object ) 组成一个变量对象的属性
    • 如果变量对象已经存在相同名称的属性,则完全替代这个属性。
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性。
    • 如果变量名称已经和声明的形成参数或函数相同,则变量声明不会干扰已经存在的这类属性。

举个例子:

function foo(a){
    var b = 2;
    function c() {}
    var d = function() {};
}
foo(1);

在进入执行上下文后,这时的 AO 是:

AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: referece to function c(){},
    d: undefined;
}

总结:

  1. 全局上下文的变量对象初始化是全局对象。
  2. 函数上下文的变量对象初始化只包括 Arguments 对象。
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值。
  4. 在后续的代码执行阶段,会再次修改变量对象的属性值。

作用域链

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

​ 函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,但并不代表 [[scope]] 完整的作用域链。

举个例子:

function foo(){
    function bar(){}
}

函数创建时,各自的 [[scope]] 为:

foo.[[scope]] = [
    globalContext.VO
];
bar.[[scope]] = [
    fooContext.AO,
    globalContext.VO
]

然后当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用域链的前端。

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

至此,作用域链创建完毕。

this 值

​ 在全局执行上下文中,this 的值指向全局对象,在浏览器中 this 的值指向 window 对象。

​ 在函数执行上下文中,this 的值取决于函数的调用方式。具体有:默认绑定、隐式绑定、默认绑定(硬绑定)、new 绑定、箭头函数。

详细请至👉 --- > this 解析

例子解析

var scope = "global scope";
function checkscope(){
    var scope = "local scope";
}
checkscope();
// 1. 执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
ECStack = [
  	globalContext
];
// 2.全局上下文初始化
globalContext = {
    VO: [global, scope, checkscope],
    Scope: [globalContext.VO],
    this: globalContext.VO
}
// 2.1 初始化的同时,checkscope 函数被创建,保存作用域链的内部属性 [[scope]]
checkscope.[[scope]] = [
  	globalContext.VO
];
// 3. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行栈
ECStack = [
    checkscopeContext,
    globalContext
];
// 4. checkscope 函数执行上下文初始化
// 4.1 复制函数的 [[scope]] 属性创建作用域链,
// 4.2 用 arguments 创建活动对象
// 4.3 初始化活动对象,即加入形参、函数声明、变量声明
// 4.4 将活动对象压入 checkscope 作用域顶端
checkscopeContext = {
    AO: {
        arguments: {
            length: 0
        },
        scope: undefined,
    },
    Scope: [AO, globalContext.VO],
    this: undefined
}

执行阶段

执行变量赋值、代码执行

回收阶段

执行上下文出栈等待虚拟机回收执行上下文


ES5 版

创建阶段

在创建阶段会发生三件事:

  1. this 值的决定,也被称为 This Binding。(即 this 绑定)
  2. LexicalEnvironment——词法环境组件被创建。
  3. VariableEnvironment——变量环境组件被创建。

因此,执行上下文可以在概念上表示如下:

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

This Binding

​ 在全局执行上下文中,this 的值指向全局对象,在浏览器中 this 的值指向 window 对象。

​ 在函数执行上下文中,this 的值取决于函数的调用方式。具体有:默认绑定、隐式绑定、默认绑定(硬绑定)、new 绑定、箭头函数。

详细请至👉 --- > this 解析

词法环境(Lexical Environment)

官方 ES6文档将词法环境定义为:

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

即词法环境一个包含标识符——变量映射的结构。(这里的标识符表示变量/函数的名称,变量是对实际对象【包括函数类型对象】或原始值的引用)

ecma-international.org/ecma-262/6.…

词法环境中,有两个组成部分:

  1. 环境记录——environment record:是存储变量和函数声明的实际位置。
  2. 对外部环境的引用——outer:可以访问其外部词法环境。(类似于作用域链)。

词法环境有两种类型:

  1. 全局环境:(在全局执行上下文中)是一个没有外部环境的词法环境。全局环境的外部环境引用为 null。它拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局对象,this 的值指向这个全局对象。
  2. 函数环境:用户在函数中定义的变量被存储在环境记录中。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。

住:对于函数环境而言,环境记录还包含了一个 arguments 对象,该对象包含了索引和传递给函数的参数之间映射以及传递给函数的参数的长度。例如:Arguments:{ 0:a , 1:b, length: 2}

环境记录 同样有两种类型:

  • 声明性环境记录:存储变量、函数、和参数。一个函数环境包含声明性环境记录。
  • 对象环境记录:用于定义在全局执行上下文中出现的变量和函数的关联。全局环境包含对象环境记录。

用伪代码可以表示成:

// 全局执行上下文
GlobalExectionContext = {
    // 词法环境
    LexicalEnvironment: {
        // 环境记录
        EnvironmentRecord: {
            Type: "Object", // 全局环境
            // ... 标识符绑定在这里
           outer: <null>,	// 对外部环境的引用
        }
    }
}
// 函数执行上下文
FunctionExectionContext = {
    LexicalEnvironment: {
        EnvironmentRecord: {
            Type: "Declarative",// 函数环境
            // ... 标识符绑定在这里
            // 对全局环境或外部函数环境的引用
            outer: <Global or outer function environment reference>,
        }
    }
}

变量环境(VariableEnvironment)

变量环境也是一个词法环境,其环境记录器包含了由变量声明语句

在执行上下文中创建的绑定关系。

如上所述,变量环境也是一个词法环境,所有它有着上面的定义的词法环境的所有属性。

在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);

执行上下文如下所示:

// 全局执行上下文
GlobalExectionContext = {
    
    ThisBinding: <Global Object>,
    // 词法环境
    LexicalEnvironment: {
    	EnvironmentRecord: {
            Type: "Object",
            // 标识符绑定,let、const、函数声明 
            a: <uninitialized>,
            b: <uninitialized>,
            multiply:< func >
        },
        outer: <null>
    },
    // 变量环境
    VariableEnvironment: {
        EnvironmentRecord: {
            Type: "Object",
            // 标识符绑定,var 声明
            c: undefined,
        }
        outer: <null>
    }
}

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

注意:只有遇到调用函数 multiply 时,函数执行上下文才会被创建。

由上可以看出 letconst 定义的变量并没有关联任何值 uninitialized(未初始化),但 var 定义的变量被初始化成 undefined

这是因为在创建阶段,代码会被扫描并解析变量和函数声明,其中函数声明存储在环境中,而变量会被设置为 undefined (在 var 的情况下)或保持未初始化 uninitialized(在 letconst 的情况下)。

这就是为什么可以在声明之前访问 var 定义的变量(尽管是 undefined),但是如果在声明前访问 letconst 定义的变量就会提示引用错误的原因(也就是我们所常说的暂时性死区(TDZ,temporal dead zone))。

这就是我们所谓的变量声明提升。

详细请☞ 细谈变量声明

执行阶段

在此阶段,完成对所有变量的分配,最后执行代码。

注: 在执行阶段,如果 Javascript 引擎在源代码中声明的实际位置找不到 let 变量的值,那么将为其分配 undefined 值。

回收阶段

执行上下文出栈等待虚拟机回收执行上下文。

总结

个人看法:ES5 版的词法环境和变量环境应该是为了更好的区分 varlet、const

参考文章

ES6官方文档

【译】理解JavaScript执行上下文和执行栈

JavaScript深入之执行上下文栈