深入JavaScript之执行上下文

189 阅读18分钟

什么是执行上下文

关于执行上下文的概念,众说纷纭,有以下几种描述:

  • 描述一:执行上下文是 JavaScript执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。
  • 描述二:执行上下文是用来跟踪记录代码运行时环境的抽象概念
  • 描述三:执行上下文是当前JavaScript代码被解析和执行时所在环境的抽象概念

综述,执行上下文简单理解就是代码执行时所在环境的抽象

执行上下文的类型

JavaScript中有三种执行上下文类型:

  • 全局执行上下文——这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置this的值等于这个全局对象。一个程序中只会有一个全局执行上下文。
  • 函数执行上下文——每当一个函数被调用时,都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序执行一系列步骤。
  • Eval函数执行上下文——执行在eval函数内部的代码也会有它属于自己的执行上下文,但由于 JavaScript 开发者并不经常使用 eval,所以在这里不会讨论它。

执行栈

执行栈,也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。

当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。

引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

让我们通过下面的代码示例来理解:

var a = 'Hello World!';

function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}

function second() {
  console.log('Inside second function');
}

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

// Inside first function
// Inside second function
// Again inside first function
// Inside Global Execution Context

image

如上图所示:

  • 当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first() 函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。
  • 当从 first() 函数内部调用 second() 函数时,JavaScript 引擎为 second() 函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second() 函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first() 函数的执行上下文。
  • 当 first() 执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。

使用ECStack来模拟调用栈:

ECStack = []

JS第一次执行代码时会遇到全局代码,执行上下文栈会压入一个全局上下文,我们用globalContext表示它,只有当整个应用程序结束的时候,ECStack才会被清空,所以ECStack最底层永远有个globalContext

ECStack.push(globalContext)

使用伪代码模拟上述代码行为:

ECStack.push(<first> functionContext);
ECStack.push(<second> functionContext);

// second出栈
ECStack.pop();

// first出栈
ECStack.pop();

为了巩固一下执行上下文的理解,下面再来绘制一个例子执行过程,这是一个简单的闭包例子:

function f1() {
    var n = 99;
    function fn2() {
        console.log(n);
    }
    return fn2;
}
f1()(); //99

使用伪代码模拟上述代码行为:

ECStack.push(<f1> functionContext);

// f1出栈
f1.pop();

ECStack.push(<f2> functionContext);

// f2出栈
f2.pop()

因为f1中的函数f2在f1的可执行代码中,并没有被调用执行,因此执行f1时,f2不会创建新的上下文,而直到f2执行时,才创建一个新的。如下图所示: image

ES3版本

es3版本执行上下文内有三个重要属性:

  • 变量对象 VO(variable object)
  • 作用域链(scope chain)
  • this

可以将每个执行上下文抽象为一个对象。

执行上下文的组成代码示例:

executionContextObj = {
  scopeChain: { /* 变量对象(variableObject)+ 所有父执行上下文的变量对象*/ },
  [variableObject | activationObject]: {
    /*函数 arguments/参数,内部变量和函数声明 */
    arguments,
    ...
  },
  this: {}
}

image

变量对象

变量对象 是与执行上下文相关联的数据作用域,用来存储上下文中定义的变量和函数声明。 不同执行上下文中的变量对象也不一样:

  • 全局上下文 中的变量对象就是全局对象,在浏览器中就是window对象。在在顶层JavaScript代码中,可以用关键字this引用全局对象。
  • 函数执行上下文中我们用活动对象AO(activation object)来表示变量对象,因为变量对象是规范上的或者说是引擎实现上的,在JavaScript环境中是不能被直接访问的,只有当函数被调用时,变量对象被激活为活动对象时,我们才能访问到其中的属性和方法。

活动对象就是变量对象,只不过处于不同的状态和阶段而已

作用域链

对于JavaScriot来说作用域及作用域链的变量查询时通过存储在浏览器内存中的执行上下文实现的。当查找变量时,首先从当前上下文中的变量对象查找,如果没有就会往上查找父级作用域中的变量对象,最终找到全局上下文的变量对象,如果没有就报错。这样由多个执行上下文的变量对象构成的链表就叫做作用域链

那么有同学就有疑问了,作用域和执行上下文有什么区别呢?

函数执行上下文时在调用函数时,函数体代码执行之前创建,函数调用结束时就会自动释放。因为不同的调用可能有不同的参数:

