【JavaScript】【作用域】执行上下文和执行栈

132 阅读12分钟

前言

可以通过我下面两篇文章简单的了解一下,作用域相关的概念:

在这篇文章中我们主要是对 执行上下文、执行栈 做个介绍。本文是基于 ES6

一、什么是执行上下文

官方一点地说,执行上下文(Execution context stack 简称 ECS)就是一个评估和执行JavaScript代码的环境的抽象概念。通俗地说,就是每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。

通俗的解释,就是一段代码执行时所带的所有信息。包括变量、函数声明、参数(arguments)、作用域链、this等信息。

在不同 ECMAScript 版本中执行上下文所代表的含义:

  • ES3
    • scope:作用域,作用域链
    • variable object: 变量对象,用来存储变量的对象
    • 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中有三种执行上下文类型:

  • 全局执行上下文:一个程序中只有一个全局执行上下文,任何不在函数内部的代码,都属于全局上下文,也就意味着this的指向是window对象(浏览器环境下)
  • 函数执行上下文:是在函数被调用的时候创建的,函数上下文可以有很多个,调用一次函数生成一个,执行顺序是函数调用的顺序
  • eval执行上下文:是在执行eval函数内部的代码是会有子级的执行上下文(在日常开发不会用到)

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

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

3.1 创建阶段

JavaScript代码执行前,执行上下文将经历创建阶段。在创建阶段需要做的三件事:

  • this值的决定,就是this绑定
  • 创建此法环境组件(lexical environment)
  • 创建变量环境组件(variable environment)

1) this绑定

  • 在全局执行上下文中,this对象指向全局对象(浏览器环境值得是window)
  • 在函数执行上下文中,this取决于该函数是否调用,且被什么调用。
    • 如果是一个引用对象调用,那么this会被设置成那个对象
    • 如果函数没有被调用,那么此时this的值会被设置成 全局对象或者undefined

    严格模式下,如果函数没有被绑定在任何对象上,函数执行上下文中的this会被设置为 undefined。这是因为在这种情况下,函数没有任何上下文可依赖,因此this被设置为undefined

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

foo.baz();   // 'this' 引用 'foo', 因为 'baz' 被
             // 对象 'foo' 调用

let bar = foo.baz;

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

2) 词法环境

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

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

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

  • 词法环境的内部有两个组件:(1)环境记录器;(2)一个外部环境的引用

    • 环境记录器:是存储变量和函数声明的实际位置
    • 外部环境的引用:意味着它可以访问其父级词法环境(作用域)
  • 词法环境的类型:

    • 全局执行上下文中是没有外部环境引用的词法环境。
      • 全局环境的外部环境引用是 null 。它拥有内建的 Object/Array等
      • 在环境记录器内的原型函数(关联全局对象,比如window对象),还有任何用户定义的全局变量,并且this的值指向全局对象
    • 函数执行上下文中,函数内部用户定义的变量存储在环境记录器中。并且引用的外部环境可能是全局环境,或者任何包含此内部函数的外部函数
  • 环境记录器的类型:在全局环境中,环境记录器是对象环境记录器;在函数环境中,环境记录器是声明式环境记录器

    • 声明式环境记录器存储变量、函数和参数

    • 对象环境记录器用来定义出现在全局上下文中的变量和函数的关系

    注意 — 对于函数环境声明式环境记录器还包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length

3) 变量环境

同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系

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

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

  VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // 在这里绑定标识符
      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>
  }
}

3.2 执行阶段

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

注意: 在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined

3.3 回收阶段

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

注意 : 在执行阶段,如果 JavaScript 引擎不能在源码中声明的实际位置找到 let 变量的值,它会被赋值为 undefined

四、执行栈

4.1 什么是执行栈

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

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

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

4.2 删除代码在浏览器加载时的执行过程

下面我们通过一个示例来理解:

let a = 'Hello World!';

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

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

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

大致流程如下:

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

4.3 查看调用栈的信息

调用栈是 JavaScript 引擎追踪函数执行的一个机制。

  • 使用浏览器查看调用栈信息的方式:

    1. 打开“开发者工具”
    2. 点击“Source”标签
    3. 选择 JavaScript 代码的页面,然后在函数内部加上断点,并刷新页面
    4. 可以看到执行到该函数时,执行流程就暂停了
    5. 通过右边“调用堆栈”来查看当前的调用栈的情况
    6. 栈的最底部就是全局的函数入口

    image.png

  • 通过命令:console.trace()输出

    var a = 2;
    
    function add(b, c) {
        console.trace(); // 输出当前的函数调用关系
        return b+c;
    }
    
    function addAll(b, c) {
        var d = 10;
        return a + add(b, c) + d;
    }
    
    addAll(1, 1)
    

    打印出函数调用关系:

    image.png

五、总结

5.1 程序执行过程

  1. 程序启动,全局执行上下文被创建,压入调用栈
    • 创建全局上下文的 词法环境
      • 创建 对象环境记录器 。它用来定义出现在 全局上下文 中的变量和函数的关系(负责处理 let 和 const 定义的变量)
      • 创建 外部环境引用。值为 null
    • 创建全局上下文的 变量环境
      • 创建 对象环境记录器 。它持有 变量声明语句 在执行上下文中创建的绑定关系(负责处理 var 定义的变量,初始值为 undefined 造成声明提升)
      • 创建 外部环境引用。值为 null
    • 确定 this 值为全局对象(以浏览器为例,就是 window )
  2. 函数被调用,函数执行上下文被创建,压入调用栈
    • 创建函数上下文的 词法环境
      • 创建 声明式环境记录器 。存储变量、函数和参数,它包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。(负责处理 let 和 const 定义的变量)
      • 创建 外部环境引用,值为全局对象,或者为父级词法环境(作用域)
    • 创建函数上下文的 变量环境
    • 确定this
  3. 进入函数执行上下文的执行阶段
    • 在上下文中运行/解释函数代码,并在代码逐行执行时分配变量值。

5.2 JavaScript的阶段

JavaScript是解释型语言,JavaScript的执行分为解释和执行两个阶段,并且这两个阶段所做的事情不同:

  • 解释阶段
    • 词法分析
    • 语法分析
    • 作用域规则确定
  • 执行阶段
    • 创建执行上下文
    • 执行函数代码
    • 垃圾回收

JavaScript解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。执行上下文最明显的就是this的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。

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

一个作用域下可能包含若干个上下文环境。有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值

简要概况一下作用域词法环境执行上下文这三者的概念:

  • 作用域:作用域就是一个独立的区域,它可以让变量不会向外暴露出去。作用域最大的用处就是隔离变量。内层作用域可以访问外层作用域。一个作用域下可能包含若干个执行上下文。
  • 词法环境:指相应代码块内标识符与变量值、函数值之间的关联关系的一种体现。词环境内部包含环境记录器和对外部环境的引用。环境记录器是存储变量和函数声明的实际位置,对外部环境的引用意味着可以访问父级词法环境。
  • 执行上下文:JavaScript代码运行的环境。分为全局执行上下文,函数执行上下文和eval函数执行上下文(前两个较常见)。创建执行上下文时会进行this绑定、创建词法环境和变量环境。

资料来源