跳到主要内容

2. 执行上下文

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:(VO) 变量对象,用于存储变量的对象。
  • 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 标准。

imgx

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

一:编译阶段​

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

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

  1. variable environment 变量环境:参与 函数作用域 的 (var)变量和 (function)函数的相关声明和初始化。更多相关信息,参考 ”变量环境“ 章节。
    • outer 外部环境:指向了外部的 函数作用域(或全局作用域),或者说外部的执行上下文。根据 outer 的引用关系,形成了一个 [[scope]] 链。更多相关信息,参考 ”作用链“ 章节。
  2. lexical environment 词法环境:参与 块级作用域 的变量 let 、 const 和 class的相关声明。词法环境中,是一个栈,保存了块作用域的结构。更多相关信息,参考 ”词法环境“ 章节。
  3. this value:保存 this 值。更多相关信息,参考 ”this“ 章节。

二:运行阶段​

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

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

1.2 全局执行上下文​

ES3 版本:

在执行代码前,引擎会预先创建好基本的对象,放入 globalObject,也就是在全局执行上下文中:

  • 类:String、Date、Window(指向 globalObject);
  • 函数:setTimeout ...

他们通过原型链的形成继承关系,最终都指向了 Object 对象。

在全局作用域内的变量声明,都会放在 globalObject(GO)中。

2 调用栈 Call Stack​

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

流程​

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

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

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

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

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

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

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

image-20210905222747993

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

imgx

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

全局执行上下文,在运行时会最先被放入栈底,且一直存在,直到代码全部执行完毕才会出栈。

  • 在全局执行上下文中,Js 引擎会在堆内存中创建全局对象: Global Object(GO)这其实是一个闭包的形式。然后在全局执行上下文中引用这个堆内存的对象。
    • 该对象所有的作用域都可以访问的到;
    • 里面会预先定义:
      • 引用类型:Date、Array、String、Number;
      • 函数:setTimeout、setInterval;
      • window:指向自己。

入栈:当正在执行的代码中,出现函数调用,即会对应的创建一个新的执行上下文,把该执行上下文压入栈中。活跃指针指向了这个新建的执行上下文。开始执行新执行上下文中的代码。

出栈:当执行上下文中的代码全部执行完毕,执行上下文会从栈中弹出。活跃指针指向上一个执行上下文。

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

如何查看调用栈:​

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

3 变量环境 Variable Environment​

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

imgx

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

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

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

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

4 词法环境 Lexical Environment​

引,块级作用域:

任何用大括号括起来的区域,都是一个块级作用域:

  • if else、function、switch ...

ES6 标准规定:const、let、function、class 都受块级作用域的限制,意思是仅有 var 不受限制。

但大多数浏览器为了兼容旧代码,对 function 不受限制,所以仅有 const、let、class受到约束。

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

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

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

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

关于变量提升,ECMAScript 规定:

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

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

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

imgx

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

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

  1. 提升全部的 function 函数,放在 变量环境 中;
  2. 提升全部的 var 变量,放在 变量环境 中;
  3. 只提升最外层的 let 和 const 变量,放在 词法环境 中。

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

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

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

5. 函数如何参与​

5.1 函数的声明和执行​

函数创建时,会从内存中新建一块堆内存来存储代码块。在函数执行时,会从堆中取出代码字符串,存放在新建的栈中。

在堆内存中,会存储:

  • [[scope]] 父级作用域,形成一个作用域链;
  • 函数的执行体(代码块),以 string 的形式保存。

5.3 函数声明​

  1. 申请空间。开辟堆内存(16进制),得到内存地址;
  2. 声明环境。声明当前函数的作用域;
  3. 存储代码。把函数代码以字符串的形式存储在堆内存中;
    • 函数在不执行的情况下,在堆内存中以字符串形式存储。
  4. 指针引用。将函数堆的地址,放在栈中供变量调用(函数名);

5.4 执行函数​

  1. 创建一个当前函数的执行上下文,入调用栈。
  2. 初始化作用域链;
  3. 初始化 this(箭头函数没有this);
  4. 初始化 arguments 实参集合(箭头函数没有arguments),存储在堆内存中;
    • 形参赋值,对 arguments 内的参数进行初始化赋值;
  5. 变量提升,函数代码中的变量(函数)进行变量提升;
  6. 代码执行,在函数堆中存储的字符串在当前上下文中执行;

所以,函数的入参是保存在堆内存中的。

  • 具体是保存在堆内存中的 arguments 对象下;
  • 不论入参是否是基本类型,都在堆内存中保存;
  • 入参在代码执行之前、在变量提升之前,已经完成初始化赋值了。

6. 举例​

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

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 函数运行环境。

第一步​

imgx

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

imgx imgx

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

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

第二步​

image-20210907160558431

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

imgx

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

第三步​

image-20210907160613446

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

imgx

第四步​

image-20210907160627847

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

引用

《你不知道的JavaScript》

winter - 重学前端

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