前言
对于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)。
其实关于事件循环可以简单记作为:
- 任何事件循环都是以 Macrotasks --> MicroTasks --> UI Rendering 顺序进行的。
- 新增的任何 Microtask 只会保存在 当前事件循环的 Microtasks中。
- 新增的任何 Macrotask 只会保存在 新增Macrotasks 中,它是下一轮事件循环的 Macrotasks。
最后
原来我心想能带入 NodeJS 的东西, 但是未曾想 NodeJS的底层细节如此复杂,远不是Javascript事件循环模型能概括得了的。对于不明白的原理,小生不敢自以为是,因此只能稍作安排。libuv好难啊。。。
限于篇幅,只能说这么多……但是关于这部分内容涉及知识量极大,有谬误之处,还请慷慨指正,不胜感激。
同步转发:
https://juejin.im/post/5ecfa657f265da76ed484fd9
本文详细介绍了JavaScript的事件循环机制,包括宏任务与微任务的概念和区别。通过示例,解释了事件循环如何处理任务序列,以及如何影响页面渲染。文章强调了阻塞事件循环可能导致的性能问题,并探讨了解决方案——微任务的使用,如Promise。文章最后更新了事件循环模型,帮助读者更好地理解JavaScript异步执行的底层原理。
2423

被折叠的 条评论
为什么被折叠?



