温馨提示:如无特殊交代,本文所给出的示例代码的执行环境均为浏览器chrome v81.0.4044.92。
前言
在这个言必称single threaded
,event loop
,microtask
,macrotask
......的javascript时代,相对深入地去了解这些概念和概念背后实现的运行机制是十分有必要的。
2014年的时候,Philip Roberts先后在Scotlan JS大会和JSConfEU大会发表了关于event loop的优秀演讲,轰动业界(详见我的演讲整理)。这同时意味着event loop这个概念正式地进入到前端开发者的视野(于此同时,国内业界发生了著名的打脸事件,详见JavaScript 运行机制详解:再谈Event Loop)。
随着mutiple-processor计算机普及和前端作业越来越繁杂,javascript的并发编程越来越被重视。而javascript的并发模型是基于event loop机制的。所以,理解好event loop的实现机制能够帮助我们在并发编程的大背景下,更好地优化和架构我们的代码。
理由如此充分,那我们还在等什么?
正文
术语
需要反复强调的是,概念是人类有效沟通交流的基础,更确切地说,将同一个(概念)“名”理解为同一个“实”,即概念理解的一致性是人类有效沟通交流的基础。概念落实到某个相关领域就称之为“术语”。鉴于无论是官方文档还是业内技术文章在使用术语的不一致性,我们有必要梳理一下阐述event loop过程中所涉及的术语,如下:
task
在MDN的诸多阐述event loop相关的文档中,都使用了这个术语。在这篇文档中,task的定义是这么下的:
A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired.
这篇文档中说到:
The tasks form a queue, so-called “macrotask queue” (v8 term)
可以看到,我们天天一口一个的macrotask,比如:setTimeout/setInterval等等的callback就是一个“task”。
task queue
task会被推入到一个队列当中,等待调度。这个队列就是task queue。在Philip Roberts的演讲中,他提到了一个叫“callback queue”的术语,同时他也提到了,它就是“task queue”的别名。
macrotask
macrotask === task,这里就不赘述了。
macrotask queue
macrotask queue === task queue === callback queue,这里也不赘述了。
messsage
这篇MDN文档,通篇下来都在用“message”这个术语,可以看得出,这个message跟它对应的callback一起,两者可以统一称之为“task”。那么里面所说的“message queue”就是“task queue”。
microtask
MDN上如是说:
A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.
简单理解就是,microtask是一个“小”函数(正好呼应了它的名字:micro)。这个函数仅在JavaScript执行栈为空,并且创建它的函数或程序退出后才会执行。但是,它会在将控制权返回给用户代理之前执行。“控制权返回给用户代理之前”是什么意思呢,其实就是“下一个event loop之前”的意思。
microtask queue
跟macrotask queue一样,microtask queue也是用于存放microtask的队列。
job
在jake archibald的这篇技术文章中,他指出了“job”的概念来自于ECMAScript规范,它与“microtask”几乎等同,但是不完全等同。业界对两者区别的阐述一直处于含糊不清的状态。鉴于整篇文章下来,在他的阐述中,他已经把“job”等同于“microtask”了。所以,我也倾向于采用这种理解。
而另外一位同行Daniel Chang 在他的技术文章Microtasks & Macrotasks — More On The Event Loop也秉持着同样的看法:
In this write up I’ve been using the term task interchangeably between macrotask and microtask, much of the documentation out there refers to macrotasks as tasks and microtasks as job. Knowing this will make understanding documentation easier.
call stack/execution context stack
首先,我们先去维基百科上面看看有关于call stack的定义。
在计算机科学中,call stack是一种存储计算机程序当前正在执行的子程序(subroutine)信息的栈结构......使用call stack的主要原因是保存一个用于追踪【当前子程序执行完毕后,程序控制权应该归还给谁】的指针.....一个call stack是由多个stack frame组成。每个stack frame对应于一个子程序调用。作为stack frame,这个子程序此时应该还没有被return语句所终结。举个例子,我们有一个叫DrawLine的子程序正在运行。这个子程序又被另外一个叫做DrawSquare的子程序所调用,那么call stack中顶部的布局应该长成下面那样:
结合维基百科给出的定义和Philip Roberts的演讲,我们再来看看MDN的诸多文档通篇下来使用的“execution context stack”这个概念。顾名思义,“execution context stack”当然是由“execution context”组成的,那“execution context”又是什么?不难理解,“execution context”就是维基百科给出示意图中stack frame里面的“Locals of xxx”,即函数执行所需要用到的上下文环境。
最后,我们可以看出,“execution context stack”其实就是“call stack”的子集。因为在event loop语境下,我们不关心stack frame里面的其他“成分”:“parametres for xxx”和“return address”。所以,两者可以等同起来理解。
小结
有鉴于业内技术文章对这些术语的使用频率,在本文的阐述中,相比于“task”,我会采用“macrotask”的叫法;相比于“job”,我会采用“microtask”的叫法;相比于“execution context stack”,我会采用“call stack”的叫法。
That is a deal!
什么是event loop?
从广义上来说,event loop就是一种【user agents用于协调各种事件,用户交互,脚本执行,网络活动等执行时机的】调度机制。
实现event loop的user agent有好多个。比如说:
-
window
-
worker
worker又可以分为:
- dedicated worker
- shared worker
- service worker
-
worklet
-
nodejs
实现event loop的通用算法(注意,这是简单的,通用的算法)大概是这样的:
- 时刻监视task queue, 当task queue不为空的时候:
- 执行队列中入队时间最久的那个task
- 休眠。直到等到有可执行的task出现,跳回1。
用代码来简单表示就是:
while (queue.waitForTask()) {
queue.processNextTask()
}
作为单线程的javascript就是通过event loop来实现了它的异步编程特性。
鉴于实现event loop的user agent之多和时间有限,我在这里只是深入讨论浏览器中的event loop(特指window event loop)和nodejs中的event loop。
浏览器中的event loop
我这里为了通俗易懂,使用了“浏览器中的event loop”这种描述方式。在后面,如何特殊交代,它都是特指规范文档里面的“window event loop”。
上面指出,我们将会统一使用“call stack”,“microtask”和“macrotask”等术语来阐述event loop。
macrotask跟microtask的种类
在浏览器这个上下文中,macrotask有以下的种类:
- 当外部或内部<script>标签所对应的脚本加载完成之后,执行这些脚本就是一个macrotask;
- 当用户点击页面上的按钮,那么分发click事件后的对handler的执行就是一个macrotask;
- 调用setTimeout或者setInterval时传入的callback的执行,就是一个macrotask;
- 非标准全局方法setImmediate()调用时传入的callback的执行,就是一个macrotask;
- requestAnimationFrame调用时传入的callback的执行,就是一个macrotask(注意,因为requestAnimationFrame入队的callback往往回来一个动画帧的开头执行,所以,它的优先级要比setTimeout/setInterval要高,即排在setTimeout/setInterval的前面);
- .......
microtask的种类有以下几种:
- 在promise对象调用then/catch/finally的时候传入的callback的执行,就是一个microtask;
- 显式地调用queueMicrotask(fn)来入队一个microtask时候,那么对这个fn函数的执行就是一个microtask;
- new MutationObserver()传入callback的执行就是一个microtask;
- Object.observe()传入callback的执行就是一个microtask。
处理模型图
以Chrome浏览器为例,event loop的处理模型图大概是下面这样:
上图中,需要事先交代两点:
- (1),(2),(3)表示的是“一次loop中,不同类型队列被检查是否为空的顺序;
- render callback queue可能无法对应到具体的实现,但是从心智模型的角度来说,引入它,有助于推演event loop。
下面我们解释一下这个处理模型图。
- 当浏览器遇到一个有代码的<script>标签的时候,那么浏览器就进入了一个以(window)event loop为驱动的执行流中。外部<script>与内部<script>不同的一点是多了网络加载过程,不管怎样,我们的执行流从代码可执行的那一刻开始讲起。首先,浏览器会把整段脚本的执行当作像C语言里面的main函数去执行。这个时候,“main”函数推入到call stack,成为了第一个call frame。“main”函数的执行就是从上到到下,遇到同步代码的调用,就往call stack增加一个call frame,遇到异步代码,就把它交给web API来处理。一方面,同步代码调用完成后(比如说遇到return语句),它所对应的call frame就会从call stack中pop走,以此类推,直到call stack为空,程序就把控制权交回给event loop。另一方面,到了相关的时间点,web API就会把一个callback封装成一个task,把它推入到它所属的队列中。比如,从上到下执行“main”函数的时候,遇到了以下代码:
setTimeout(()=> {
console.log('time out');
});
那么浏览器就会把这个setTimeout调用交给web API,然后把它从call stack中pop出来。web API接收到个setTimeout调用后,它会在自己的线程里面启动一个定时器,因为在这段代码里面,没有传递time out时间,那么就是默认的0。接着,web API没有丝毫犹豫,它就把setTimeout的callback推入到它所属的macrotask queue里面。假如是遇到在promise对象身上调用then/catch/finally方法,那么它们的callback最终会被web API推入到microtask queue中;假如遇到的是界面更新的DOM操作,那么这些DOM操作就会被封装成一个render callback,推入到render callback queue中。这些callback经过封装后成为一个task,静静地躺在各自的队列中,等待调度。等待谁的调度呢?当然是等待event loop的调度。
- event loop负责监视call stack,一旦call stack处于清空状态,那么它首先会去看看microtask queue是否有task。有的话,它就取出队列中入队时间最长的task,
注意,从标准规范的角度来看,“取出队列中入队时间最长的task”这种表述是不正确的,详见规范文档。但是至于在V8的内部,具体实现是如何的呢,目前就不得而知了。为了易于理解和帮助推演,本文姑且采用这种表述方式。
然后把它推入到call stack去执行。跟macrotask queue和render callback queue不同的,一旦第一个microtask在call stack执行完之后,第二个microtask就会紧跟着推入到call stack去执行,而不是等到下一次的event loop才会执行。也就是说,microtask queue中的所有 micrtask会被一次性执行完毕。
- 当call stack再次为空的时候,这时候就轮到render callback queue了。render callback主要是处理UI渲染相关的事务。当call stack为空后,浏览器就会去render callback推入大call stack中,UI渲染完成后,render callback也就从call stack弹走,call stack再次为空。这样子,一个loop就结束了。当一段新的代码片段需要执行或者某个UI事件触发了,那么浏览器就会进入下一个loop。
以上是理论表述,下面我们结合一下实际的代码来验证并理解上面的话。
<script>
console.log(1);
setTimeout(()=>{
console.log(2);
}, 0);
new Promise(res=> {
res();
}).then(()=> {
console.log(3);
throw new Error('error');
}).catch(e=> {
console.log(4);
}).finally(()=> {
console.log(5);
});
console.log(6);
</script>
就像上面所说的那样,对<script>标签所包裹的代码的执行是一个macrotask。为了方面描述,我们可以把这段代码的初始执行理解为C语言中“main函数”。event loop首先执行macrotask,所以,“main()”调用推入到call stack中。这个时候,遇到同步代码的console.log(),那么就推入call stack,在浏览器控制台打印1之后,console.log()就会被pop出call stack。接着下来,call stack会执行setTimeout(),call stack马上把它交给web API,然后把它从call stack中弹走。因为web API的实现并不在js engine(特指V8)里面,而是另外一个线程里面,所以js engine的执行跟web API的执行可以是并行的。在call stack继续往下执行的同时,web API会检查setTimeout调用时传入的表示需要延迟的time out,发现它为默认的0,于是乎就马上把相应的callback推入到macrotask中。与此同时,call stack执行到了new Promise().then().catch().finally()语句。值得注意的是,这段语句回分两步执行:
(1)const temp = new Promise(executor);
(2)temp.then().catch().finally();
第一句promise实例的构造调用是属于同步代码,会在call stack中执行。对构造好promise实例的then/catch/finally方法的调用,都会交给web API,web API会在promise被reslove的时候,把这些方法所对应的callback推入的microtask queue中。因为在这里,promise会被马上reslove掉,因而then/catch/finally这三个方法的callback因此会马上被推入到microtask queue中。
到了这里,如果我们只关注macrotask queue和microtask queue的话,并且为了描述简单起见,我们用需要打印的数字来代表相应的task,那么两个队列的应该是长下面这个样子:
macrotask queue:
---------------------------
| 2 | |
---------------------------
microtask queue;
---------------------------
| 3 | 4 | 5 |
---------------------------
好,我们继续往下看,现在call stack依然有着“main()”占据着,并不处于清空状态。因为我们还要一句“console.log(6)”没执行。好吧,执行它,在浏览器打印出“6”,然后把它从call stack中pop走。到了这里,我们已经到达了“main”函数的底部,“main”函数调用完毕,于是它也从call stack中pop走。此时call stack终于处于清空状态了。
好了,一直处于欲睡未睡状态的event loop看到call stack为空,它马上就打起十二分精神了。因为“main”函数调用本身就是一个macrotask。轮完macrotask,那么这次得轮到microtask queue了。一人一次,相当的公平,是吧?就想我们上面所说的,microtask queue中所有的microtask是会被依次被推入到call stack,整个队列会被一次性执行完并清空的。所以,浏览器控制台会依次打印“3”,“4”和“5”。打印完毕后,call stack重新回到清空状态。这一次, 轮到render callback queue了。因为我们这段代码中并没有操作界面的东西,所以render callback queue是空的。event loop看到这个队列中为空,心中大喜,想着这一次的event loop结束后,自己终于可以休息了。但是可怜的event loop是劳碌命,它被浏览器逼迫着进入了下一个loop中去了。
在下一个loop中,老规矩,我们还是会先检查macrotask queue。这个时候,它发现有一个macrotask在里面,于是它二话不说,把它推入到call stack去执行,最终在浏览器控制台打印出“2”,call stack处于清空状态。event loop接着看microtask queue和render callback queueu,发现这个两个队列都是为空。最终的最终,event loop可以歇着了,它如愿以偿地进入休眠状态。
为了验证一下我们的理解是否准确,我们不妨把代码复制到chrome浏览器控制台去运行一下,结果是这样的:
可以看出,实际运行的结果跟我们推演的结果是一致的,我们的理解应该是没错的。macrotask跟microtask的不同点
macrotask和microtask虽然都会被入队到队列中,都会最终能推入到call stack去执行,但是两者的不同的还是挺明显的,并且对于理解整个event loop的运行机制还是挺重要的。它们两个之间主要有两个不同点:
- 在同一个event loop中,执行次序不同。一个event loop一旦开始了,总是先执行macrotask,后执行microtask。
- 同一个队列中,相邻任务的相对执行时机不同。对于macrotask queue而言,相邻的任务会分散不同批次的event loop去执行;而对于 microtask queue而言,相邻的任务会在同一批次的event loop执行完,并且是连续性地,依次地执行完。
下面,我们在chrome浏览器(v81.0.4044.92)跑几个例子来验证一下。
先说一下第一个不同点。无论是script标签内部的js代码片段还是通过外部加载进来的js代码文件,浏览器都会将对它的执行视为一个macrotask,这也是驱动js代码执行流的第一个macrotask。从这个角度来看,microtask总是从macrotask衍生而来的,那我们凭什么能说“在同一个event loop中,microtask会比macrotask先执行呢?”。这道理就好像,妈妈把儿子生下来之后,儿子长大后,指着妈妈说:“我长得比你高,我比你先来到这个世界”。你不觉得不符合逻辑吗?不过,话说回来,要想通过浏览器控制台的打印顺序来正面证明 macrotask比microtask先执行还是挺难的。不过我们可以反向证明一下。
假设js引擎扫描代码后并没有把整个js代码片段/文件作为macrotask来执行,而是把“console.log(1)”和promise的代码分别入队到macrotask queue和microtask中。当js引擎准备执行代码的时候,假若它是先执行microtask,后执行macrotask的话,那么,控制台会先打印“2”,后打印“1”。实际上,这段代码无论你执行多少次,结果都是一样的:会先打印“1”,后打印“2”。这就反向证明了两点:1)代码片段和代码文件的执行本身就是一个macrotask;2)从源头上说,microtask是作为macrotask的一个执行结果而存在的,或者说,macrotask衍生了microtask。 所以,从表象上说,肯定是先执行macrotask,再执行microtask。
这里再次强调,第一点理解“代码片段和代码文件的执行本身就是一个macrotask”是十分重要的。因为一旦你看不到它的话,那么你就会下错结论。请看下面这个简单图示:
基于event loop的执行流:
======================================================================
|| macrotask || microtask || macrotask || microtask || .....
======================================================================
^ ^
| |
| |
| |
观察点1 观察点2
因为macrtask queue和microtask queue是交替式地得到一次推入call stack的机会的。那么,如图,如果你忽略了“代码片段和代码文件的执行本身就是一个macrotask,并且是驱动执行流的第一个macrotask”这个实现上的事实后,光从控制台的打印结果去做简单判断的话的话,那么实际上你是站在了“观察点2”上。这个时候,你会觉得先执行microtask,后执行macrotask的。然而,这并不是事实。
综上所说,macrotask是先于microtask先执行的。
第二个不同点,倒是可以通过简单地在浏览器控制台运行代码来验证。
首先,我们先来验证一下,同一个event loop中,microtask是批量地,依次地执行的,而macrotask是单个执行的:
setTimeout(()=>{
console.log(2);
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
});
Promise.resolve().then(()=> {
console.log(5);
});
初始macrotask执行后,macrotask queue和microtask queue应该是长这样的(跟上面阐述一样,同样是用【所需要打印的数字】来标志这个任务):
---------------------------
| 2 | 3 |
---------------------------
microtask queue;
--------------------------
| 4 | 5 |
---------------------------
如果,单个macrotask跟单个microtask是交替执行的话,那么打印结果将会是:
4
2
5
3
但是实际上打印结果是:
看这个结果,你可能会说,我是看到microtask是批量执行了,但是macrotask不也是“批量执行”吗?。实际上,不是这样的。那是因为进入第二次event loop之后,执行完(2)之后,microtask queue中并没有任务的任务可执行,于是乎又进入了第三次event loop,这个时候,才执行了(3)。下面我们在第一个setTimeout的callback入队一个microtask(为了简便起见,这里用全局方法queueMicrotask)来试试看:
setTimeout(()=>{
console.log(2);
queueMicrotask(()=> {
console.log(2.5);
});
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
});
Promise.resolve().then(()=> {
console.log(5);
});
如果macrotask也是批量执行的话,那么打印结果将会是:
4
5
2
3
2.5
但是实际打印结果是什么呢?实际如下:
实际的打印结果是:
4
5
2
2.5
3
这是为什么呢?这是因为,浏览器在走完第二次的event loop的macrotask之后,代码使用queueMicrotask入队了一个microtask(2.5)。上面说过,一旦执行完一个macrotask,接下来就会去检查microtask queue是否有任务等待执行。此时,正好有一个microtask(2.5)在里面,所以,event loop就把它推入到call stack执行了,然后打印出“2.5”。再然后才进入第三次的event loop,这才有了macrotask(3)的执行。
上面,基本上是在验证microtask执行的“批量性,依次性”。那下面来验证,microtask执行的“连续性”。简单来说,如果一个microtask在call stack上执行的过程中导致了一个新的microtask入队,而这个新的microtask在call stack执行过程中又导致了一个更新的microtask入队,如此类推.....的话,那么这些连续产生的microtask都会在同一次event loop中被连续地执行完,中间不会去执行macrotask queue或者render callback queue里面的任务。注意,这里强调的是“导致了一个新的microtask入队”的意思是指,浏览器以几乎可以忽略的时间差,真正地把一个microtask入队到microtask queue中。比如,下面的代码就不是“导致了一个新的microtask入队”:
Promise.resolve().then(()=> {
setTimeout(function macrotask2() {
queueMicrotask(()=> {
console.log(4.5);
});
}, 0)
});
因为只有当“macrotask2”这个macrotask被推入到call stack去执行的时候,(4.5)这个microtask才会真正入队。
去掉setTimeout的包裹,才是真正的“导致了一个新的microtask入队”:
Promise.resolve().then(()=> {
queueMicrotask(()=> {
console.log(4.5);
});
});
以上这两种情况对执行流有啥影响呢?我们下面看看各种的打印结果的差异。
(1)有setTimeout这层wrapper:
setTimeout(()=>{
console.log(2);
queueMicrotask(()=> {
console.log(2.5);
});
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
setTimeout(()=> {
queueMicrotask(()=> {
console.log(4.5);
});
}, 0)
});
Promise.resolve().then(()=> {
console.log(5);
});
// 打印结果是:
4
5
2
2.5
3
4.5
(2)把setTimeout这层wrapper去掉后:
setTimeout(()=>{
console.log(2);
queueMicrotask(()=> {
console.log(2.5);
});
}, 0);
setTimeout(()=>{
console.log(3);
}, 0);
Promise.resolve().then(()=> {
console.log(4);
queueMicrotask(()=> {
console.log(4.5);
});
});
Promise.resolve().then(()=> {
console.log(5);
});
// 打印结果是:
4
5
4.5
2
2.5
3
从打印结果来看,你可以看到两者执行流的差别吗?一个是4.5放在最后打印了,一个是接着前面两个的microtask的尾巴,连续打印了。说了这么多,我就是想说,我这里所说的“microtask执行的连续性”是指第二种情况。下面我们把这个例子放大来看:
setTimeout(()=>{
console.log(1);
}, 0);
Promise.resolve().then(()=> {
console.log(2);
queueMicrotask(()=> {
console.log(3);
queueMicrotask(()=> {
console.log(4);
queueMicrotask(()=> {
console.log(5);
queueMicrotask(()=> {
console.log(6);
});
});
});
});
});
你猜猜打印结果如何?
你猜对了吗?到这里,不知道你看清楚所谓的“microtask执行的连续性”是啥没?它的具象化理解其实就是“连续入队的microtask会被依次,连续地推入到call stack去执行,中间不会调度其他的任务(macrotask后者render callback)去打断这种连续性”。
实际上microtask这种连续性,在使用不当(比如说入队过多,递归入队)的时候,就会长期占用call stack,本质上造成了浏览器运行的阻塞。MDN在文档里面也给出了相关的警告:
Warning: Since microtasks can themselves enqueue more microtasks, and the event loop continues processing microtasks until the queue is empty, there's a real risk of getting the event loop endlessly processing microtasks. Be cautious with how you go about recursively adding microtasks.
对于microtask跟macrotask的不同点,到这里已经阐述得差不多了。还有一个值得强调的是,要想理解event loop的运行机制,理解microtask/macrotask的入队时机也是十分重要,并且需要额外注意的一点。microtask/macrotask的入队时机是掌握在web API手上的。关于这一点,Philip Roberts在他的演讲中有提到过。在这里,会给出一个示例:
const timeout1 = 0;
const timeout2 = 0;
const timeout3 = 0;
// 代码块(1)
setTimeout(()=>{
console.log(1);
}, timeout1);
// 代码块(2)
new Promise(resolve=> {
setTimeout(()=> {
resolve('finished');
},timeout2);
// 可以尝试把setTimeout wrapper去掉
// resolve('finished');
}).then(()=> {
console.log(2)
throw new Error("error");
}).catch((e)=> {
console.log(3);
}).finally(()=> {
console.log(4);
});
// 代码块(3)
setTimeout(()=>{
console.log(5);
}, timeout3);
你可以通过以下一种或者多种结合的方式来观察一下入队时机是如何影响执行流的:
- 任意修改变量timeoutxxx的值;
- 切换同步resolve promise或异步resolve;
- 调整代码块的书写顺序;
提示1:记得,这个时候,一定要想起web API这个扫地僧啊。
提示2:构造promise实例的代码是同步代码。
setImmediate,MutationObserver和async...await
setImmediate
首先,我们来聊聊setImmediate。在MDN上,开门见山了指出这个方法并不是标准规范要求实现的方法:
This feature is non-standard and is not on a standards track. Do not use it on production sites facing the Web: it will not work for every user. There may also be large incompatibilities between implementations and the behavior may change in the future.
This method is not expected to become standard, and is only implemented by recent builds of Internet Explorer and Node.js 0.10+. It meets resistance both from Gecko (Firefox) and Webkit (Google/Apple).
也就是说,这个特性当前不是标准方法,它的发展也不在可以标准化的方向上。当前只有最新的(相对于Feb 22, 2020)IE版本和Node.js 0.10+上实现了它。 它的标准化进程受到了Gecko (Firefox) 和 Webkit (Google/Apple)的抵制。故而,请不要在生产环境中使用它。
题外话:当我在掘金域名下的页面的控制台输入“set....”的时候,它竟然提示有这个“setImmediate”API,并且也是能执行的。我当时就懵掉了,难道在最新版本的chrome中,它实现了这个方法?后面经过摸索(就简单地用“setImmediate.toString()”来看看,原来它并不是原生方法,应该是掘金自己引入了外部的polyfill。
虽然它不是标准方法,但是考虑到nodejs有这个方法,并且有个别面试官的“丧心病狂”,我们不妨看看这个方法到底是怎么一个回事。
MDN上对它的介绍是这样的:
This method is used to break up long running operations and run a callback function immediately after the browser has completed other operations such as events and display updates.
在这段介绍里面,我们没有看到这里面有提到setImmediate跟(window)event loop的关系。我们只看到了,它会在“event callback”和 UI更新等操作后面执行。为了弄清楚调用它的时候传入的function到底是入队到哪个队列中,我们来看看市面上各种setImmediate polyfill是如何解读它的。
我们挑一个star最多的,也就是第一个“YuzuJS/setImmediate”来看看,只见它的readme里面是这么写的:
The setImmediate API, as specified, gives you access to the environment's task queue, sometimes known as its "macrotask" queue. This is crucially different from the microtask queue used by web features such as MutationObserver, language features such as promises and Object.observe, and Node.js features such as process.nextTick.
第一句话就很明确地指出,它所入队的队列是macrotask queue。同样,在stackoverflow上面的这个问题的一个高分答主也秉持同样的观点:
鉴于,在我的电脑上只有Microsoft Edge(v44.18362.329.0),并且它原生实现了setImmediate方法:
那么我们就在上面把示例代码跑起来看看,先来个简单版:
setTimeout(() => {
console.log('setTimeout');
});
setImmediate(()=> { console.log('setImmediate')});
Promise.resolve().then(()=> {
console.log('Promise');
});
//output:
Promise
setImmediate
setTimeout
从这一次的运行结果来看,setImmediate入队的任务要么是追加到microtask queue的后面,要么就插队到macrotask queue的最前面了。这两者都有可能。但是随着深入试验,我有个惊奇的发现。先卖个关子,我们先看看下面两个代码运行结果截图:
从这个运行结果来看,我们还是无法 判断setImmediate入队的任务到底归属于那个任务队列。但是我们可以有一个结论,那就是:(1)多个setImmediate的入队顺序还是按照它们在代码书写期的顺序来入队的,它没有后来者居上的插队表现。 但是,从下面这运行结果我们就可以大概看出个端倪来:
首先,我们姑且把同样代码会有不同的执行结果这个发现放在一边。我们可以看到,两次的setTimeout竟然在两次setImmediate前面打印出来了。这就证明了:(2)setImmediate入队的任务是归属于macrotask queue的。 为什么呢?因为假如setImmediate入队的任务是归属于microtask queue的话,那么这段代码无论执行多少次都不会出现第二张截图所显示的运行结果。第二张截图所显示的运行结果证明了setImmediate入队的任务肯定是归属于macrotask queue的,但是综合两次运行结果来看,我们基本可以判断:(3)setImmediate和setTimeout的入队顺序无法得到保证。不过绝大部分的情况下,都是setImmediate入队在先。 我们伟大的扎叔在他的技术博客里面也提到过:
Another advantage is that the specified function executes after a much smaller delay, without a need to wait for the next timer tick. That means the entire process completes much faster than with using setTimeout(fn, 0).
这种不一致表现,好像有点似曾相识,好像哪里见过,是吧?对的,就是我们亲爱的nodejs。nodejs在自己的官方文档setImmediate() vs setTimeout()中对于这种不确定性如是说道:
The order in which the timers are executed will vary depending on the context in which they are called. If both are called from within the main module, then timing will be bound by the performance of the process (which can be impacted by other applications running on the machine).
所以,我们不妨做个大胆的推测:(4)Microsoft Edge对setImmediate的实现机制跟nodejs对setImmediate实现机制是大致相仿的。
最后,我们来看最后一个例子:
从个示例代码的运行结果来看, (5)虽然setImmediate()和setTimeout()所入队的任务都在一个macrotask里面,但是无论书写代码的顺序如何,两者都不会交叉入队。 也就是说,使用同一个方法入队的多个任务,要么不执行,要么就一起执行。
网上对于(浏览器环境下的)非标准的setImmediate的研究资料和技术文章着实少。相对权威点的资料我查到三个:
- w3c的API标准文档;
- 解释为什么在浏览器实现这个API的原因:兼顾callback的执行频率和电耗性能;
- 扎叔的技术文章:humanwhocodes.com/blog/2011/0…
好了,对非标准的setImmediate在event loop中的表现的探索到此为止,有空可继续深入。
MutationObserver
通过MutationObserver接口我们能够去监听DOM树的各种更改。引入该特性是为了替代DOM3事件规范的Mutation Event3特性。MDN文档中如是说。
关于MutationObserver接口的语法以及如何在监听DOM树更改领域的应用,本文不打算深入讲解。本文只是探索通过它来入队的任务是如何参与到event loop中去的。为了此目标,我们不妨基于它来封装这样的一个方法:
function queueMicrotaskWithMutationObserver(callback){
const div = document.createElement('div')
let count = 0
const observer = new MutationObserver(() => {
callback && typeof callback === 'function' && callback.call(null)
})
observer.observe(div, { attributes: true })
div.setAttribute('count', ++count);
}
好的,有了它,我们就可以愉快地玩耍了。我们来看看下面这个示例:
基本上可以,确定通过MutationObserver来入队的任务是属于microtask。这与网上盛传的说法是一致的。为了进一步验证,我们再看看复杂一点示例:
我们可以通过各种更加复杂的示例来观察过MutationObserver在event loop中的表现。我们会发现,它并不具备比其他接口更高的优先级,它跟Promise和queueMicrotask等接口在入队方面的表现完全一样的。通过它来入队的microtask的执行方式一样具有“批量性和连续性”。
async...await
限于篇幅的原因,我在这里就不深入探讨async...await了。也就是说,不会通过深入分析async...await的实现原理来探索它在event loop中的表现以及为什么这样表现。我们只需要记住一个当前的事实就是:async...await是promise的语法糖。所以,在这一小节,我们通过desugar来理解它在event loop中的表现。
我们结合具体的示例来谈谈如何desugar:
const response = await fetch(…);
const json = await response.json();
const foo = JSON.parse(json);
console.log(foo);
fetch(…)
.then(response => response.json())
.then(json => {
const foo = JSON.parse(json);
console.log(foo);
});
desugar一个await,基本可以按三部步走:
第一步,将await所在的语句之后,(块/函数/全局)作用域底部边界之前的所有语句都封装到一个callback函数里面:
const callback = (json) => {
const foo = JSON.parse(json);
console.log(foo);
};
第二部步,将await关键字所在的语句改造为promise...then:
2. response.json().then();
第三步,将callback装进then方法里面:
response.json().then((json) => {
const foo = JSON.parse(json);
console.log(foo);
});
desugar多个await的顺序应该由下到上地应用上面的“算法”。那么我们继续往上desugar的话,应该是这样的:
第一步:
const callback = (response) => {
response.json().then((json) => {
const foo = JSON.parse(json);
console.log(foo);
});
};
第二步:
fetch(…).then();
第三步:
fetch(…).then((response) => {
response.json().then((json) => {
const foo = JSON.parse(json);
console.log(foo);
});
});
为了代码结构变得更扁平,我们把上面嵌套调用then的代码风格改为链式调用then的代码风格:
fetch(…).
then(response => response.json())
then((json => {
const foo = JSON.parse(json);
console.log(foo);
});
到了这里,我们就基本把多个“await”desugar为一个“promise...then”的代码。至于async关键字标志的函数其实就是构造promise对象时传入构造函数的executor,然后函数的return值就是promise的resolve值。比如以下的async标志的函数:
async function foo(){
return 'bar';
}
那么它就会被desugar为:
new Promise(res=> {
res('bar');
})
好,讲完如何将async...await转换为promise写法后,我们在一个例子上面验证一下:
console.log('script start')
async function async1() {
console.log('async1 start')
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 start')
return Promise.resolve().then(()=>{
console.log('async2 end')
})
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('new promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// output:
script start
async1 start
async2 start
new promise
script end
async2 end
promise1
promise2
async1 end
setTimeout
然后我们将其中的async...await降级为promise之后是这样的:
console.log('script start')
function asyn1(){
new Promise(()=> {
console.log('async1 start');
new Promise((res)=> {
console.log('async2 start');
// 这里有一个注意点,只有then方法执行完,promise才会resolve掉
res(Promise.resolve().then(()=>{
console.log('async2 end');
}) );
}).then(()=> {
console.log('async1 end');
})
})
}
asyn1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('new promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
从运行结果的截图来看,代码的执行顺序一致的。这里采用了一个取巧的方式去理解async...await在event loop的表现,不能为长远之计。有时间,再回来深入研究。
(window)event loop的应用
虽然业务型的前端开发中很少需要用到event loop机制,但是还是有不少场景还真的十分需要它。下面列举一下。
1.需要把某些代码延迟到同步代码执行之后再执行
某种情况下,有些框架/类库虽然提供异步的API,但是却没有提供回调给我们hook进去,这个时候就需要祭出我们的杀手锏了:setTimeout。
比如说,早期版本的react的setState方法,并没有提供一个callback让我们hook进去,来获取更新后的state值。但是由于event handler里面的代码是在一个批量更新的事务中,这就导致了这种情况下,setState的执行是“异步”(相对原生的异步行为,这种异步是伪异步)的。这个时候,如果你把获取更新state值的代码写在setState()后面的话,那么你是无法获取到更新后的state值的。比如下面:
import React from 'react';
const Count = React.createClass({
getInitialState() {
return {
count: 0
}
},
render() {
return <button onClick={()=> {
this.setState({count: this.state.count + 1});
console.log(this.state.count);
}}>{this.state.count}</button>
}
componentDidMount() {
}
})
export default Count;
按理说,你要想获取更新后的state值的话,你应该在生命周期函数componentDidUpdate里面去获取。但是,假如我们非要通过写在this.setState({count: this.state.count + 1});
之后的代码去获取呢?我们有什么办法呢?办法还是有的。就是用setTimeout来包裹一下就好:
render() {
return <button onClick={()=> {
this.setState({count: this.state.count + 1});
setTimeout(()=> {
console.log(this.state.count);
}, 0);
}}>{this.state.count}</button>
}
原理是什么呢?原理有二:
- setState方法的异步执行只是“伪异步”,或者说只是react这个类库层面的异步,并不是真正的异步代码(进入过microtask queue/macrotask queue的才是真正的异步代码)。它还是在react应用代码的同步的执行流里面。
- 使用setTimeout能够把获取更新后state值的这个动作变成了一个macrotask,也就是真正的异步代码。而异步代码相比call stack里面的同步代码,总是后执行的。所有的react同步代码执行完之后,state值必定是更新的了,所以这个时候再去执行异步代的话,我们是能够获取到组件最新的状态值。
这里需要强调的是,只提setTimeout只是为了抛砖引玉。在这种场景下,任何把()=> { console.log(this.state.count);}
入队到microtask queue/macrotask queue的方法都是可行的。比如说,setInterval,promise,queueMicrotask等等API都是可行的。在这个需求之下,入队到microtask queue还是入队到 macrotask queue,其实区别都不打,我们只需要使之变为异步代码即可。
这里拿react的setState方法举例子也只是抛砖引玉,所有有这种需求的场景,我们都可以用这种方法实现我们的需求。
2.对一些耗时,阻塞主线程(call stack)的任务进行切片
在不借助web worker的情况,我们如何在主线程去执行一些原本耗时,阻塞主线程的任务(比如CPU-hungry task)呢。答案是基于event loop的运行机制去做任务切片。至于什么是阻塞主线程,阻塞主线程会有什么后果,本文就不赘述了。详情看Event Loop到底是什么鬼?。首先我们来看看下面这个示例1:
<body>
<input type="text" placeholder="我是input,试一试点击我" style="width: 100%;"/>
</body>
<script>
window.onload = function(){
let i = 0;
let start = Date.now();
function count() {
// do a heavy job
for (let j = 0; j < 1e9; j++) {
i++;
}
console.log("Done in " + (Date.now() - start) + 'ms');
}
count();
}
</script>
把这个页面代码运起来之后,你会发现,在控制台打印结果出来之前,页面上的input框是点不动(无法获取焦点)的。这是因为count()的执行一直在占用call stack,导致render callback无法放到call stack去执行。这就是所谓的“阻塞主线程”。现在我们使用setTimeout来对count()这个大任务进行切片(示例2):
<body>
<input type="text" placeholder="我是input,试一试点击我" style="width: 100%;"/>
</body>
<script>
window.onload = function(){
let i = 0;
let start = Date.now();
function count() {
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
console.log("Done in " + (Date.now() - start) + 'ms');
} else {
setTimeout(count); // schedule the new call
}
}
count();
}
经过切片后,这段代码的执行流是这样的:
- 执行1~1000000的累加。在i === 1000000的时候,把下一轮(1000001 ~ 2000000)的累加推入到macrotask queue中;
- event loop去render callback queue中pop一个渲染任务出来执行,以此响应用户的交互;
- 执行1000001 ~ 2000000的累加。在i === 2000000的时候,把下一轮(2000001 ~ 3000000)的累加推入到macrotask queue中;
- event loop去render callback queue中pop一个渲染任务出来执行,以此响应用户的交互;
- ......以此类推,直到累加到1e9。
其本质就是通过macrotask和render callback的交替执行来防止这个耗时的大任务来阻塞call stack。虽然切片之后,累加到1e9的所用的总时间变长了,但是我们保证了界面的可交互性,所以,这一点时间代价不值一提。
如果你想基于示例2去继续优化,想要它既不阻塞call stack,有能够尽量地缩短它的执行时长,方法也是有的。下面,我们来看看示例3:
<body>
<input type="text" placeholder="我是input,试一试点击我" style="width: 100%;"/>
</body>
<script>
window.onload = function(){
let i = 0;
let start = Date.now();
function count() {
if(i < 1e9 - 1e6){
setTimeout(count); // schedule the new call
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
console.log("Done in " + (Date.now() - start) + 'ms');
}
}
count();
}
在这个示例中,我们入队动作放在累计之前,通过这样做,我们能把任务的耗时从示例2的12秒左右减少到8秒左右。这是为什么呢?
这是因为,虽然我们setTimeout()调用没有传递time out时间,即为默认值0。但是HTML5规范要求,嵌套执行的setTimeout的最小延迟时间为4ms,即使我们显式设置为0也不行。大部分浏览器也是这么实现的。当我们把入队的动作放在最前面的时候,在每一轮累加中,我们就能省掉4毫秒。1e9/1e6 = 1000(轮),所以我们能省掉的时间大概为 1000 * 4 = 4000(ms)。这也是跟我 们的实际执行结果相吻合的。至于,把入队的动作放在最前面之后,为什么每轮可以省掉4毫秒呢?你大可依据上面给出的event loop处理模型图来进行推断,这个练习就留给你自己了。
3.保证代码在不同的情况下,保持一致的执行顺序
这种场景一般是特指在不同的条件分支里面,一个分支用了异步代码,一个分支没用异步代码,从而导致在不同的情况下,代码的执行顺序没法保持一致。请看下面这个例子:
customElement.prototype.getData = url => {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
element.addEventListener("load", () => console.log("Loaded data"));
console.log("Fetching data...");
element.getData();
console.log("Data fetched");
在这里,假设我们是想对特定的接口做数据缓存。当你第一次执行的时候,程序会执行第二个条件分支,打印结果是这样的:
Fetching data
Data fetched
Loaded data
当你第二次执行的时候,程序会执行第一个条件分支,打印结果将会这样:
Fetching data
Loaded data
Data fetched
因为代码的执行次序无法得到保证,这就会增加了代码运行的不可预测性,从而理解和维护成本变得更高。为了解决这个问题,我们可以使得两个条件分支的都是异步代码即可:
customElement.prototype.getData = url => {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
其实,这种解决方案对所有的条件语句都适用,不一定是if...else。具体使用的API,也不一定是queueMicrotask和promise...then,只要保证两个条件分支的代码都编排成同种类型的task即可。
4.合并请求,批量执行任务
有时候我们需要批量作业,即将多次连续的操作请求合并为一次的操作请走,最终实际执行一次操作。我们来看看下面的代码:
const messageQueue = [];
let sendMessage = message => {
messageQueue.push(message);
if (messageQueue.length === 1) {
queueMicrotask(() => {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
// 这里用console.log来模拟实际的操作
console.log('最终要操作的数据的json序列是:', json)
});
}
};
sendMessage(1);
sendMessage(2);
sendMessage(3);
sendMessage(4);
sendMessage(5);
// output:
// 最终要操作的数据的json序列是: [1,2,3,4,5]
这里的原理就是:event loop和闭包。 在多次连续的sendMessage()调用中,我们通过判断来确保在第一次调用的时候就把一个函数编排为microtask,并入队到microtask queue中(此处,不一定用“messageQueue.length === 1”这种判断条件,我们也可以用标志位来实现)。此时,我们已经把变量messageQueue保存在函数闭包中了。在后续的sendMessage()调用,我们其实就是操作这个闭包中的变量(闭包变量会相对地常驻内存)。就像上面所说的那样,代码片段的初始执行本身就是一个macrotask,当这个macrotask在call stack执行完毕后(即call stack处于清空状态),此时event loop发现microtask queue上有一个microtask,于是乎就把它推入到call stack去执行。在执行microtask之前,messageQueue变量的值其实已经是数组类型的[1,2,3,4,5]
,那么最后在microtask操作数据的时候肯定没问题了。其实上面的调用代码就等同于:
const messageQueue = [];
messageQueue.push(1);
messageQueue.push(2);
messageQueue.push(31);
messageQueue.push(4);
messageQueue.push(5);
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
console.log('最终要操作的数据的json序列是:', json)
我们可以借鉴这里的思想,模仿实现原生react中setState方法的行为表现:异步和批量:
const componentInstance = {
state:null,
_pendingState:null,
_stateList:[],
render(){
console.log('reconciling...');
},
setState(partialState){
this._stateList.push(partialState);
if(this._stateList.length === 1) {
queueMicrotask(() => {
if(this.state !== null){
this._stateList.unshift(this.state);
}
const finalState = this._stateList.reduceRight((prev,curr)=>{
return Object.assign(curr,prev);
},{})
this._pendingState = finalState;
console.log('reconciliation start...')
this.render();
console.log('reconciliation end...')
this.state = this._pendingState;
this._pendingState = null;
this._stateList.length = 0;
});
}
}
}
首先,我们来考察上面实现的异步表现:
componentInstance.state = {count: 1};
componentInstance.setState({count: componentInstance.state.count + 1});
// 能拿到更新后的state值吗?
// 打印结果:state: 1,所以答案是:不能。
console.log('state:', componentInstance.state);
// 能拿到更新后的state值吗?
// 打印结果:state: 2,所以答案是:能。
setTimeout(() => {
console.log('state:', componentInstance.state);
}, 0);
然后,我们在来考察上面实现的批量更新表现:
componentInstance.state = {count: 1};
componentInstance.setState({count: componentInstance.state.count + 1});
componentInstance.setState({count: componentInstance.state.count + 1});
componentInstance.setState({count: componentInstance.state.count + 1});
// output: state: 2,证明是批量更新
setTimeout(() => {
console.log('state:', componentInstance.state);
}, 0);
event loop在javascript异步编程领域下,应该还要很多的应用场景,期待有更多的发掘。
nodejs中的event loop
详情请查看我写的文章nodej event loop。
event loop的面试题
面试题难度的几个层级:
- 比较生冷的考法,比如考你以此几点:
- macrotask,microtask和render callback执行的前提是call stack为空;
- 三个队列执行的优先级:microtask > render callback > macrotask;
- 理解macrotask与microtask执行的先后顺序;
- 理解microtask执行的批量性,连续性;
- 理解入队时机对执行流的影响,理解promise对象的构造代码是同步执行的。
- 掌握比较冷僻的,setImmediate,async...await和mutationObserver;
好下面,我们来看看市面上面试题:
问题1: 以下的三个场景的执行结果会是怎样?为什么?
// 场景1:
function foo() {
setTimeout(foo, 0);
};
foo();
// 场2:
function foo() {
return Promise.resolve().then(foo);
};
foo();
// 场景3:
function foo() {
foo()
};
foo();
解析:
- 场景1会无限递归执行,js引擎不会报“maximum call stack size exceeded”,同时界面能够响应用户的交互;因为, macrotask与microtask执行的前提是call stack为空。call stack同一时间里面只有一个call frame;界面之所以能够响应用户交互是因为用户通过交互产生的各种render callback的优先级比macrotask的优先级要高,意思是优先响应UI。
- 场景2也会无限递归执行,js引擎不会报“maximum call stack size exceeded”,但是界面不能够响应用户的交互;不能报“maximum call stack size exceeded”的原因跟场景1是一样的。界面不能够响应用户交互是因为microtask的优先级比render callback的优先要高,这样子的话,连续,无限的microtask执行就长期占用了call stack,使得render callback无法得到执行的机会,界面也就没法重新渲染了(为了验证这个场景的执行结果,把当前的render process搞崩了好几次)。
- 场景3无法递归执行,js引擎会报“maximum call stack size exceeded”。这是同步代码,每递归一次,就会增加一个call frame,所以必定会引起call stack长度溢出。
问题2: 以下的打印顺序结果会是怎样的呢?:
setTimeout(function() {
console.log(1)}, 0);
new Promise(function executor(resolve) {
console.log(2);
for( var i=0 ; i<10000 ; i++ ) {
i == 9999 && resolve();
}
console.log(3);
}).then(function() {
console.log(4);
});
console.log(5);
解析: 打印结果是:
2
3
5
4
1
考点有:
- event loop的基本处理模型
- promise的executor是属于同步代码,即归属于js引擎初始化后的第一个macrotask。
- 题中的for循环一直在占用call stack,所以,后面的“console.log(5);”也没法执行。
- promise一旦resolve掉,相应的callback才能入队到microtask中。
问题3: 以下的打印顺序结果会是怎样的呢?:
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 2
console.log('start');
// 位置 3
Promise.resolve().then(function () {
// 位置 5
console.log('promise1');
// 位置 6
Promise.resolve().then(function () {
console.log('promise2');
});
// 位置 7
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 0);
});
// 位置 4
console.log('done');
解析: 打印结果是:
start
done
promise1
promise2
timeout2
promise3
timeout1
这里有好几个考点。首先在考你:
- 位置1和位置7到底谁先入队macrotask queue?
- 位置6和位置7几乎同时分别入队到microtask和macrotask中,当前的microtask执行完,call stack为空的时候,到底先执行谁?
针对考点1,其实就是考你同一个类型的任务,入队时机的问题。这种问题得具体问题具体分析。不过一般是看以下几点:
- setTime调用时传入的delay时间值(单位为毫秒);
- promise被resolve的时机(因为这会影响到后面then方法callback的入队时机);
- 当两个setTime的delay时间值一样的时候,我们就看它们在代码书写期的先后顺序,不相等的时候(并且前面代码的执行耗时几乎可以忽略不计),那么我们就比较它们时间值得大小。越小越早入队。
- 要是当前入队动作发生前时候有同步代码阻塞call stack,注意评估该同步代码的执行时间。
拿setTimeout这个入队动作举个例子,两个setTimeout的入队顺序算法如下:
promise也是一样的,只不过它所对应的delay时间是由resolve方法执行的时间点来决定的。
回归到本示例,因为位置7前面的同步代码的执行时间几乎忽略不计,而位置1总的delay时间则为1000毫秒。所以,最先入队的是位置7。假如,我们把位置7的delay时间改为1001ms的话,那么打印结果将会是这样的:
start
done
promise1
promise2
timeout1
timeout2
promise3
可以看出,“timeout1”在前面,“timeout2”在后面。具体的执行结果截图就不给出,大家可以自行去验证。
为了测试我们算法的准确性,那我们再来测试一下在delay时间相等的情况:
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 2
console.log('start');
// 位置 3
Promise.resolve().then(function () {
// ....
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 1000);
});
// 位置 4
console.log('done');
那么打印结果将会是:“timeout1”在前面,“timeout2”在后面。如果我们调换一下两者的书写顺序:
// 位置 2
console.log('start');
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 1000);
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 4
console.log('done');
那么打印结果将会:“timeout2”在前面,“timeout1”在后面。为了证明我们算法的准确性,我们最后来验证一下“同步代码的执行时间不能忽略不计的情况”。我们有以下代码:
// 位置 1
setTimeout(function () {
console.log('timeout1');
}, 1000);
// 位置 2
console.log('start');
// 位置 3
Promise.resolve().then(function () {
// 位置 5
console.log('promise1');
// 位置 6
Promise.resolve().then(function () {
console.log('promise2');
});
// 阻塞2ms
const now = Date.now();
while(Date.now() - now < 3){}
// 位置 7
setTimeout(function () {
// 位置 8
Promise.resolve().then(function () {
console.log('promise3');
});
// 位置 9
console.log('timeout2')
}, 999);
});
// 位置 4
console.log('done');
以上代码中,虽然位置7本身的delay时间比位置1的delay时间少了1毫秒,但是位置7前面在call stack上阻塞了2ms,那么位置7的入队所用总时间 = 999 + 2 = 1001(ms)。1000 < 1001,所以,位置1先入队。最终打印结果将会:“timeout1”在前面,“timeout2”在后面。执行结果截图为证:
对于promise而言,只要把delay之间改为resolve所需要的时间即可,在这里就不多加讨论了。一般而言,面试不会出一些那么牛角尖的题目,但是如果我们自己提前深入到这一点话,那么我们就能够应付得了一些丧心病狂的面试题。
针对考点1已经解释完了,那么看看考点2。哎,其实考点2也没有啥好说的,就是在考microtask的连续性。换句话说,要是同时入队两个任务,一个是macrotask,一个microtask,那么接下来要执行的肯定是microtask。
针对同一个示例,我们可以根据上面给出的面试题考点来触类旁通地改造它,然后在浏览器的控制台运行起来,看看代码的执行结果跟自己推演的结果是否一致就可以。多加练习,相信你会越来越有信心,对(window)event loop的理解也会更加深入的。
总结
参考资料
- Event loop: microtasks and macrotasks;
- Concurrency model and the event loop;
- tasks-microtasks-queues-and-schedules
- event-loop-processing-model;
- In depth: Microtasks and the JavaScript runtime environment
- General asynchronous programming concepts;
- Using microtasks in JavaScript with queueMicrotask();
- Does async/await blocks event loop?;
- 通杀 Event Loop 面试题;
- Explore the Magic Behind Google Chrome;
- how-javascript-works-event-loop-and-the-rise-of-async-programming-5-ways-to-better-coding-with;