前言
对于Javascript异步,我是从其他面向对象编程语言的并发编程层层向下介绍的,在一些细节上并没有多详细说明。此次算是补充所缺,在选择主题时,我茫然了好一阵,决定从微任务和宏任务开始入手,阅读下文时,尽可能有些Promise的基础。
开始吧。
事件循环
首先补充一下上次事件循环的更多细节:
- 事件循环开启
- 将新消息序列设为当前消息序列
- 从当前消息序列中取出任务 消息序列是先入先出结构,也就是说它是按照顺序取出的。
- 处理任务 自上而下运行JS代码 如果发出异步请求,然后将消息保存到这个新消息序列(若无则新建)中 新消息序列的任务全部被阻塞,等待下次事件循环迭代处理。
- 检查当前消息序列是否为空,是则继续,否则转至 (3)
- 是否触发UI Rendering事件,是则立即进行视图渲染。 否则继续
- 是否新增消息序列, 如果是,开始下一轮事件循环,回到(2) 否则继续
- 确定再无事件,关闭事件循环。线程进入休眠; 直至有事件发生,新建消息序列并保存消息,转至(1)。
从上面的过程中,可以得到下面的结论:
- 此次事件循环将消息序列进行了细分,即当前的和新增的,两者并不同。
- 一次事件循环,处理一个消息序列,而不只是一个消息。
- 一个事件循环都只渲染一次。
- 此事件循环仍需改进
宏任务
我们常说的任务(task),都是宏任务(Macrotask),由宏任务组成的消息序列,称作宏任务序列,即 Macrotasks(套娃嫌疑确定……),一般都是涉及到 IO操作(包括网络请求、页面渲染等)的任务,例如:
- scripts: 脚本代码
- Mouse/Key Events : click、onload、input等。
- Timers: 定时器,例如
setTimeoutsetInterval等。 - 未完待续
注意:Timers工作过程是这样的:
- 调用
setTimeout时,将消息(回调函数,即task)放到延迟消息队列中 - 延迟消息队列中的task到期后,放入新Macrotasks中。
- 在下次事件循环迭代中等候处理。
示例: 首先搞一个用于生成定时器的函数。
<div id="text"></div>
var text = document.querySelector('#text')
var genTimer = (string,delay=0,cb)=>{
return ()=>{
setTimeout(()=>{
text.innerHTML+=string+'<br/>'
!cb||cb();
},delay*1000)
}
}
现在请一直记住脚本代码和Timer是一个宏任务。
并且这个函数会一直用到结束为止。
(……想必上面的代码极易理解的吧……)
事件循环与渲染
准备两个消息序列的任务, 算是模拟两次事件循环的消息序列。
var macroTasks = []
macroTasks[0] = [genTimer('A1: Be honest rather clever.',1),
genTimer('A2: Being on sea, sail; being on land, settle.',1)]
// 两个队列的到期时间不一致!
macroTasks[1] = [genTimer('B1: Failure is the mother of success.',3),
genTimer('B2: The shortest answer is doing.', 3)]
/*
macroTask[0] 是 下次的消息序列的任务
macroTask[1] 是 下下次的消息序列的任务
*/
请注意, macroTasks[0]中的task都在1s后到期。因此下轮事件循环中会处理这些tasks。同理,macroTasks[1]中的tasks都在3s后到期,因此会在下下次事件循环中处理这些tasks。
为什么要注意这些区别呢? 因为它们分两次事件循环的处理的!
text.innerHTML +='start......<br>'
macroTasks[0].forEach(f=>f())
macroTasks[1].forEach(f=>f())
text.innerHTML +='end......<br>'
输出如下:

A1和A2 以及B1和B2仿佛是分成了两次渲染出来的! 这才是关键。
说明如下:
- 一次事件循环,只会处理一个消息序列。由于
A1和A2的Timer是同时到期的,因此会被划分到同一个消息序列中,而一次事件循环只渲染一次,所以A1和A2同时被渲染。 - 同理,
B1和B2也是同样的情形;但是要注意:B1和B2的到期时间与A1和A2的并不同,因此它们分为两次事件循环处理的。
基本过程如下:



