最后更新于
2020-05-24
在之前的文章当中,我们梳理了 JavaScript 中的作用域 和 JavaScript 中的闭包 相关内容,其中涉及到一些作用域链,[[Scopes]]
,VO/AO
等可能理解起来比较隐晦的内容,所以在本章当中,我们就从头开始重新的梳理一下这方面的相关内容,也算是针对 JavaScript
当中的作用域以及闭包的一个更为深层次的梳理,下面就让我们来一起看看 JavaScript
当中的的执行过程到底是怎样的
这里在这里需要注意,本章当中我们主要关注的是 JavaScript
的同步执行过程,关于其异步执行过程的相关内容可以参考之前整理过的 JavaScript 并发模型
EC 概念结构
我们在之前的 JavaScript 中的作用域 当中的执行上下文环境部分曾经提到过,JavaScript
在执行一个代码段之前,都会进行这些『准备工作』来生成执行上下文,这其中就涉及到了变量对象和活动对象相关概念,而在执行 JavaScript
代码时,会有数不清的函数调用次数,自然就会产生许多个上下文环境,而这些则主要依赖『执行上下文栈』来帮助我们进行管理,以及销毁而释放内存的
而这些其实就是我们将要介绍的 EC
结构,它的结构是下面这样的
1 | { |
是不是感觉比较熟系,有我们知道的变量对象(Variable Object
,VO
)和活动对象(Activation Object
,AO
),以及作用域 [[Scopes]]
,但是在这里我们也要明确这些容易混淆的概念之间的区别,因为它们看上去都是栈或数组的形式,而且随着代码运行和函数调用,也都会产生入栈出栈动作,但是它们都是不同的东西
Callback Stack
,调用栈(概念),函数调用时产生的进度信息,当子过程结束时需要继续执行父过程Execution Context Stack
,执行上下文栈,包含一组EC
,是Callback Stack
背后的实际数据结构,用于过程管理Scope Chain
,作用域链,是每个EC
的一部分,包含一组词法作用域父级,用于外部变量查找Closure
,闭包,视为作用域链的持久化的快照 引用外层变量
下面我们就来简单的梳理一下 Program
生命周期和 EC
生命周期
Program 生命周期
它的执行流程是下面这样的
- 创建
ECS
- 开始
Global EC
流程(Global Code
) Code
类型Global Code
,产生ECS
的第一个EC
,唯一顶层全局EC
Function Code
,将创建并入栈一个新EC
Eval Code
,根据浏览器不同(另有性能和安全问题,避免使用)
当 JavaScript
开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,这里我们使用 globalContext
来表示它
并且只有当整个应用程序结束的时候,ECStack
才会被清空,所以程序结束之前,ECStack
最底部永远有个 globalContext
,所以对应的 ECS
大致是下面这样的
1 | ECStack = [ |
而下面的 Code
类型则对应着我们之前提到过的『准备工作』当中的三种情况,分别是全局代码,函数体 和 Eval
,其实简单总结一下,这个过程就是一个初始化的过程,下面我们再来看看比较重要的 EC
生命周期
EC 生命周期
我们都知道在代码执行的时候,JavaScript
引擎并非一行一行地分析和执行程序,而是一段一段地分析执行,当执行一段代码的时候,会进行一个准备工作,就比如之前我们提到过的的变量提升和函数提升,这其实就是所谓的执行上下文
执行全局代码时,会产生一个执行上下文环境,每次调用函数都又会产生执行上下文环境,当函数调用完成时,这个上下文环境以及其中的数据都会被消除,再重新回到全局上下文环境,处于活动状态的执行上下文环境只有『一个』,其实简单来说,就是一个『压栈』==>『出栈』的过程,这其实也就对应着我们 EC
生命周期当中的三个过程
Creation
(准备环境,创建并入栈一个新EC
)Execution
(执行代码)Finished
(执行结束,出栈EC
)
不过这里有一点需要注意,那就是若函数里面是多层函数嵌套,也会出现多层执行上下文的嵌套(压栈和出栈也是嵌套产生的),我们上面提及到的三个过程只是较为理想的情况,另外还存在一种情况是无法做到这样干净利落的说销毁就销毁,而这种情况就是闭包,但是关于闭包的概念结构我们会在下面来进行介绍,下面我们就先来看看 EC
生命周期当中的三个过程
Creation(准备环境,创建并入栈一个新 EC)
这个过程分为三个步骤
- 创建当前
EC
的VO/AO
- 创建当前
EC
的作用域链(是根据词法作用域解析得到的,和Callback Stack
是两回事) - 创建当前
EC
的this
(ObjectThis || global || window || undefined
)
其中关于第一点是较为重要的,我们详细来看看,其实在创建当前 EC
的 VO/AO
的过程当中,不仅仅只是创建,它还会涉及到 VO
填充的过程,主要有以下三点
- 函数参数(若为传入,初始化该参数值为
undefined
) - 函数声明(若发生命名冲突,会覆盖)
- 变量声明(初始化变量值为
undefined
,若发生命名冲突,会忽略)
对应到我们的生命周期则是
- 创建
arguments
对象 Hoisting
(提升、声明解析)- 映射
arguments
的形参,可以视为var
声明,在提升阶段时一同加入分析,如果有函数体代码有同名function
,则function
总是优先,丢弃了传入的实参 - 不同类型的声明(根据代码分类)
Function
声明 (FunctionDeclaration
)- 变量声明 (
VariableDeclaration
) Class
声明 (ClassDeclaration
)
- 映射
而这其中的 Hoisting
过程便是我们熟知的变量提升的过程,它会根据不同类型的声明分别进行不同的处理
function
总是优先提升- 函数表达式(
FunctionExpression
)如var fn = function () {}
中的函数是一个表达式语句,不是声明,例子中只会提升变量fn
的声明,不会提升函数 function
实际上是对象,函数名就是Identifier
(类似var
),但也提升函数体
- 函数表达式(
var
只提升声明,不提升赋值,初始化为undefined
let
、const
、Class
只提升声明,不提升赋值,内部标记初始化为『未初始化』- 在执行到声明代码所在行之前就调用,就会产生报错(因为未初始化),这个现象称之为『暂时性死区』
- 同名声明(
Identifier
)function
、var
(视为)总是能覆盖,其中function
以最后一个为准(带函数体),而var
由于只提升声明,所以覆盖不覆盖无所谓(视为忽略)- 但同名问题一旦涉及
ES6
新语法(let
、const
、Class
),则会报错
这里我们也需要注意提升幅度
var
(函数级)let
、const
、Class
(块级)function
(块级 + 函数级),严格模式只提升到块级作用域,非严格模式,除了块级提升,也会同时提升到函数作用域(旧标准特性)
另外需要注意的就是,如果不加 var
的且上下文没有该变量赋值操作会(隐式地)声明成全局变量(Global/Window
),严格模式报错 ReferenceError
Execution(执行代码)
其实简单来说,在 JavaScript
中,所有代码大致都可理解为三个部分,即『左侧 ==> 操作 ==> 右侧』
- 左右两个部分都或可继续进行拆分(递归,也就形成了
AST
中的Tree
的结构) - 每句代码执行时(如『赋值的变量名』或『函数名』)先对左侧进行标识查找
- 左侧部分可能是
Identifier
或MemberExpression
等 - 查找失败则
ReferenceError
或TypeError
等
- 左侧部分可能是
- 然后以相似的过程解析右侧(如『赋值的值』或『函数参数』)
- 然后基于解析完的左右侧,执行相应的操作(如『执行赋值操作』或『进入函数调用流程』)
简单总结就是,查找变量先直接查找当前 VO/AO
,如果找不到则基于当前作用域链依次向上查找,依然找不到则失败报错
这里关于语法顺序相关内容我们简单提及一二,在很多编程语言当中都有类似 JavaScript
当中的『中缀的语法顺序』,但有些语言可能有不同的顺序,如 Lisp
的顺序是前缀的,即『操作符 ==> 左侧 ==> 右侧』
1 | // JavaScript |
Finished(执行结束,出栈 EC)
也就是卸载过程
- 显式的
Return Statement
或隐式的(视为return undefined
),return
值将用于上一层相应位置(调用处) - 卸载当前上下文,卸载时可能会产生闭包
- 继续执行上一层后续代码
一些细节
下面我们来看一些其中涉及到的细节,加深一下理解
arguments
- 不使用高级特性(严格、默认值、剩余运算符)且有非空实参时,会跟踪形参数值(双向同步),否则不会
- 默认参数只对
undefined
实参有效,其他falsy
值不会判断 - 箭头函数的
arguments
是绑定词法作用域的父级的arguments
length
someArray.length
,数组长度,修改直接影响数组表现arguments.length
,实参个数,修改后影响类数组操作时的表现someFn.length
,必要形参个数(不包含默认和剩余参数),修改无效果window.length
,iframe
个数,修改后不再表示iframe
计数global.length
,undefined
,未定义变量
- 箭头函数
- 没有自己的
this/arguments
,所谓的this/arguments
是词法作用域中的this
(相当于创建时自动bind
父级环境中的this/arguments
) - 所以也不能进行
bind
和call
,但因为依然还是函数,所以支持闭包的特性
- 没有自己的
this
Global Code
直接读取this == window
(浏览器),如果是module
的情况,则this == module.exports
(初始是{}
,且不会像arguments
一样进行跟踪)function
或Eval
会创建新的this
(新的EC
),裸块(只有花括号)不会- 方法调用
this == Host Object
- 函数调用
this == global/window
,但是严格模式下this == undefined
,因为严格来讲没有宿主 - 箭头函数没有自己的
this
,它的this
等同于绑定词法作用域的父级的this
- 方法调用
- 严格模式
- 作用域(
use strict
的影响范围)是函数级的
- 作用域(
以上,当我们了解了 Program
生命周期和 EC
生命周期相关内容以后在回过头来看 JavaScript 中的作用域 和 JavaScript 中的闭包 当中的相关内容就清晰许多
闭包
闭包我们可以理解为函数所需的『作用域链』的持久化的快照,由于 JavaScript
中的函数可以作为变量传递,所以当函数注册后,若所在位置发生改变,ECS
就会发生改变,函数的执行效果就会变得不可控(这将违反静态作用域的特性)
为了解决这个问题,引擎会对函数体中的变量进行词法解析,将当前『作用域链』(保留所需的变量)转为闭包,(隐式地)标记到函数上,以便函数能够正常工作,传统的 function
将排除 this/arguments
,因为根据执行流程,这些值在每次函数调用时都重新生成,而箭头函数将会绑定这两个值(根据箭头函数的的特性,它们本来就是绑定的)
当函数调用时,使用闭包作为当前的作用域链(用于变量查找),JavaScript
的闭包特性是引擎的内部实现,无法通过 JavaScript
代码显式操控,但是根据模块化和 Webpack
打包的原理,显然每个 Module
中的函数基本都有自己的闭包,这也说明闭包基本上无处不在
虽然闭包是一个正常的 JavaScript
特性,但是我们还是需要注意正确的使用以避免内存泄露(毕竟 JavaScript
没有显式的垃圾回收,以及闭包无法直接操控),下面我们就先来看看它的概念结构
1 | fn { |
而闭包对于性能的影响,主要有两点,也就是时间和空间
- 时间
- 减少执行时间(变量值的解析),闭包中的变量由于已经被解析完成,驻于内存(直到函数销毁垃圾回收),所以相同逻辑下能够提高执行速度
- 增加变量查找时间,显然闭包需要多一层或者几层变量查找动作,但根据不同浏览器的优化,一般情况下,这个耗时或可忽略不计
- 空间
- 占用内存空间(直到所涉及的函数本体被回收)
所以我们在某些场景下可以利用闭包来进行空间换时间的操作,最后我们在通过一个简单的示例看下闭包的使用,更多关于闭包的内容可以参考 JavaScript 中的闭包 这篇文章
1 | var AA = ((a1) => { |
JavaScript
并不支持动态作用域,所以生成闭包的时候需要解析和固定当前所有(所需)变量,比如在上面的示例当中,变量 b1
、b2
就并不会携带进闭包中(因为在那时的 ECS
中本来也不存在),其实从任意处调用,观察到调用时的作用域依然是相同的(闭包)结构,如果我们将注释掉的那一行代码放开,将会在控制台看到到 Uncaught ReferenceError: b1 is not defined
代码文件的上下文
我们在上面了解了一些 JavaScript
标准的原理和特性,但是在实际的代码文件中,在不同运行环境下,会拥有略有不同的执行环境,下面我们就来看简单的了解一下在各个不同环境下的情况
这里需要注意,下文中的顶层是层级的层,指代码文件中的顶层书写层级(不在函数体或块中的)
HTML 中的情况
其实也就是 <script>
标签,无论是 src
引用,还是直接位于标签内部的 JavaScript
代码,每个 <script>
的顶层代码都位于 Global
层(ECS
栈底),在页面打开后,显然 Window
(GO
)总是存在(直到页面关闭),所以每组代码都共用同一个 Window
,也就是 this == window
但不同的 <script>
相当于不同的 Program
任务,都会创建一套独立的完整的 Program
生命周期(当然其中的 GO
还是同一个),所以其中一个 <script>
报错中断也不会影响后续 <script>
执行
Node.js 的情况
通过 Node.js
命令直接启动环境后,直接位于 Global
层,即 this == global
,而通过 Node.js
当中的 filename.js
命令执行代码以后,则会以模块化的形式读取和执行文件,Node.js
将使用内建的包装函数加载文件代码,这时文件中的顶层代码并不位于 Global
层,此时 this == module.exports
Webpack 打包的情况
虽然一般用途也是在浏览器运行,但是类似 Node.js
中的模块化,Webpack
也有一个加载器来加载代码(JavaScript
实现的)
每个 Module
的顶层代码并不位于 Global
层,所以同样的 this == module.exports
,但是打包会默认会加上 use strict
,所以默认 this == undefined
顶层声明挂载到 Global
Global
的 VO
是引擎提供的 Global/Window
对象,当位于顶层时,ES5
旧标准的 var
、function
声明会成为 Global
对象的属性
而 ES6
新标准中,对新加入的关键字 let
、const
、Class
进行了调整,如果是这些声明在顶层,会创建一层新的 EC
层来存放变量,避免直接挂载到 Global
上(Chrome
中可以直接观察到该 EC
类名为 Script
)
相关词汇
在这里简单整理汇总一下以上内容当中涉及到的一些词汇,加深理解
EC
(Execution Context
),执行上下文,包含当前词法环境中的变量等信息ECS
(Execution Context Stack
),执行上下文栈,包括调用时产生的父级环境的一组EC
Hoisting
(声明),提升(为了预分配内存空间)Scope
,作用域(可能是Global
、Closure
、Block
等)Scope Chain
,作用域链,存在于EC
中,在浏览器内或表现为[[Scopes]]: Array
Closure
,闭包,视为持久化的作用域VO
(Variable Object
),变量对象,每个EC
的一部分,存放变量的地方AO
(Activation Object
),活动对象,可视为函数级作用域中的VO
(多了arguments
)GO
(Global Object
),特指Global
层的VO
Stack Frame
,栈帧,指单个EC
Stack Overflow
,执行栈溢出(函数嵌套调用深度过大达到引擎设定的上限)Segfault
(Segmentation Fault
),段错误(访问非法内存地址)TCO
(Tail Call Optimization
),尾调用优化(如果函数最后一句是另一个调用,则直接替换而不是入栈)TDZ
(Temporal Dead Zone
),暂时性死区(let、const 某特性的民间称呼)JIT
(Just in time
),及时化(运行时逐字解析编译)AOT
(Ahead of Time
),预处理(全部编译完再运行)IIFE
(Immediately Invoked Function Expression
),立即执行函数表达式,如(() => { })()
以下是作用域当中涉及的一些词汇,不过针对于作用域,由于函数有一层自己的作用域,可以利用 IIFE
来对代码过程进行局部封装,以便更好地管理变量
Lexical Scope
,词法作用域(以代码字面结构为依据的解析,不会根据调用位置而动态改变)Static Scope
,静态作用域(词法作用域)Dynamic Scope
,动态作用域(ECMAScript
包括大多数编程语言中都不采用)Global Scope
,全局作用域Function Scope
,函数作用域Block Scope
,块级作用域with
(严格模式禁止,有变量指向歧义,避免使用,可以用解构代替)try-catch
let
、const
参考
- 使用断点暂停代码
- 从 JavaScript 作用域说开去
- JavaScript 闭包
- 反思闭包
- 严格模式
- 块级作用域的函数提升
- The Ultimate Guide to Execution Contexts, Hoisting, Scopes, and Closures in JavaScript
- JavaScript Visualizer
- What is the Execution Context & Stack in JavaScript?
- JavaScript. The Core.
- temporal dead zone
- Tail call optimization in ECMAScript 6
- What is this?
- ECMAScript Language Specification
- ECMAScript compatibility table