var a = 10;
function fn(x) {
  var a = 20;
  console.log(arguments)
  console.log(x)
}
fn(20)
fn(10) // 不同的调用可能有不同的参

而JavaScript采用的时词法作用域,fn函数创建的作用域在函数定义时就已经确定了 image

作用域只是一个“地盘”,其中没有变量,要通过作用域对应的执行上下文环境来获取变量的值,所以作用域是静态的,而执行上下文环境是动态的。也就是说,作用域只是用于划分你在这个作用域里面定义的变量的有效范围,出了这个作用域就无效。

同一个作用域下,对同一个函数的不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值,所以,作用域中变量的值是在执行过程中确定的,而作用域是在函数创建时就确定的。

this 绑定

在全局执行上下文中,this的值指向全局对象(在浏览器中,this 引用 Window 对象)。

在函数执行上下文中,this 的值取决于该函数是如何被调用的

  • 通过对象方法调用函数,this指向调用的对象
  • 声明函数后使用函数名称普通调用,this 指向全局对象,严格模式下 this 值是 undefined
  • 使用 new 方式调用函数,this 指向新创建的对象
  • 使用 call、apply、bind 方式调用函数,会改变 this 的值,指向传入的第一个参数,例如
function fn () {
  console.log(this)
}

function fn1 () {
  'use strict'
  console.log(this)
}

fn() // 普通函数调用,this 指向window对象
fn() // 严格模式下,this 值为 undefined

let foo = {
  baz: function() {
  console.log(this);
  }
}

foo.baz();   // 'this' 指向 'foo'

let bar = foo.baz;

bar();       // 'this' 指向全局 window 对象,因为没有指定引用对象

let obj {
  name: 'hello'
}

foo.baz.call(obj) // call 改变this值,指向obj对象

生命周期

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

  • 创建阶段
    • 生成变量对象
      • 创建arguments
      • 扫描函数声明
      • 扫描变量声明
    • 建立作用域链
    • 确定this的指向
  • 执行阶段
    • 变量赋值
    • 函数的引用
    • 执行其他代码
  • 销毁阶段

创建阶段

生成变量对象/活动对象

  • 创建arguments: 如果是函数上下文,首先会创建arguments对象,给变量对象添加形参名称和值
  • 扫描函数声明:对于找到的函数声明,将函数名和函数引用(指针)存入到VO中,如果VO中已经有同名函数,那么就进行覆盖。
  • 扫描变量声明:对于找到的每个变量声明,将变量名引入VO中,并且将变量的值初始化为undefined。如果变量的名字已经在变量对象里存在,不会进行任何操作并继续扫描。

举个例子:

function person(age) {
  console.log(typeof name); // function
  console.log(typeof getName); // undefined
  var name = 'abby';
  var hobby = 'game';
  var getName = function getName() {
    return 'Lucky';
  };
  function name() {
    return 'Abby';
  }
  function getAge() {
    return age;
  }
  console.log(typeof name); // string
  console.log(typeof getName); // function
  name = function () {};
  console.log(typeof name); // function
}
person(20);

在调用person(20),代码还没执行时,创建的状态是这样:

personContext = {
    scopeChain: { ... },
    activationObject: {
        arguments: {
            0: 20,
            length: 1
        },
        age: 20,
        name: pointer, // reference to function name(),
        getAge: pointer, // reference to function getAge(),
        hobby: undefined,
        getName : undefined,
    },
    this: { ... }
}

函数执行之前,会创建一个函数执行上下文,首先是指出函数的引用,然后按顺序对变量进行定义,初始化为undefined存到AO之中,在扫描到变量name时发现在AO之中存在同名的属性(函数声明变量),因此忽略

全局执行上下文的创建没有创建 arguments 这一步

建立作用域链

在执行上下文的创建阶段,作用域链是在变量对象之后创建的。作用域链本身包含变量对象。

  • 当书写一段函数代码时,就会创建一个词法作用域,这个作用域是函数内部的属性,我们用[[scope]]表示,它里面保存父变量对象,所以[[scope]]就是一条层级链。
person.[[scope]] = {
    globalContext.variableObject
}
  • 当函数调用,就意味着函数被激活了,此时创建函数上下文并压入执行战,然后复制函数[[scope]]属性创建作用域链:
