阅读 90

JavaScript系列 - 执行上下文

本文讲述了 JavaScript 的执行上下文相关知识,主要有:执行上下文的创建过程、执行上下文参与的调用栈、执行上下文内部的变量环境和词法环境,以及他们是如何运作和相互配合的。

1 执行上下文 Execution Context

JavaScript 标准把一段代码(包括函数),在 Js 引擎执行所需的所有信息定义为:“执行上下文”。

通过上文了解的 Js 执行过程我们知道,Js 引擎的代码执行是以块为单位的,每个块划分为 编译时运行时 两个阶段。

这个块是按照 词法作用域(函数作用域、全局作用域) 来划分,每个词法作用域也是一个运行环境。当 JS 引擎要执行某个运行环境时,这个运行环境就会创建一个登记像关信息的资料表,这个表就是 执行上下文。执行上下文整理了该运行环境内所有的信息:声明的变量、函数、外部作用域、this、内部属性 [[scope]] 等等。

所以,执行上下文就是一个登记信息的资料表,这个表整理了一个运行环境,或者是一个函数作用域(或全局作用域)要执行时的全部信息。

在运行环境的编译时,会创建执行上下文,Js 引擎会根据代码中变量、函数来初始化执行上下文;在运行时,会随时读取执行上下文中的信息,并根据代码更新执行上下文中的信息;当该运行环境中的代码全部执行完毕后,则会销毁这个执行上下文。

JS 的 运行环境 主要有 3 种:

  1. 全局环境。在开始执行代码时,最先进入的就是全局环境,这也是全局作用域;
  2. 函数环境。函数调用的时候,进入到这个函数环境,这也是函数作用域;
  3. eval 环境。用的很少,存在安全和性能问题,这其实也一个函数作用域。

对应的,当运行环境即将执行时,会创建 执行上下文

  1. 全局执行上下文。调用栈最底层的执行上下文,伴随着调用栈的创建而入栈,调用栈的销毁而出栈。
  2. 函数执行上下文。函数调用的时候,进入到这个函数环境,这也是函数作用域;
  3. eval 函数执行上下文。

以下摘抄自 winter 重学前端

因为这部分术语经历了比较多的版本和社区的演绎,所以定义比较混乱,这里我们先来理一下 JavaScript 中的概念。

执行上下文ES3 中,包含三个部分。

  • scope:作用域,也常常被叫做作用域链。
  • variable object:变量对象,用于存储变量的对象。
  • this value:this 值。

ES5 中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

  • variable environment:变量环境,当声明变量时使用。
  • lexical environment:词法环境,当获取变量时使用。
  • this value:this 值。

ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical environment,但是增加了不少内容。

  • variable environment:变量环境,当声明变量时使用。
  • lexical environment:词法环境,当获取变量或者 this 值时使用。
  • code evaluation state:用于恢复代码执行位置。
  • Function:执行的任务是函数时使用,表示正在被执行的函数。
  • ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
  • Realm:使用的基础库和内置对象实例。
  • Generator:仅生成器上下文有这个属性,表示当前生成器。

1.1 基本模型

所以,本文是基于引入块作用域的 ES6 规则,来描述执行上下文的,更接近 ES5 标准。

image.png

在 JavaScript 引擎内部,每次调用执行上下文,会分为两个阶段:

一:编译阶段

引擎创建执行上下文,编译器逐行扫描代码片段,根据需要把对应的声明、作用域、this值等信息登记到执行上下文中。扫描完毕后,会把代码片段转化为运行时可执行的代码。

创建执行上下文,并把创建好的执行上下文压入调用栈中。此时函数被调用,但未执行任何其内部代码,依次创建:

  1. variable environment 变量环境:参与 函数作用域var 变量和 function 函数的相关声明和初始化。更多相关信息,参考 ”变量环境“ 章节。

    • outer 外部环境:指向了外部的 函数作用域(或全局作用域) ,或者说外部的执行上下文。更多相关信息,参考 ”作用链“ 章节。
  2. lexical environment 词法环境:参与 块级作用域letconst 变量的相关声明。词法环境中,是一个栈,保存了块作用域的结构。更多相关信息,参考 ”词法环境“ 章节。

  3. this value:保存 this 值。更多相关信息,参考 ”this“ 章节。

二:运行阶段

运行阶段时,引擎会逐行运行可执行代码,根据代码来对执行上下文的信息进行读取、更新。

