2. 执行上下文
1 执行上下文 Execution Context
JavaScript 标准把一段代码(包括函数),在 Js 引擎执行所需的所有信息定义为:“执行上下文”。
通过上文了解的 Js 执行过程我们知道,Js 引擎的代码执行是以块为单位的,每个块划分为 编译时 和 运行时 两个阶段。
这个块是按照 词法作用域(函数作用域、全局作用域)来划分,每个词法作用域也是一个运行环境。当 JS 引擎要执行某个运行环境时,这个运行环境就会创建一个登记相关信息的资料表,这个表就是 执行上下文。执行上下文整理了该运行环境内所有的信息:声明的变量、函数、外部作用域、this、内部属性 [[scope]]
等等。
所以,执行上下文就是一个登记信息的资料表,这个表整理了一个运行环境,或者是一个函数作用域(或全局作用域)要执行时的全部信息。
在运行环境的编译时,会创建执行上下文,Js 引擎会根据代码中变量、函数来初始化执行上下文;在运行时,会随时读取执行上下文中的信息,并根据代码更新执行上下文中的信息;当该运行环境中的代码全部执行完毕后,则会这个销毁执行上下文。
JS 的 运行环境 主要有 3 种:
- 全局环境。在开始执行代码时,最先进入的就是全局环境,这也是全局作用域;
- 函数环境。函数调用的时候,进入到这个函数环境,这也是函数作用域;
- eval 环境。用的很少,存在安全和性能问题,这其实也一个函数作用域。
对应的,当运行环境即将执行时,会创建 执行上下文:
- 全局执行上下文。调用栈最底层的执行上下文,伴随着调用栈的创建而入栈,调用栈的销毁而出栈。
- 函数执行上下文。函数调用的时候,进入到这个函数环境,这也是函数作用域;
- 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 标准。
在 JavaScript 引擎内部,每次调用执行上下文,会分为两个阶段:
一:编译阶段
引擎创建执行上下文,编译器逐行扫描代码片段,根据需要把对应的声明、作用域、this值等信息登记到执行上下文中。扫描完毕后,会把代码片段转化为运行时可执行的代码。
创建执行上下文,并把创建好的执行上下文压入调用栈中。此时函数被调用,但未执行任何其内部代码,依次创建:
- variable environment 变量环境:参与 函数作用域 的 (
var
)变量和 (function
)函数的相关声明和初始化。更多相关信息,参考 ”变量环境“ 章节。- outer 外部环境:指向了外部的 函数作用域(或全局作用域),或者说外部的执行上下文。根据 outer 的引用关系,形成了一个
[[scope]]
链。更多相关信息,参考 ”作用链“ 章节。
- outer 外部环境:指向了外部的 函数作用域(或全局作用域),或者说外部的执行上下文。根据 outer 的引用关系,形成了一个
- lexical environment 词法环境:参与 块级作用域 的变量
let
、const
和class
的相关声明。词法环境中,是一个栈,保存了块作用域的结构。更多相关信息,参考 ”词法环境“ 章节。 - 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 引擎开始执行这个运行环境的编译时、运行时。
JS 引擎会以栈的数据结构对执行上下文进行处理,形成执行上下文栈(ECStack)。就像 stack overflow 的logo 那样:
栈底是 全局执行上下文(global execution context),栈顶时是当前的执行上下文。
全局执行上下文,在运行时会最先被放入栈底,且一直存在,直到代码全部执行完毕才会出栈。
- 在全局执行上下文中,Js 引擎会在堆内存中创建全局对象: Global Object(GO)这其实是一个闭包的形式。然后在全局执行上下文中引用这个堆内存的对象。
- 该对象所有的作用域都可以访问的到;
- 里面会预先定义:
- 引用类型:Date、Array、String、Number;
- 函数:setTimeout、setInterval;
- window:指向自己。
入栈:当正在执行的代码中,出现函数调用,即会对应的创建一个新的执行上下文,把该执行上下文压入栈中。活跃指针指向了这个新建的执行上下文。开始执行新执行上下文中的代码。
出栈:当执行上下文中的代码全部执行完毕,执行上下文会从栈中弹出。活跃指针指向上一个执行上下文。
另:JavaScript 的引用数据存储,是在一个堆结构中。包括 function、 array 等各种对象,都保存在堆内存中。栈中的变量中,保存的是这些数据在堆内存的地址。所以才会把引用数据类型的值,称之为”地址值“。相反,基本数据类型的值,就是具体的值。因为 string、number 等数据,都是直接保存在栈中的(具体来讲,是栈中每一个执行上下文内)。
如何查看调用栈:
- 使用
console.trace()
打印当前函数的调用关系; - 使用
debugger
打断点,然后通过开发者工具中,call stack
查看当前函数的调用关系。
3 变量环境 Variable Environment
本小节详细剖析环境变量内部的结构。
根据 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
变量。
所以,面对块作用域,Js 引擎的执行流程是这样的:
JS 引擎进入当前运行环境的 编译时,
- 提升全部的
function
函数,放在 变量环境 中; - 提升全部的
var
变量,放在 变量环境 中; - 只提升最外层的
let
和const
变量,放在 词法环境 中。
JS 引擎进入当前运行环境的 运行时,
- 开始逐行执行代码;
- 当读取到一个块作用域,会暂停对后面代码的执行。
- 首先在词法环境中创建一个属于这个块作用域区域,然后对该块作用域内声明的
let
和const
进行 变量创建提升,并 初始化。 - 当变量提升完成后,才会恢复对后面代码的执行。
需要注意的是,在真实的浏览器 V8 引擎中, let
和 const
变量进行提升时,不仅 创建 了变量,也对变量进行了 初始化。这和 ECMA 的标准不符。为了保证和标准的表现一致,这些变量在执行赋值操作前,V8引擎被禁止对其读取。
5. 函数如何参与
5.1 函数的声明和执行
函数创建时,会从内存中新建一块堆内存来存储代码块。在函数执行时,会从堆中取出代码字符串,存放在新建的栈中。
在堆内存中,会存储:
[[scope]]
父级作用域,形成一个作用域链;- 函数的执行体(代码块),以 string 的形式保存。
5.3 函数声明
- 申请空间。开辟堆内存(16进制),得到内存地址;
- 声明环境。声明当前函数的作用域;
- 存储代码。把函数代码以字符串的形式存储在堆内存中;
- 函数在不执行的情况下,在堆内存中以字符串形式存储。
- 指针引用。将函数堆的地址,放在栈中供变量调用(函数名);
5.4 执行函数
- 创建一个当前函数的执行上下文,入调用栈。
- 初始化作用域链;
- 初始化 this(箭头函数没有this);
- 初始化 arguments 实参集合(箭头函数没有arguments),存储在堆内存中;
- 形参赋值,对 arguments 内的参数进行初始化赋值;
- 变量提升,函数代码中的变量(函数)进行变量提升;
- 代码执行,在函数堆中存储的字符串在当前上下文中执行;
所以,函数的入参是保存在堆内存中的。
- 具体是保存在堆内存中的 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 函数运行环境。
第一步
当代码执行到第一行 debugger
时,已经完成了对全局执行上下文的创建。此时 V8 引擎对 a1
变量声明,且初始化为 undefined
,对 a2
变量也进行了声明且初始化为 undefined
。但是,为了和标准表现一致(let
声明的变量只能声明提升,不能初始化提升),这里没有允许引擎对 a2
变量进行访问,把它放在了一个单独的区域内。
左图可以看到,此时 Call Stack 调用栈中,压入了匿名的全局执行上下文。var
声明的 a1
变量在 Global,初始化为 undefined
; let
声明的变量 a2
在 Script 中放置,也已经初始化为 undefined
。
右图可以看到,在执行上下文创建完成,刚进入运行阶段时,a1 输出 undefined
,a2 报错。虽然 a2
已经被初始化,但最终效果和标准一致,在尚未执行第 6 行的赋值语句之前,a2
不允许被访问,报错。
第二步
当代码执行到第7行时,进入 foo 的函数执行上下文。
上图可以看到,当代码执行到第 11 行,foo
执行上下文的编译阶段已经完成,刚进入运行阶段。此时已经完成对执行上下文的创建和初始化。此时,不仅处在 foo
函数作用域内,还处在 foo
内第一个块作用域中,所以也会对第一个块作用域中的变量进行提升。此时对 a11
,a22
,和 a111
进行声明和初始化。
第三步
当代码执行到第12行时,执行到第二个块级作用域时,对第二个块作用域中的变量进行提升。可以看到上图中,词法环境中,是一个块作用域的栈。第二个块作用域中的 let
变量已经声明,然后入栈。
第四步
当全部代码执行完毕,即将完成 foo 函数执行上下文的运行阶段,以及全局执行上下文的运行阶段时,可以看到调用栈如上图所示。
引用
《你不知道的JavaScript》
winter - 重学前端
李兵 - 浏览器工作原理与实战