重学 JavaScript03——执行
简介
Javascript 代码运行在Javascript 引擎当中,当我们拥有一段JS代码时,宿主,也就是浏览器或者Node首先要将代码传递给Javascript 引擎,并要求其执行。也就是说,JS引擎常驻内存,等待宿主将JS代码或函数传递给它执行。
宿主一方面向JS引擎传递代码执行,一方面也提供API给Javascript, 比如setTimeout,会允许JS在特定的时机执行。
ES3及之前的版本,JS是没有异步执行能力的,JS引擎依次执行宿主传递的代码。ES5 之后引入了Promise,JS引擎也可以自己发起任务了。按照JSC引擎的术语,我们把宿主发起的任务叫做宏观任务,把JS引擎发起的任务叫做微观任务。
宏观任务与微观任务
操作系统中,等待行为通常都是一个事件循环,Node 中也是这么叫的,而事件循环在C/C++中就是一个跑在独立线程中的循环:
而整个事件循环就是反复的“等待-执行”。每次执行相当于一个宏观任务,而宏观任务队列,就相当于事件循环。
如前所述,宏观任务当中,Promise 还会产生异步代码,JS必须保证这些异步代码在宏观任务中完成,因此每个宏观任务又包含了一个微观任务队列。
由于又了宏观任务和微观任务队列,就可以实现JS引擎级和宿主级的任务了。比如, Promise 将微观任务添加到队列尾部;setTimeout等宿主API,则会添加宏观任务。
Promise
Promise 的设计思想是标准化异步管理,当需要IO、等待等异步操作时,返回的不是结果,而是一个承诺,调用方等待这个承诺的兑现(then),如:
很好理解,是等待传入参数的指定时常,打印finished。
我们知道,Promise的then回调是异步的,那么:
这段代码的输出,应当是 a b c,因为Promise 的resolve 是异步的,所以b 一定会出现在c之前。
上面都相对好理解,下面我们加入setTimeout。
结果是a b c d, d 为什么出现在最后了呢?不是等待时间是0 吗?因为Promise 产生的是JS引擎内部的微任务,而 setTimeout 是浏览器API产生的宏任务。
那就是说,微任务始终先于宏任务吗?
我们设计一个实验:
这里我们强制了等待一秒,可以确保输出 c2 的任务是在 输出d 之后添加到任务队列的。
但是输出仍然是c1 c2 d。即使耗时了1秒的c1执行完毕,也是先执行c2, 再执行d。
结论就是,微任务优先。
也就是说异步的执行顺序为:
首先我们分析有多少个宏任务;
在每个宏任务中,分析有多少个微任务;
根据调用次序,确定宏任务中的微任务执行次序;
根据宏任务的触发规则和调用次序,确定宏任务的执行次序;
确定整个顺序。
练习一下:
setTimeout 将代码分成了两个宏任务,5秒并不重要,第二个宏任务执行调用了resolve,输出c。因此输出为 a b c。
似乎Promise带来的好处并没有比回调更简单,于是ES6的 async/asait 出场了,它和Promise 配合起来,能有效改善代码结构。
async/await
async/await 提供了用for、if 等代码结构来编写异步的方式。async 函数一定返回Promise,我们把所有返回Promise 的函数都可以认为是异步函数。
用法为在函数前加上async关键字,就可以在其中使用await 来等待一个Promise。
async 的优势在于,它是可以嵌套的。我们定义了一批原子操作的情况下,可以利用async函数组合出新的async函数。
如此这般,如果我们把sleep这样的异步操作放入到某个框架或者库中,使用者几乎不需要了解Promise 的概念就可以进行异步编程了。
提一下,经常讲到异步就会讲generator/iterator,而它俩仅仅是在缺少async/await时来模拟async/await的行为,并且generator/iterator 的设计初衷并不是来实现异步,因此有了async/await,就不要再考虑generator / iterator 了。
闭包
追本溯源一下,闭包,翻译自closure,计算机领域中,出现过三次:
编译原理中,是处理语法产生式的一个步骤
计算几何中,表示包裹平面点集的多边形(又叫凸包)
编程语言中,表示一种函数
最早出现在1964年《The Computer Journal》,文章是《The mechanical evaluation of expressions》,作者是 P. J. Landin , 概念是applicative expression 和 closure。而那个时代,编程语言的主角是基于lambda 演算的编程语言,因此当时闭包的含义仅仅为“带有一系列信息的lambda 表达式”,而lambda表达式就是函数,翻译一下,就是带有一系列信息的函数。
函数可以理解为表达式,那什么是一系列信息呢?也就是函数所携带的环境部分,即环境和标识符列表,收敛到Javascript:
环境就是函数的词法环境,即执行上下文的一部分
标识符列表,就是函数中用到的未声明的变量
表达式部分,即为函数体
由此可见,Javascript 中的函数定义其实就是闭包的定义。
但是,一种常见的“误解”,当然可能仅仅是为了讲述那个知识点而产生的字面误解。许多人,包括我在内,将Javascript 的执行上下文,或Scope作用域(ES3中规定的执行上下文的一部分)概念当作闭包。是种“误解”,但我并不反对,起名没啥意义,我知道你说的什么就行了,你可以接着说。
执行上下文
执行上下文,就是函数所携带的“环境部分”,它包括了词法环境等(TODO)。JS标准把一段代码(包括函数)执行所需的所有信息定义为执行上下文。
ES3中,执行上下文包括:
Scope: 作用域,或常听到的作用域链
VO(Variable Object): 变量对象,用于存储变量的对象
this value: 就是this 值。
ES5中, 由于我们修改了命名方式,执行上下文包括:
lexical environment: 词法环境,获取变量时使用
variable environment:变量环境,声明变量时使用
this value:还是this 值
ES2018,this 被划分到了lexcial environment 中,但增加了许多,执行上下文变成了:
lexical environment: 词法环境,获取变量或者this值时使用
variable environment:变量环境,声明变量时使用
code evaluation state: 用于恢复代码执行位置
Function: 执行的任务是函数时使用,表示正在被执行的函数
ScriptOrModule:执行的任务时脚本或模块时使用,表示正在被执行的代码
Realm:使用的基础库和内置对象实例
Generator:仅仅生成器上下文有这个属性,表示当前的生成器。
这是执行上下文的发展,我们统一使用最新的ES2018中规定的术语即可。
从几行简单的代码说起:
平淡无奇,且枯燥,有什么问题呢?执行上下文能解决什么问题呢?
这段代码,出现在不同的位置,甚至每次执行中,会关联到不同的执行上下文,也就是说,同一段代码会产生不一样的行为。
比如:
var 把 b 声明到哪了? b 表示的哪个变量? b的原型是哪个对象?
let 把 c 声明到那了?
this 指向的是谁?
var 的声明与赋值
代码声明了b, 赋值为1,var 的声明作用域为执行作用域,即会穿透for、if 等。
因此没有let 的年代,催生出了IIFE,立即执行函数表达式,为的就是构造一个新的作用域,控制var 的影响范围。
怎么立即执行呢? 加括号!
但是加括号有问题,如果上一行代码没写分号,那括号就会被解释成上一行代码的函数调用,显然不符合预期也不好调试。
但的确有不加分号的代码风格规范,那涉及到IIFE,就要自己给行首加上分号,类似:
有点丑,推荐void 写法:
void 不仅避免了“分号问题”,同时void 本身就表示忽略后面表达式的值,变成undefined,与IIFE 初衷一样,语义上也更合理,因此更优雅。
var 的穿透特性,加上with,会产生一些灾难:
导致声明的变量 b 和被赋值的变量 b,是两个b:
with 我们后面还会讲到,是个大坑,建议别用,function 的环境中,并没有var b的声明,也就是说with 内的var b 作用到了function 中。
而 var b = xxx 这样的语句对两个作用域都产生了作用,显然是一个糟糕的设计,也因此很多人反对使用with
let
var 的弊病很多,比如穿透,因此let 做了明确的梳理和规定。
为了实现let,Javascript 终于引入了块级作用域,let 让以下语句拥有了单独的作用域(在let之前,是没有的):
for
if
switch
try/catch/finally
Realm
Realm 是9.0标准新引入的概念,它是什么呢?
它包含了一组完整的内置对象,而且是复制关系。怎么理解呢?
先执行:
再执行:
常见的Realm 就是iframe了。
代码展示从浏览器环境中获取来自两个 Realm 的对象,它们跟本土的 Object 做 instanceOf 时会产生差异:
由于 b1、 b2 由同样的代码“ {} ”在不同的 Realm 中执行,所以表现出了不同的行为。
说白了,两个Realm 的Object 并不是同一个Object。
函数
切换上下文最主要的场景是函数调用,那我们先来讲讲函数。
函数简介
ES2018中,函数已经很复杂了,大概分为以下几种:
普通函数,也就是function关键字定义的函数
箭头函数,即用 => 运算符定义的函数
生成器函数,也就是用function*定义的函数
方法,是指class 中定义的函数
类,用class 定义的类,实际上也是函数
async 异步函数家族,包括
1. async 普通函数
2. async 箭头函数
3. async 生成器函数
对普通变量来说,这些函数没有本质区别,都是遵循“继承定义时环境”,行为差异在于this关键字。
this 关键字
this 在javascript 中是个关键字,使用方法类似于变量,而且this 是执行上下文中的重要组成。同一个函数调用方式不同,得到的this值也不同。
普通函数的this 值,是由“调用它所使用的引用”决定的,我们获取函数的表达式,它实际上返回的是一个Reference类型,而非函数本身
Reference 类型包含两部分,一个对象和一个属性值。比如上例中的o.showThis 所产生的Reference类型,就是由对象o和属性“showThis”构成的。
总结一下,调用函数的引用,决定了函数执行时刻的this 值。
也就是说,和Java相比,这里的this 完全是在模仿Java 的语法,this 实际上与面向对象无关联,而是与函数调用时使用的表达式相关。
但改为箭头函数,结果就不一样了:
也就是说,this 在箭头函数中,不论用什么引用来调用,都不影响this 的值。
下面我们再换成方法:
与普通函数一样,方法也是,this 的值与调用它使用的表达式相关。
async 的行为与其不加async 的行为是一致的。(即,异步箭头函数是不影响的,另外两个是影响的)
this 关键字的机制
再深入一些说,实现this 行为的机制是什么样的呢?
Javascript 中,为函数定义了用来保存定义时上下文的私有属性,即[[Environment]]。
当函数执行时,会创建一条新的执行环境的记录,记录的是外层词法环境的[[Environment]],设置成函数的[[Environment]],而这,就是切换上下文的机理。
function 中的foo能够访问b(定义时的词法环境),却访问不了a(执行时的词法环境)
底层使用栈+链表来管理执行上下文的。函数调用,会入栈新的执行上下文,调用结束,执行上下文出栈。
this 要更复杂一点,JavaScript 定义了 [[thisMode]] 私有属性,包括三个取值:
lexical:上下文中找this,对应了箭头函数
global:this 为undefined 时,取全局变量,即普通函数
strict:当严格模式时使用,this 严格按照调用时传入的值,可能为null 也可能为undifeined。
这也解释了上面说到的,方法的行为与普通函数的行为有差异,就是因为class设计成了默认按strict 模式进行。
做个实验:
如果代码执行,遇到了this ,会逐层检查当前词法环境记录中的[[ThisBindingStatus]],找到则获取,如下例所示,调用三个函数,获取到的this 值时一致的。
而,如果都是普通函数:
操作this 的内置函数
JS还提供了一系列函数的内置方法来操纵this。
Function.prototype.call 和 Function.prototype.apply 这两个方法可以指定函数调用时传入的this值:
第一个参数就是this 的值,两者仅在传参方式上不同。
还有一个,叫Function.prototype.bind,可以生成一个绑定过的函数,而这个函数的this 值,也可以通过入参来设置。
但是,这都是针对普通函数的,call、bind、apply 对于不接受this 的函数类型,如箭头、class 不会报错,但无法改变this 的能力,只能进行传参。
new 与 this
复习一下前面说过的new 的执行过程:
以构造器的prototype 属性为原型,创建新对象。(注意是prototype 属性,而不是私有字段[[prototype]]
将this 和调用参数传递给构造器,执行
如果构造器返回的是对象,则返回,否则返回第一步创建的对象。
也就是说,通过new 调用函数,与直接调用的this 取值有明显区别:
也就是说,仅普通函数和类可以跟new 搭配。
语句
常见的语句有变量的声明、表达式、条件语句、循环语句等,这些都是我们熟悉的。那我们就来看看不熟悉的。
Completion 类型
下面的代码中,finally块会执行吗?
发现返回是 a 0。
如果我们在finally中,return呢
发现仅返回了1,finally 中的return 覆盖了try 中的return。如果你知道Java ,其实结果也是这样。
这一机制靠的就是JavaScript语句执行的完成状态,我们用一个标准类型表示,叫Completion Record,用于描述异常、跳出等语句执行过程。它有三个字段:
[[type]] 表示完成的类型,如break continue return throw normal
[[value]] 就是语句的返回值,没有则是empty
[[target]]表示语句的目标,通常是一个JavaScript 标签
那Javascript 是怎么使用Completion Record 来控制语句执行过程的呢?
语句大概分为以下几类:
普通的语句
普通的语句即不带控制能力的语句,如上图所示。
普通语句的执行是依次执行(忽略var和函数声明的预处理机制),执行后得到的Completion Record,[[type]] 为normal,JS引擎遇到这种CR,会继续执行下一句。
普通语句中,只有表达式语句会产生[[value]],而且对引擎来说,并没什么用。在Chrome 的控制台也能看到。
控制台显示的,就是Completion Record 的[[value]]
语句块
语句块就是大括号扩起来的一组语句,是语句的复合结构,可以嵌套。
语句块中的语句,如果Completion Record 的[[type]] 不为 normal, 会打断后续的语句执行。
如return 类型、 throw 类型
语句块中的语句被return这一非normal的完成类型打断了。这个结构保证了非normal的完成类型产生控制的效果。
控制型语句
控制型语句带有关键字,如if 、 switch 等,会对不同类型的CR产生反应。
控制语句也可以分两种,一种是对内部造成影响,如if、switch、while/for、try
一种是对外部造成影响,如break、continue、return、throw。
两者相配合,可以控制代码的执行顺序和执行逻辑。
『消费』是控制语句里的内容执行完毕。消费就是在这一层就执行了这个break或者continue。
『穿透』是控制语句里的内容没能执行完,被中止了。穿透就是去上一层的作用域或者控制语句找可以消费break,continue的执行环境。
回到之前的例子,就是try 和return 的组合。由于finally 的内容要保证必须执行,所以try/catch执行完毕,即使得到的结果是非normal的,也必须要执行finally。而finally 如果也得到了非normal的记录,则会使finally中的记录作为整个try结果的结果。
带标签的语句
最后一个字段target,涉及了JavaScript 中带标签的语句。
任何JavaScript语句都是可以加标签的,只需要在语句前加冒号。
大部分可以看作注释,唯一用得到的地方是与CR的target 相配合,跳出多层循环,即break/continue 后加关键字,会产生带target 的完成记录,到只拥有对应label的循环语句来消费。
如上段代码,如果不加outer, 就死循环了。
评论