personContext = {
    scopeChain:person.[[scope]]
}
  • 创建活动对象,然后将活动对象推到作用域链前端:
personContext = {
    activationObject: {
        arguments: {
            0: 20,
            length: 1
        },
        age: 20,
        name: pointer, // reference to function name(),
        getAge: pointer, // reference to function getAge(),
        hobby: undefined,
        getName : undefined,
    },
    scopeChain:[activationObject,[[scope]]]
}

确定this的指向

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

执行阶段

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

此时代码从上到下执行的时候激活阶段的过程是:

  1. 第一次执行 console.log; 此时 name 在 AO 中是函数。getName 未指定值在 AO 中的值是 undefined。
  2. 执行到赋值代码,getName 被赋值成函数表达式,name 被赋值为 abby
  3. 第二次执行 console.log; 此时的 name 由于函数被字符串赋值覆盖因此是 string 类型getName 是 function 类型。
  4. 第三次执行 console.log; 此时的 name 由于又被覆盖因此是 function 类型

因此理解执行上下文之后很好解释了变量提升(Hoisting):实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被JavaScript引擎放入内存中

这就解释了为什么我们能在 name 声明之前访问它,为什么之后的 name 的类型值发生了变化,为什么 getName 第一次打印的时候是 undefined 等等问题了。

✨ ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域,很好解决了变量提升带来的一系列问题。

销毁阶段

一般来说当函数执行完成之后,当前执行上下文会被弹出执行上下文栈并且等待虚拟机回收,控制权被重新交给执行栈上一层的执行上下文。

ES5版本

ES5 规范去除了 ES3中变量对象和活动对象,以词法环境组件(LexicalEnvironment component)和变量环境组件(VariableEnvironment component)替代。

为什么需要两个环境组件

一般情况下一个执行上下文内的VariableEnvironment和LexicalEnvironment指向同一个词法环境,**变量环境组件(VariableEnvironment)用于记录var声明的绑定和函数声明词法环境组件(LexicalEnvironment)用于记录其他声明的绑定(如let、const、class等)

之所以要区分两个组件,主要是为了实现块级作用域的同时不影响var声明及函数声明

生命周期

es5 执行上下文的生命周期也包括三个阶段:创建阶段 → 执行阶段 → 回收阶段

创建阶段

创建阶段做了三件事:

  • 确定this的值,也被称为This Binding
  • LexicalEnvironment(词法环境) 组件被创建
  • VariableEnvironment(变量环境) 组件被创建

直接看伪代码可能更加直观:

ExecutionContext = {  
  ThisBinding = <this value>,     // 确定this 
  LexicalEnvironment = { ... },   // 词法环境
  VariableEnvironment = { ... },  // 变量环境
}

This Binding

This Binding 是和执行上下文绑定的,也就是说每个执行上下文中都有一个this,与es3的this并没有什么区别,this的值是在执行的时候才能确认,定义的时候不能确认

创建词法环境(Lexical Environment)

ECMAScript规范中对词法环境的描述如下:词法环境是用来定义基于词法嵌套结构的ECMAScript代码内的标识符与变量值和函数值之间的关联关系 的一种规范类型。一个词法环境由环境记录(Environment Record)和一个可能为null的对外部词法环境的引用(outer)组成。一般来说,词法环境都与特定的ECMAScript代码语法结构相关联,例如函数、代码块、TryCatch中的Catch从句,并且每次执行这类代码时都会创建新的词法环境。

简而言之,词法环境就是相应代码块内标识符与值的关联关系的体现。

词法环境的结构如下:

// 全局执行上下文
GlobalExectionContext = {
 LexicalEnvironment: {  // 词法环境
   EnvironmentRecord: { // 环境记录
      Type: 'Object', // 全局环境
      outer: <null>, // 对外部环境的引用
   }
 }
}

// 函数执行上下文
FunctionExectionContext = {
   LexicalEnvironment: {  // 词法环境
   EnvironmentRecord: { // 环境记录
      Type: 'Declarative', // 函数环境
      outer:<Global or outer function environment reference>  , // 对外部环境的引用
   }
 }
}

