尽管很多开发者每天都在使用 JavaScript,却不知道这背后发生了什么。本文作为是【进阶系列】文章的第一篇,旨在深入探讨调用栈与执行上下文
。
前言
先来看下栈的定义:栈是一种后进先出(LIFO)的数据结构.
那么延伸一下,调用栈就可以理解为:用来管理函数调用关系的一种数据结构
。这里面会记录我们在程序调用过程中的大概位置。当程序执行进入一个函数,把它置于栈的顶部,如果从函数中返回(或执行到 return语句)则从栈顶部移除函数。那为什么先讲执行上下文呢?它这么重要吗?可以这么说,只有理解了 JavaScrip 的执行上下文,你才能更好地理解 JavaScript 这门语言本身。
什么是调用栈?
正如我们所知道的,javascript解释器被实现为单线程,同一时间只能处理一个任务,JS 程序中多个执行环境通过栈的方式来管理,这个栈叫做执行栈(调用栈Call Stack
)。在函数编程中,通常代码中会声明多个函数,函数调用会在内存形成一个调用记录
,又称调用帧
(call frame),用来保存调用位置和内部变量等信息(执行上下文
),因此对应的执行上下文也会存在多个。
举个简单例子:
如果在函数 A 的内部又调用函数 B,那么在 A 的调用记录上方,还会形成一个 B 的调用记录。等到 B 运行结束,将结果返回到 A,B 的调用记录才会消失。如果函数 B 内部还调用函数 C,那就还有一个 C 的调用记录栈,以此类推。所有的调用记录,就形成一个调用栈。
function A() {
B();
}
function B() {
C()
}
function C() {
let aa = 1;
console.trace()
}
A()
了解调用栈有什么好处?
了解调用栈至少有以下四点点好处:
- 帮助你了解 JavaScript 引擎背后的工作原理(执行上下文相关);
- 调试 JavaScript 代码的能力;
- 避免栈溢出(Stack Overflow): 调用栈是有大小的,递归调用避免栈溢出
- 帮助你搞定面试,因为面试过程中,调用栈也是出境率非常高的题目。
什么是执行上下文(Execution Context)?
当我们的执行一段代码时,会进入到不同的执行环境(Global Code、Function Code或 eval)。在不同的执行环境中,有着不同的 scope(作用域),代码所能访问到的资源也就不同。 所以可以这么理解:执行上下文是javascript执行一段代码的运行环境。执行上下文是一个逻辑上的堆栈结构(Stack)。堆栈中最顶层的执行上下文就是正在运行的执行上下文。
再来对比看下ES5 规范 如何定义的:
An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation
执行上下文的类型
根据可执行代码类型,执行上下文分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文
执行上下文类 | 执行环境 | 描述 |
---|---|---|
全局执行上下文 | 全局代码 | 代码默认运行的环境,代码执行时会首先进入全局执行上下文的环境中 |
函数执行上下文 | 函数代码 | 函数被调用执行时,所创建的执行执行环境 |
eval 函数执行上下文 | eval 代码 | (已不推荐使用) |
当某个执行环境中的所有代码执行完毕后,该段代码的的执行上下文也会被销毁,即对应的变量和函数也随之销毁(内存回收)
执行上下文的组成
前端这些年迅猛发展,规范也在快速迭代升级,从ES3到ES5,再到如今的ES6。同样地,执行上下文定义以及JS引擎的实现也在相应发生变化,如果不看ECMAScript规范文档,只从别人博客文档粗略了解,不免给我们造成困扰,到底哪个是正确的。这里梳理了几个阶段,笔者对执行上下文的理解(有不当之处,请指出)
执行上下文在 ES3 中,包含三个部分:
组成 | 作用目的 |
---|---|
变量对象/活动对象 | 用于存储当前环境的变量信息,变量对象和活动对象其实是一个东西,只不过处于不同的状态和阶段而已(当函数进入执行阶段时,原本不能访问的变量对象被激活成为一个活动对象) |
作用域链 | 由多个执行上下文的变量对象构成的链表。当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象 |
this | 指定该执行环境内的 ECMA 脚本代码中 this 关键字所关联的值 |
用伪代码实现如下:
// Execution context in ES3
ExecutionContext = {
[variable object | activation object]:{ // 变量对象、激活对象
arguments,
variables: [...],
funcions: [...]
},
scope chain: variable object + all parents scopes // 作用域链
thisValue: context object
}
在 ES5 中,我们改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。
组件 | 作用目的 |
---|---|
词法环境组件 | 指定一个词法环境对象,用于解析该执行环境内的代码创建的标识符引用 |
变量环境组件 | 指定一个词法环境对象,其环境数据用于保存由该执行环境内的代码通过 VariableStatement 和 FunctionDeclaration 创建的绑定 |
this 绑定 | 指定该执行环境内的 ECMA 脚本代码中 this 关键字所关联的值 |
用伪代码实现如下:
// Execution context in ES5
ExecutionContext = {
LexicalEnvironment:{// 词法环境,当获取变量时使用。
OuterEnv:< ... >,
EnvironmentRecord:<...>
},
VariableEnvironment:{ // 变量环境,当声明变量时使用。
OuterEnv:< ... >,
EnvironmentRecord:<...> // 环境记录, 标识符绑定在这里
},
ThisBinding:<...> //
}
在 ES2018(通俗地讲 ES6) 中,执行上下文又变成了这个样子,this 值被归入 lexical
// Execution context in ES6
// 还未深入,有空补上
ExecutionContext = {
environment,但是增加了不少内容。
lexical environment:词法环境,当获取变量或者 this 值时使用。
code evaluation state:用于恢复代码执行位置。
Function:执行的任务是函数时使用,表示正在被执行的函数。
ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
Realm:使用的基础库和内置对象实例。
Generator:仅生成器上下文有这个属性,表示当前生成器。
}
执行上下文的生命周期
上面提到某个执行环境中的所有代码执行完毕后,该环境的执行上下文也会被销毁,这表明执行上下文是存在生命周期的。
执行上下文生命周期包含两个阶段: 编译阶段
和执行阶段
。
在编译阶段,变量和函数会被存放到变量环境(Viriable Environment)
中,变量的默认值会被设置为 undefined;
在代码执行阶段,JavaScript 引擎会从(变量环境
/词法环境
)中去查找自定义的变量和函数
文字怎么阅读都有点抽象、生涩,让我们跑一段代码来加深对它的理解:
console.log(bar) // (1)
var bar = 'this is bar' // (2)
function foo() {
console.log('bar'); // (4)
}
foo() // (3)
先来看看结果
// 输出 undefined
// 输出 this is foo
小试牛刀,使用ES3规范定义的执行上下文来分析,第一步 创建阶段(变量提升)
globalExecutionContext = {
VO: {
bar: undefined,
foo: function foo(){...}
},
scopeChain: [],
this: { ... }
}
第二步 执行阶段
ExecutionStack = [ globalExecutionContext ]
globalExecutionContext = {
AO: { // VO => AO
bar: undefined,
foo: function foo(){...}
},
scopeChain: [],
this: <...>
}
// 执行到 (1)处,访问bar,返回undefined 输出
// 执行到(2)处 ,变量bar被赋值
globalExecutionContext = {
AO: { // VO => AO
bar: 'this is bar',
foo: function foo(){...}
},
scopeChain: [AO],
this: <...>
}
// 执行到(3)处, 函数被调用,函数执行上下文入栈
fooExecutionContext = {
VO: {
arguments: <...>
},
scopeChain: [globalExecutionContext.AO],
this: <...>
}
ExecutionStack = [ globalExecutionContext,fooExecutionContext]
// 执行到(4)处, 当前执行上下文 fooExecutionContext未找到变量bar,沿着作用域链,在globalExecutionContext.AO中查到已经被赋值的bar,输出'this is bar'
// foo函数执行结束,fooExecutionContext 从调用栈中移除
ExecutionStack = [globalExecutionContext]
结语
本文主要回答了这几个方面的问题:
Q:什么是调用栈? A:一种存储执行上下文(保存代码位置及变量信息)的数据结构。
Q: 什么是执行上下文? A:执行上下文是javascript执行一段代码的运行环境,对于不同代码对应三种类型执行上下文,分别为全局执行上下文,函数执行上下文、eval执行上下文(不推荐)。
Q:执行上下文的组成? A:分别介绍了ES3、ES5、ES6 三个规范对执行上下文的定义,梳理解释了当前造成前端小白对执行上下文理解偏差、混乱的原因。
本文其实还有很多概念未详细展开,如作用域和闭包、变量环境、词法环境、环境记录。下篇文章将详细讲解作用域和闭包