JavaScript 的同步执行过程

JavaScript 的同步执行过程

最后更新于 2020-05-24

在之前的文章当中,我们梳理了 JavaScript 中的作用域JavaScript 中的闭包 相关内容,其中涉及到一些作用域链,[[Scopes]]VO/AO 等可能理解起来比较隐晦的内容,所以在本章当中,我们就从头开始重新的梳理一下这方面的相关内容,也算是针对 JavaScript 当中的作用域以及闭包的一个更为深层次的梳理,下面就让我们来一起看看 JavaScript 当中的的执行过程到底是怎样的

这里在这里需要注意,本章当中我们主要关注的是 JavaScript 的同步执行过程,关于其异步执行过程的相关内容可以参考之前整理过的 JavaScript 并发模型

EC 概念结构

我们在之前的 JavaScript 中的作用域 当中的执行上下文环境部分曾经提到过,JavaScript 在执行一个代码段之前,都会进行这些『准备工作』来生成执行上下文,这其中就涉及到了变量对象和活动对象相关概念,而在执行 JavaScript 代码时,会有数不清的函数调用次数,自然就会产生许多个上下文环境,而这些则主要依赖『执行上下文栈』来帮助我们进行管理,以及销毁而释放内存的

而这些其实就是我们将要介绍的 EC 结构,它的结构是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
{
VO/AO:{
arguments?: ArrayLike<ANY>,
[declarations]: ANY,
},
[[Scopes]]: [
Scope {}, // * 父级作用域
Scope {}, // * 多级父级作用域
Global {}, // * 栈底是全局作用域
],
this: {} || undefined,
}