当遇到一个新的代码块时(一个新的函数作用域),会暂停对当前代码的运行时,进入新代码块儿的编译时,创建对应的执行上下文。直到新代码块儿的编译时、运行时全部执行完毕后。恢复上一个代码块代码的继续执行。

2 调用栈 Call Stack

调用栈是一个机制,用来让 JavaScript 引擎方便地去追踪函数执行。当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行,以及查看各函数之间的调用关系。

流程

上文提到,执行上下文是一个收集信息的表,收集了即将要执行的运行环境(函数或全局作用域)所需要的全部信息。

在 JS 执行阶段时,会逐块识别载入的代码。这些代码根据 词法作用域(有全局作用域、函数作用域,这里没有块作用域)划分,区分出一块块的代码块。这些代码块也称之为 运行环境

之后全部代码的执行,都是按照个一块块代码块为单位,来逐块儿往下执行的。

每个代码块儿,在引擎要执行它的时候,都有一个登记代码块中相关信息的表需要维护。这个表存放了代码块中的全部变量、函数、作用域、this 等各种需要在引擎执行该代码块儿时用到的信息。而这个存放信息的表,称之为这个代码块儿的 执行上下文

  • 需要强调的是,只有当代码块儿需要执行的时候,才会创建 执行上下文。一个代码块儿可能会经历多个执行上下文的创建,比如 foo() 函数调用,在代码中执行了两次。每次执行函数调用,都会创建一个 foo 执行上下文

引擎在词法/语法分析阶段结束后,创建一个 执行上下文栈,也称为 调用栈。然后,引擎开始执行 JavaScript 代码,遇到一个要执行的词法作用域,在编译阶段,就会给他创建一个执行上下文,然后放入执行上下文栈中。在运行阶段,就会更新和调用执行上下文中的信息。

  • 如果当前 执行上下文 对应的代码全部执行完毕,该执行上下文就会出栈;
  • 如果当前 执行上下文 对应的代码中,有新的函数作用域,就会继续创建一个与这个函数作用域对应的执行上下文,放入栈中。 JS 引擎开始执行这个运行环境的编译时、运行时。

image.png

JS 引擎会以栈的数据结构对执行上下文进行处理,形成执行上下文栈(ECStack) 。就像 stack overflow 的 logo 那样:

image.png

栈底是 全局执行上下文(global execution context) ,栈顶时是当前的执行上下文。

  • 全局执行上下文,在运行时会最先被放入栈底,且一直存在,直到代码全部执行完毕才会出栈。
  • 入栈:当正在执行的代码中,出现函数调用,即会对应的创建一个新的执行上下文,把该执行上下文压入栈中。 Js 引擎开始创建新的执行上下文中。
  • 出栈:当执行上下文中的代码全部执行完毕,执行上下文会从栈中弹出。活跃指针指向上一个执行上下文。

另:JavaScript 的引用数据存储,是在一个堆结构中。包括 functionarray 等各种对象,都保存在堆内存中。栈中的变量中,保存的是这些数据在堆内存的地址。所以才会把引用数据类型的值,称之为”地址值“。相反,基本数据类型的值,就是具体的值。因为 stringnumber 等数据,都是直接保存在栈中的(具体来讲,是栈中每一个执行上下文内)。

如何查看调用栈:

  1. 使用 console.trace() 打印当前函数的调用关系;
  2. 使用 debugger 打断点,然后通过开发者工具中,call stack 查看当前函数的调用关系。

3 变量环境 Variable Environment

本小节详细剖析环境变量内部的结构。

image.png

根据 ECMA 标准规定,环境变量主要分为两个内容,即:

  • 登记当前函数 (全局) 作用域内的全部变量和函数;

  • 外部环境 outer 属性指向当前执行上下文的父函数 (全局) 作用域。如果是全局执行上下文,则 outer 的值为 null

    • outer 属性和 作用域 紧紧挂钩,它和 调用栈 不是一套规则,更多 outer 属性的相关内容,参见 “作用域链 Scope Chain” 章节。

在代码的编译阶段,JS 引擎会对变量进行提升。而变量环境内部,就是承接 var 声明的变量和 function 声明的函数。更多相关内容,参见 “提升 Hoisting”“词法环境 Lexical Environment” 章节。

4 词法环境 Lexical Environment

本小节详细剖析词法环境内部的结构。

上文我们了解到,ES6 的执行上下文模型中,词法环境用来登记 letconst 关键词声明的变量,用来记录块作用域。在词法环境中,实际上是一个类似调用栈一样的小型 结构。在调用栈内,是按照函数作用域划分的不同栈元素(执行上下文);而词法环境内,小型栈是按照词法作用域划分的不同栈元素。