综上所述可以知道:
同一个消息序列中的task会共享同一次事件循环,并且会等待所有task处理完成后才会渲染
为什么我们要得到这个结论? 继续吧。
阻塞问题
大多数情况下,我们是不会感知到阻塞的,这一方面是CPU计算能力强悍,另一方面也是JS引擎高性能的原因。
不过偶尔也会出现例外,事实上,我们所说的宏任务基本上都是工作量较大的任务,例如我们的JS代码文件(少说也要有2000行代码吧),如果处理不好,就很容易阻塞(即响应时间超长)。
现在模拟一个阻塞任务,例如:
var macroTasks =[]
// 在下次事件循环中,新增一个普通任务,
macroTasks.push(genTimer('01:Better to light one candle \
than to curse the darkness.',0))
// 新增一个超长事件的阻塞任务。
// 阻塞时长3s
macroTasks.push(genTimer('XXXXXblocking long time!',
0,
()=>{
console.log('i am running!');
let c = Date.now();
while((Date.now()-c)<=3000);
}))
text.innerHTML +='hello<br>'
macroTasks.forEach(f=>f())
text.innerHTML +='byebye!<br>'
注意:: macroTasks中所有任务都是同时到期的,因此可知它们会被划分到同一个事件循环中;
然后输出如下(请耐心看下去):

在上面的示例中,尽管将 microtasks中的所有内容都分到了统一个时间循环中,但它们并没有如我们所想的那般在 0s后输出。而是同时阻塞了3s。 这是又为何?
说明:
- 因为
microtask中的所有任务共享一次事件循环,并且只有事件循环的所有任务都处理完毕时才会发生渲染事件。 - 所以可以知道,
microtask[0]和microtask[1]只有都被处理完成后才能够渲染!但是由于microtask[1]产生了阻塞,最终导致了卡顿。
所以可以得到下面的结论:
- 如果消息序列中有一个
task陷入阻塞,那么就会导致整个事件循环陷入阻塞,最终导致卡顿。 事实上,一旦事件循环陷入阻塞,也会影响到下次事件循环的运行。
接下来,当做我们全然不知道 macrotasks[1] 是阻塞任务。
上面的代码总是Hello之后就ByeBye!!了,内容完全没输出,这是没道理的。所以姑且为了用户体验着想,代码改成这样:
var macroTasks =[]
macroTasks.push(genTimer('XXXXXblocking long time!',
0.5,
()=>{
console.log('i am running!');
let c = Date.now();
while((Date.now()-c)<=3000);
}))
macroTasks.push(genTimer('01:Be honest rather clever',1))
macroTasks.push(genTimer('ByeBye',1))
text.innerHTML +='hello<br>'
macroTasks.forEach(f=>f())
注意:上面的代码中,macrotasks[0]和macrotasks[1]以及macrotasks[2]的事件循环不同,它们已经被错开了。但是仍然被硬生生卡到3s后才输出。原因很简单,因为当前事件循环仍在处理中,所以就推迟了进入下次事件循环的时间。
因此总结一条:永远不要阻塞事件循环,它是所有异步模型的黄金铁律。因为它不仅导致严重的卡顿,而且极其影响用户体验,更重要的是:事件循环阻塞就意味着更大的性能开销。
因此我们只能在阻塞任务之前处理所有任务,但通常情况下仍不可避免的受其影响,例如阻塞任务的延迟时间为0s时,那么任何宏任务都会受阻塞影响, 惹怒用户第一步,循环阻塞想呕吐
微任务便是上面的一种解决方案(当然最直接的处理办法就把阻塞任务给Pass掉,但是大多数情况下,这种任务偏偏就很重要。)
使用微任务
微任务Microtask简单来说是能够快速完成的任务,并且它保证所有的tasks处理完成后(但仍然在UI Rendering前)进行处理完成。在ES8规范中,微任务用 Job 表示,嘛,不过喜欢 microtask的人更多些,两个术语表达的意思都是相同的。
最经常使用的微任务是Promise。
例如下面代码:
var macroTasks =[]
macroTasks.push(genTimer('XXXXXblocking long time!',
0 ,
()=>{
console.log('i am running!');
let c = Date.now();
while((Date.now()-c)<=3000);
}))
macroTasks.push(()=>{
return new Promise(res=>{
text.innerHTML+='lark in the clear air<br/>'
res('success!');
})
})
macroTasks.push(()=>{
return new Promise(res=>{
text.innerHTML+='ByeByeBye!<br/>'
})
})
text.innerHTML +='hello<br>'
macroTasks.forEach(f=>f())
输出:

