JS的执行上下文
只要你是js开发员,大概率都能听到 执行上下文 这个概念,在我工作的几年里,我仍然对这个概念一知半解的。因此通过对别人文章和理解,特意写一下这篇文章记录一下我自己的收获,如有写错或者问题,欢迎留言指正。
什么是执行上下文
较为官方的概念就是:执行上下文是评估和执行JavaScript代码的环境的抽象概念。简单来说,执行上下文就是代码的执行环境。因此js的代码都是在执行上下文中执行的。
执行上下文还可以理解为:当JS引擎解析到有可执行的代码片段时,就会去进行一些执行之前的准备,这个 “准备工作” 就是执行上下文(Execution context 简称为:EC)。也可以被称为 执行环境
执行上下文的类型
执行上下文总共有三种类型,分别是:
-
全局执行上下文:默认的或者说是最基础的一种执行上下文(基本不在函数作用域内的代码,都属于全局执行上下文),一个程序中只会存在一个全局执行上下文。在整个js执行的生命周期内,全局执行上下文都会压在执行栈的栈底,不会被销毁。全局执行上下文主要的工作是:1.会生成一个全局对象(在浏览器内执行的时候,这个全局对象就是window对象),2.将this绑定到这个全局对象上。
-
函数执行上下文:可以从字面上的意思去理解:当一个函数被调用的时候,都会创建一个新的函数执行上下文(注意:每一次函数被调用都会去创建一个新的函数执行上下文)。
-
Eval执行上下文:Eval函数也存在自己的执行上下文,因为使用Eval函数有安全风险,所以一般不推荐使用Eval函数,因此不需要Eval函数的执行上下文有过多的了解。
E3的执行上下文
执行上下文是一个抽象的概念,它作为代码的执行环境,我们从它所包含的内容去了解它:
- 变量对象
- 活动对象
- 作用域链
- this的绑定
变量对象和活动对象
变量对象(variable object 简称: VO): 是每个执行环境用于存储变量的对象,全局执行环境的变量对象(对于浏览器来说就是window),是在代码的执行中一直存在的,而函数执行环境只存在函数执行过程中。在函数的具体调用过程前,函数的当前参数列表会对这个变量对象进行初始化,并且与当前的执行上下文进行关联(函数代码块中声明的 变量 和 函数 将作为属性添加到这个变量对象上)。
// 这种叫做函数声明,会被加入变量对象
function a () {}
// b 是变量声明,也会被加入变量对象,但是作为一个函数表达式 _b 不会被加入变量对象
var b = function _b () {}
需要注意的是:函数代码块内的变量声明或者函数声明可以被加入变量对象(VO)内,而函数表达式则不会变量提升,不会加入VO内。
活动对象(activation object 简称:AO): 活动对象本质上和变量对象是一个对象,当函数进入到执行阶段时,变量对象被激活变成一个活动对象。(活动对象和变量对象本质上是一个对象,只是生存在不同阶段)
函数执行上下文中的变量对象内部定义的属性,是不能被直接访问的,只有当函数被调用时,变量对象(VO)被激活为活动对象(AO)时,我们才能访问到其中的属性和方法。
作用域链
作用域大家都知道,作用域确定对作用域内变量的访问权限。当在作用域内寻找一个变量时,首先会先从当前的变量对象去寻找,找不到时会从上一层的作用域的变量对象去找,直至找到或者到全局的变量对象寻找。这种由多个执行上下文的变量对象构成的链表关系被叫做作用域链。
作用域和执行上下文的关系: 作用域是函数在被创建的时候就已经确定了,当函数被创建的时候,函数内部会有一个[scope]的内部对象,用于存储着上层执行上下文的变量对象。当函数被执行时,会创建一个执行环境(也就是说执行上下文),并且复制函数的[scope]对象来构建成执行环境的作用域链。当执行环境内的VO对象被激活成AO对象,并添加到作用域链的顶端。如果想知道这一块内容我觉得可以去看一下这篇文章理解 JS 作用域链与执行上下文解释的蛮不错的。
按照我的理解:当一个函数被执行的时候,一个执行环境被初始化,因此也将本作用域链进行确定。具体过程是 -> 初始化时,变量对象复制[scope]属性(内部保存着上层执行环境的变量对象),接着将变量对象激活成活跃对象,推到作用域链的顶端,完成作用域链的构成。因此寻找变量时,会有一层一层的寻找变量。
this的绑定
关于this的绑定,那么就取决于调用函数的对象是谁,或者是否用bind call apply等语法来进行显示绑定,并将调用者信息(this value)存入当前的执行上下文,否则默认是全局对象调用
具体可以了解的话可以去看一下大佬的文章this
执行上下文的结构
executionContext:{
[variable object | activation object]:{
arguments,
variables: [...],
funcions: [...]
},
scope chain: variable object + all parents scopes
thisValue: context object
}
执行上下文的生命周期
执行上下文的生命周期有三个阶段,分别是:
- 创建阶段
- 执行阶段
- 销毁阶段
创建阶段
执行上下文的创建是在函数刚开始被调用时,并且是并未真正开始执行其代码之前。创建阶段的主要几大点:
-
函数初始化时,函数的参数列表
arguments来初始化一个变量对象,将其与当前执行上下文进行关联。并通过变量提升的方式,将函数内的变量和函数添加到变量对象内。 -
作用域链的构建
-
this的绑定
执行阶段
执行阶段中,JS 代码开始逐条执行,在这个阶段,JS 引擎开始对定义的变量赋值、开始顺着作用域链访问变量、如果内部有函数调用就创建一个新的执行上下文压入执行栈并把控制权交出
销毁阶段
一般情况下:当函数执行完成后,当前执行上下文(局部环境)会被弹出执行上下文栈并且销毁,控制权被重新交给执行栈上一层的执行上下文。
闭包的情况:当闭包的父包裹函数执行完成后,父函数本身执行环境的作用域链会被销毁,但是由于闭包的作用域链仍然在引用父函数的变量对象,导致了父函数的变量对象会一直驻存于内存,无法销毁,除非闭包的引用被销毁,闭包不再引用父函数的变量对象,这块内存才能被释放掉。过度使用闭包会造成 内存泄露 的问题。
ES5 中的执行上下文
ES5规范又对ES3中执行上下文的部分概念做了调整,最主要的调整,就是去除了ES3中变量对象和活动对象,以 词法环境组件( LexicalEnvironment component)和变量环境组件( VariableEnvironment component)替代。因此执行上下文的的组成结构也变成了:
ExecutionContext = {
ThisBinding = <this value>,
LexicalEnvironment = { ... }, // 语法环境
VariableEnvironment = { ... }, // 变量环境
}
词法环境
ES6官方中的词法环境定义:
> 词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
简单来说 词法环境 是一种持有 标识符—变量映射 的结构。这里的 标识符 指的是变量/函数的名字,而 变量 是对实际对象(包含函数类型对象)或原始数据的引用(可以把它理解为 ES3 中的 变量对象,因为它们本质上做的是类似的事情)。
变量环境
变量环境 它也是一个 词法环境 ,所以它有着词法环境的所有特性。
之所以在
ES5的规范力要单独分出一个变量环境的概念是为ES6服务的: 在ES6中,词法环境组件和 变量环境 的一个不同就是前者被用来存储函数声明和变量(let 和 const)绑定,而后者只用来存储var变量绑定。
在上下文创建阶段,引擎检查代码找出变量和函数声明,变量最初会设置为undefined(var 情况下),或者未初始化(let 和 const 情况下)。这就是为什么你可以在声明之前访问var定义的变量(虽然是undefined),但是在声明之前访问let和const的变量会得到一个引用错误(形成暂时性死区)。
执行栈
执行栈,也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(先进后出) 数据结构的栈,被用来存储代码运行时创建的所有执行上下文。
当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。
引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文
让我们通过下面的代码示例来理解:
let 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');
当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到first()函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。
当从first()函数内部调用second()函数时,JavaScript 引擎为second()函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当second()函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即first()函数的执行上下文。
当first()执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。
递归和栈溢出
在了解了调用栈的运行机制后,我们可以考虑一个问题,这个执行上下文栈可以被无限压栈吗?很显然是不行的,执行栈本身也是有容量限制的,当执行栈内部的执行上下文对象积压到一定程度如果继续积压,就会报“栈溢出(stack overflow)”的错误。栈溢出错误经常会发生在 递归 中。
递归的使用场景,通常是在运行次数未知的情况下,程序会设定一个限定条件,除非达到该限定条件否则程序将一直调用自身运行下去。递归的适用场景非常广泛,比如累加函数:
// 求 1~num 的累加,此时 num 由外部传入,是未知的
function recursion (num) {
if (num === 0) return num;
return recursion(num - 1) + num;
}
recursion(100) // => 5050
recursion(1000) // => 500500
recursion(10000) // => 50005000
recursion(100000) // => Uncaught RangeError: Maximum call stack size exceeded
从代码中可以看到,这个递归的累加函数,在计算 1 ~ 100000 的累加和的时候,执行栈就崩不住了,触发了栈溢出的错误。