是不是感觉比较熟系,有我们知道的变量对象(Variable ObjectVO)和活动对象(Activation ObjectAO),以及作用域 [[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
2
3
ECStack = [
globalContext
]

而下面的 Code 类型则对应着我们之前提到过的『准备工作』当中的三种情况,分别是全局代码,函数体 和 Eval,其实简单总结一下,这个过程就是一个初始化的过程,下面我们再来看看比较重要的 EC 生命周期

EC 生命周期

我们都知道在代码执行的时候,JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行,当执行一段代码的时候,会进行一个准备工作,就比如之前我们提到过的的变量提升和函数提升,这其实就是所谓的执行上下文

执行全局代码时,会产生一个执行上下文环境,每次调用函数都又会产生执行上下文环境,当函数调用完成时,这个上下文环境以及其中的数据都会被消除,再重新回到全局上下文环境,处于活动状态的执行上下文环境只有『一个』,其实简单来说,就是一个『压栈』==>『出栈』的过程,这其实也就对应着我们 EC 生命周期当中的三个过程

  • Creation(准备环境,创建并入栈一个新 EC
  • Execution(执行代码)
  • Finished(执行结束,出栈 EC

不过这里有一点需要注意,那就是若函数里面是多层函数嵌套,也会出现多层执行上下文的嵌套(压栈和出栈也是嵌套产生的),我们上面提及到的三个过程只是较为理想的情况,另外还存在一种情况是无法做到这样干净利落的说销毁就销毁,而这种情况就是闭包,但是关于闭包的概念结构我们会在下面来进行介绍,下面我们就先来看看 EC 生命周期当中的三个过程

Creation(准备环境,创建并入栈一个新 EC)

这个过程分为三个步骤

  • 创建当前 ECVO/AO
  • 创建当前 EC 的作用域链(是根据词法作用域解析得到的,和 Callback Stack 是两回事)
  • 创建当前 ECthisObjectThis || global || window || undefined

其中关于第一点是较为重要的,我们详细来看看,其实在创建当前 ECVO/AO 的过程当中,不仅仅只是创建,它还会涉及到 VO 填充的过程,主要有以下三点

  1. 函数参数(若为传入,初始化该参数值为 undefined
  2. 函数声明(若发生命名冲突,会覆盖)
  3. 变量声明(初始化变量值为 undefined,若发生命名冲突,会忽略)

对应到我们的生命周期则是

  • 创建 arguments 对象
  • Hoisting(提升、声明解析)
    • 映射 arguments 的形参,可以视为 var 声明,在提升阶段时一同加入分析,如果有函数体代码有同名 function,则 function 总是优先,丢弃了传入的实参
    • 不同类型的声明(根据代码分类)
      • Function 声明 (FunctionDeclaration
      • 变量声明 (VariableDeclaration
      • Class 声明 (ClassDeclaration

而这其中的 Hoisting 过程便是我们熟知的变量提升的过程,它会根据不同类型的声明分别进行不同的处理

  • function 总是优先提升
    • 函数表达式(FunctionExpression)如 var fn = function () {} 中的函数是一个表达式语句,不是声明,例子中只会提升变量 fn 的声明,不会提升函数
    • function 实际上是对象,函数名就是 Identifier(类似 var),但也提升函数体
  • var 只提升声明,不提升赋值,初始化为 undefined
  • letconstClass 只提升声明,不提升赋值,内部标记初始化为『未初始化』
    • 在执行到声明代码所在行之前就调用,就会产生报错(因为未初始化),这个现象称之为『暂时性死区』
  • 同名声明(Identifier
    • functionvar(视为)总是能覆盖,其中 function 以最后一个为准(带函数体),而 var 由于只提升声明,所以覆盖不覆盖无所谓(视为忽略)
    • 但同名问题一旦涉及 ES6 新语法(letconstClass),则会报错

这里我们也需要注意提升幅度

  • var(函数级)
  • letconstClass(块级)
  • function(块级 + 函数级),严格模式只提升到块级作用域,非严格模式,除了块级提升,也会同时提升到函数作用域(旧标准特性)

另外需要注意的就是,如果不加 var 的且上下文没有该变量赋值操作会(隐式地)声明成全局变量(Global/Window),严格模式报错 ReferenceError

Execution(执行代码)

其实简单来说,在 JavaScript 中,所有代码大致都可理解为三个部分,即『左侧 ==> 操作 ==> 右侧』

  • 左右两个部分都或可继续进行拆分(递归,也就形成了 AST 中的 Tree 的结构)
  • 每句代码执行时(如『赋值的变量名』或『函数名』)先对左侧进行标识查找
    • 左侧部分可能是 IdentifierMemberExpression
    • 查找失败则 ReferenceErrorTypeError
  • 然后以相似的过程解析右侧(如『赋值的值』或『函数参数』)
  • 然后基于解析完的左右侧,执行相应的操作(如『执行赋值操作』或『进入函数调用流程』)

简单总结就是,查找变量先直接查找当前 VO/AO,如果找不到则基于当前作用域链依次向上查找,依然找不到则失败报错

这里关于语法顺序相关内容我们简单提及一二,在很多编程语言当中都有类似 JavaScript 当中的『中缀的语法顺序』,但有些语言可能有不同的顺序,如 Lisp 的顺序是前缀的,即『操作符 ==> 左侧 ==> 右侧』

1
2
3
4
5
// JavaScript
3 > 2 === true

// Lisp
EQUAL (> 3 2) T

Finished(执行结束,出栈 EC)

也就是卸载过程

  • 显式的 Return Statement 或隐式的(视为 return undefined),return 值将用于上一层相应位置(调用处)
  • 卸载当前上下文,卸载时可能会产生闭包
  • 继续执行上一层后续代码

一些细节

下面我们来看一些其中涉及到的细节,加深一下理解

  • arguments
    • 不使用高级特性(严格、默认值、剩余运算符)且有非空实参时,会跟踪形参数值(双向同步),否则不会
    • 默认参数只对 undefined 实参有效,其他 falsy 值不会判断
    • 箭头函数的 arguments 是绑定词法作用域的父级的 arguments
  • length
    • someArray.length,数组长度,修改直接影响数组表现
    • arguments.length,实参个数,修改后影响类数组操作时的表现
    • someFn.length,必要形参个数(不包含默认和剩余参数),修改无效果
    • window.lengthiframe 个数,修改后不再表示 iframe 计数
    • global.lengthundefined,未定义变量
  • 箭头函数
    • 没有自己的 this/arguments,所谓的 this/arguments 是词法作用域中的 this(相当于创建时自动 bind 父级环境中的 this/arguments
    • 所以也不能进行 bindcall,但因为依然还是函数,所以支持闭包的特性
  • this
    • Global Code 直接读取 this == window(浏览器),如果是 module 的情况,则 this == module.exports(初始是 {},且不会像 arguments 一样进行跟踪)
    • functionEval 会创建新的 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
2
3
4
5
6
7
8
9
10
11
fn {
prototype: {
constructor: fn,
},
// * 作用域链(解构就像正常执行时那样)
[[Scopes]]: [
Closure {}, // * 闭包
Closure {}, // * 根据作用域链的概念,可能存在多级闭包
Global {},
]
}

而闭包对于性能的影响,主要有两点,也就是时间和空间

  • 时间
    • 减少执行时间(变量值的解析),闭包中的变量由于已经被解析完成,驻于内存(直到函数销毁垃圾回收),所以相同逻辑下能够提高执行速度
    • 增加变量查找时间,显然闭包需要多一层或者几层变量查找动作,但根据不同浏览器的优化,一般情况下,这个耗时或可忽略不计
  • 空间
    • 占用内存空间(直到所涉及的函数本体被回收)

所以我们在某些场景下可以利用闭包来进行空间换时间的操作,最后我们在通过一个简单的示例看下闭包的使用,更多关于闭包的内容可以参考 JavaScript 中的闭包 这篇文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var AA = ((a1) => {
var a2 = 2
return function (a4) {
var a3 = 3
console.warn([a1, a2, a3, a4])
// * nest scope search test
// console.warn([b1, b2])
console.warn(this)
}
})(1)

AA('direct call')

var obj = { AA }
obj.AA('obj call');

(() => {
var b1 = 'q';
(() => {
var b2 = 'w'
console.warn([b1, b2])
AA('nested scope call')
})()
})()

JavaScript 并不支持动态作用域,所以生成闭包的时候需要解析和固定当前所有(所需)变量,比如在上面的示例当中,变量 b1b2 就并不会携带进闭包中(因为在那时的 ECS 中本来也不存在),其实从任意处调用,观察到调用时的作用域依然是相同的(闭包)结构,如果我们将注释掉的那一行代码放开,将会在控制台看到到 Uncaught ReferenceError: b1 is not defined

代码文件的上下文

我们在上面了解了一些 JavaScript 标准的原理和特性,但是在实际的代码文件中,在不同运行环境下,会拥有略有不同的执行环境,下面我们就来看简单的了解一下在各个不同环境下的情况

这里需要注意,下文中的顶层是层级的层,指代码文件中的顶层书写层级(不在函数体或块中的)

HTML 中的情况

其实也就是 <script> 标签,无论是 src 引用,还是直接位于标签内部的 JavaScript 代码,每个 <script> 的顶层代码都位于 Global 层(ECS 栈底),在页面打开后,显然 WindowGO)总是存在(直到页面关闭),所以每组代码都共用同一个 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

GlobalVO 是引擎提供的 Global/Window 对象,当位于顶层时,ES5 旧标准的 varfunction 声明会成为 Global 对象的属性

ES6 新标准中,对新加入的关键字 letconstClass 进行了调整,如果是这些声明在顶层,会创建一层新的 EC 层来存放变量,避免直接挂载到 Global 上(Chrome 中可以直接观察到该 EC 类名为 Script

相关词汇

在这里简单整理汇总一下以上内容当中涉及到的一些词汇,加深理解

  • ECExecution Context),执行上下文,包含当前词法环境中的变量等信息
  • ECSExecution Context Stack),执行上下文栈,包括调用时产生的父级环境的一组 EC
  • Hoisting(声明),提升(为了预分配内存空间)
  • Scope,作用域(可能是 GlobalClosureBlock 等)
  • Scope Chain,作用域链,存在于 EC 中,在浏览器内或表现为 [[Scopes]]: Array
  • Closure,闭包,视为持久化的作用域
  • VOVariable Object),变量对象,每个 EC 的一部分,存放变量的地方
  • AOActivation Object),活动对象,可视为函数级作用域中的 VO (多了 arguments
  • GOGlobal Object),特指 Global 层的 VO
  • Stack Frame,栈帧,指单个 EC
  • Stack Overflow,执行栈溢出(函数嵌套调用深度过大达到引擎设定的上限)
  • SegfaultSegmentation Fault),段错误(访问非法内存地址)
  • TCOTail Call Optimization),尾调用优化(如果函数最后一句是另一个调用,则直接替换而不是入栈)
  • TDZTemporal Dead Zone),暂时性死区(let、const 某特性的民间称呼)
  • JITJust in time),及时化(运行时逐字解析编译)
  • AOTAhead of Time),预处理(全部编译完再运行)
  • IIFEImmediately Invoked Function Expression),立即执行函数表达式,如 (() => { })()

以下是作用域当中涉及的一些词汇,不过针对于作用域,由于函数有一层自己的作用域,可以利用 IIFE 来对代码过程进行局部封装,以便更好地管理变量

  • Lexical Scope,词法作用域(以代码字面结构为依据的解析,不会根据调用位置而动态改变)
  • Static Scope,静态作用域(词法作用域)
  • Dynamic Scope,动态作用域(ECMAScript 包括大多数编程语言中都不采用)
  • Global Scope,全局作用域
  • Function Scope,函数作用域
  • Block Scope,块级作用域
    • with(严格模式禁止,有变量指向歧义,避免使用,可以用解构代替)
    • try-catch
    • letconst

参考

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×