过程图如下:

macroTasks中混入了两个微任务)
虽然现在仍然还是受阻塞影响,但是至少表面上没什么卡顿。当然这只是一种实验;生产环境下无论如何也不要这样做。自此不再赘述。
微任务在浏览器环境下有三个:
- queueMicrotask: 微任务回调函数。
- Promise: Promise,最常用的
- MutationObserver: DOM树监听
这里面除了Promise其他都不怎么常用,有兴趣的可以去了解一下。不过微任务给人的感觉,就像是一个可以追加到宏任务后面的同步代码,微任务定义不重要,重要的是,微任务尽可能是体积较小的任务代码,不要尝试阻塞微任务,否则就失去了微任务的本来含义。
将上面的代码再进一步改写:
var text = document.querySelector('#text')
function genMicrotask(str){
return async ()=>{
text.innerHTML += str + '<br>'
return str ;
}
}
var microTasks = [genMicrotask('01: For man is man and master of his fate.'),
genMicrotask('ByeBye!!!')]
text.innerHTML='hello!!!<br>'
microTasks.forEach(f=>f())
输出:
hello!!!
01: For man is man and master of his fate.
ByeBye!!!
很完美,至少比上次的看起来清爽了许多。 注意:async函数最终也返回一个settled的Promise。
好了,微任务和宏任务就先到这里。 (? Promise放后面吧,相信看的人也不是零基础,总知道用法吧……)
更新事件循环模型
根据上面所有内容,加入macroTasks和microTasks等元素,就是:
- 事件循环开启
- 将新增Macrotasks设为当前Macrotasks
- 从当前Macrotasks中按顺序取出任务
- 处理任务 自上而下运行JS代码 如果发出异步请求,然后将消息保存到新Macrotasks(若无则新建)中; 如果存在Microtasks,那么:
- 从Microtasks中取出任务
- 运行代码
- 如果发出异步请求,然后将消息保存到新Macrotasks(若无则新建)中
- 如果存在Microtask,仍然将其添加到当前macrotask的Microtasks。。
- 检查当前Macrotasks是否为空,是则继续,否则转至 (3)
- 是否触发UI Rendering事件,是则立即进行视图渲染。 否则继续
- 是否有新增MacroTasks, 如果是,开始下一轮事件循环, 回到(2);否则继续。
- 确定再无事件,关闭事件循环。线程进入休眠; 直至有事件发生,新建消息序列并保存消息,转至(1)。
其实关于事件循环可以简单记作为:
- 任何事件循环都是以 Macrotask1 --> MicroTasks --> Macrotask2 --> UI Rendering 顺序进行的。
- 新增的任何 Microtask 只会保存在 当前正在处理 Macrotask 的 Microtasks中。
- 新增的任何 Macrotask 只会保存在 新增Macrotasks 中,它是下一轮事件循环的 Macrotasks。
最后
原来我心想能带入 NodeJS 的东西, 但是未曾想 NodeJS的底层细节如此复杂,远不是Javascript事件循环模型能概括得了的。等后篇再做安排。反正文档里的事件循环只要不吹毛求疵,也够用了。
限于篇幅,只能说这么多……但是关于这部分内容涉及知识量极大,有谬误之处,还请慷慨指正,不胜感激。