可以看到词法环境有两种类型:

  • 全局环境:是一个没有外部环境的词法环境,其外部环境引用为null。拥有一个全局对象(window对象)及其关联的方法和属性,以及用户自定义的全局变量,this的值指向这个全局对象。
  • 函数环境:用户在函数中定义的变量被存储在环境记录中,包含了arguments对象。对外部环境的引用可以时全局环境,也可以时包含内部函数的外部函数环境。

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

  • 环境记录(EnvironmentRecord):记录相应代码块的标识符绑定。

可以理解为相应代码块内的所有变量声明、函数声明(代码块若为函数还包括其形参)都储存于此,对应ES3中的变量对象or活动对象

  • 对外部词法环境的引用(outer):用于形成多个词法环境在逻辑上的嵌套结构,以实现可以访问外部词法环境变量的能力。

词法环境在逻辑上的嵌套结构对应ES3中的作用域链

环境记录也有两种类型:

  • 在函数环境中使用声明式环境记录器,用来存储变量、函数和参数。
  • 在全局环境中使用对象环境记录器,用来定义出现在全局上下文中的变量和函数的关系。

稍微总结一下:

  • 创建全局上下文的词法环境使用对象环境记录器, outer为null
  • 创建函数上下文的词法环境时使用声明式环境记录器,outer值威全局对象或者是父级词法环境

创建变量环境

变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。

区别在于:变量环境(VariableEnvironment)用于记录var声明的绑定和函数声明,词法环境(LexicalEnvironment)用于记录其他声明的绑定(如let、const、class等)。

下面通过代码来理解下:

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",  
      // 标识符绑定在这里  
      a: < uninitialized >,  
      b: < uninitialized >,  
    }  
    outer: <null>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里  
      c: undefined,  
      multiply: < func >  
    }  
    outer: <null>  
  }  
}

FunctionExectionContext = {  
   
  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      
    },  
    outer: <GlobalLexicalEnvironment>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      g: undefined, 
      Arguments: {0: 20, 1: 30, length: 2}, 
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}

执行阶段

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

回收阶段

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

总结

  1. 程序启动,全局上下文被创建
    1.1 创建全局上下文的词法环境
    • 创建对象环境记录器,它用来定义出现在全局上下文中的变量和函数的关系(负责处理let和const定义的变量)
    • 创建外部环境引用,值为null
    1.2 创建全局上下文的变量环境
    • 创建对象环境记录器,用于记录var声明的绑定和函数声明
    • 创建外部环境引用,值为null
  2. 函数被调用,函数上下文被创建
    2.1 创建函数上下文的词法环境
    • 创建声明式环境记录器,负责处理let和const定义的变量。
    • 创建外部环境引用,值为全局对象或者父级词法环境 2.2 创建函数上下文的变量环境
    • 创建声明式环境记录器,存储变量、函数和参数,它包含一个传递给函数的arguments对象和传递给函数的参数的length。(负责处理var定义的变量,初始值为undefined造成声明提升)
    • 创建外部环境引用,值威全局对象或者父级词法环境

拓展补充:

执行上下文和作用域的区别

经常混淆执行上下文和作用域的概念,误以为他们是相同的概念,下面将对它们做一个区分。

我们知道javascript属于解释性语言,javascript的执行分为:编译和执行两个阶段,这两个阶段所做的事情是不一样的。

编译阶段

  • 词法分析
  • 语法分析
  • 作用域规则确定

执行阶段

  • 创建执行上下文(也可以理解为执行前的“准备工作“)
  • 执行函数代码
  • 垃圾回收

从上述可以看出,js编译阶段便会确定编译规则,因此==作用域在函数定义时就已经确定了==,而不是在函数调用时确定,但是==执行上下文是在函数调用时(函数执行之前)创建的==。

执行上下文最明显的就是this的指向是执行时确定的,而作用域访问的变量是编写代码的结构确定的。

作用域和执行上下文之间最大的区别是:执行上下文在运行时确定,随时都可能改变;作用域在定义时确定,不会改变。

此外,

上下文环境可以理解为一个看不见但实际存在的对象,所有变量都在里面存着;

而作用域比较抽象,创建一个函数就创建了一个作用域,无论你调用不调用,函数只要创建了,它就有独立的作用域,就有自己的一个“地盘”。

一个作用域下可能包含若干个上下文环境;也有可能从来没有上下文环境(函数未被调用执行);也有可能有过,但是函数执行后,上下文环境被销毁了。

参考:
juejin.cn/post/684490… juejin.cn/post/684490… juejin.cn/post/684490… juejin.cn/post/684490…