一个词法环境的作用域范围,就是一个函数作用域(或全局作用域),内部是多个块作用域。

为了更好的讲解词法环境中栈的具体结构,我们要先回顾一下块作用域中 letconst 的变量提升问题。

关于变量提升,ECMAScript 规定:

  • varfunction 声明的变量,只在函数 / 全局作用域中提升。
  • letconst 声明的变量,不会提升。(事实上,在运行时的块作用域中 “创建提升”)。

更多关于提升的知识,请参考 “提升 Hoisting” 章节。

上文说过,在执行上下文中的词法环境,是一个类似执行下文栈的小型栈。这个小型栈维护了该运行环境内的所有块作用域,自然也维护了块作用域内声明的 letconst 变量。

image.png

所以,面对块作用域,Js 引擎的执行流程是这样的:

JS 引擎进入当前运行环境的 编译时

  1. 提升全部的 function 函数,放在 变量环境 中;
  2. 提升全部的 var 变量,放在 变量环境 中;
  3. 创建小型栈,只提升最外层的 letconst 变量,压入 词法环境 中。

JS 引擎进入当前运行环境的 运行时

  1. 开始逐行执行代码;
  2. 当读取到一个块作用域,会暂停对后面代码的执行。
  3. 首先在词法环境中创建一个属于这个块作用域区域,然后对该块作用域内声明的 letconst 进行 变量的创建提升,并 初始化
  4. 当变量提升完成后,才会恢复对后面代码的执行。

需要注意的是,在真实的浏览器 V8 引擎中, letconst 变量进行提升时,不仅 创建 了变量,也对变量进行了 初始化。这和 ECMA 的标准不符。为了保证和标准的表现一致,这些变量在执行赋值操作前,V8 引擎被禁止对其读取。

举个例子来体现这个问题:

debugger;
var a1 = "a1"
let a2 = "a2"
foo()
​
function foo() {
    var a11 = "a11"
    let a22 = "a22"; 
    {
        var a111 = "a111"
        let a222 = "a222"
    }
}
复制代码

首先分析这段代码的作用域:全局作用域 <---- foo 函数作用域, foo 函数作用域内部又因为一个大括号分隔开内外两层块作用域。

然后分析这代段代码的运行环境,一共有两个运行环境,全局运行环境和 foo 函数运行环境。

第一步

image.png

当代码执行到第一行 debugger 时,已经完成了对全局执行上下文的创建。此时 V8 引擎对 a1 变量创建,且初始化为 undefined,对 a2 变量也进行了创建且初始化为 undefined。但是,为了和标准表现一致(let 声明的变量只能创建提升,不能初始化提升),这里没有允许引擎对 a2 变量进行访问,把它放在了一个单独的区域内。

image.png

上图可以看到,此时 Call Stack 调用栈中,压入了匿名的全局执行上下文。var 声明的 a1 变量在 Global,初始化为 undefinedlet 声明的变量 a2 在 Script 中放置,也已经初始化为 undefined

image.png

上图可以看到,在执行上下文创建完成,刚进入运行阶段时,a1 输出 undefined,a2 报错。虽然 a2 已经被浏览器引擎初始化,但最终效果和标准一致,在尚未执行第 6 行的赋值语句之前,a2 不允许被访问,报错。

第二步

image.png

当代码执行到第7行时,进入 foo 的函数执行上下文。

image.png

上图可以看到,当代码执行到第 11 行,foo 执行上下文的编译阶段已经完成,刚进入运行阶段。此时已经完成对执行上下文的创建和初始化。此时,不仅处在 foo 函数作用域内,还处在 foo 内第一个块作用域中,所以也会对第一个块作用域(最外层的块作用域)中的变量进行提升。此时对 a11a22,和 a111 进行声明和初始化。

第三步

image.png

当代码执行到第12行时,执行到第二个块级作用域时,对第二个块作用域中的变量进行提升。可以看到上图中,词法环境中,是一个块作用域的栈。第二个块作用域中的 let 变量已经声明,然后入栈。

image.png

第四步

image.png

当全部代码执行完毕,即将完成 foo 函数执行上下文的运行阶段,以及全局执行上下文的运行阶段时,可以看到调用栈如上图所示。

引用

《你不知道的JavaScript》

winter - 重学前端

李兵 - 浏览器工作原理与实战

文章分类
前端
文章标签