以往说起JavaScript的异步,最常见的解释是说宏任务和微任务。
但实际上,随着浏览器需要应对日益复杂的页面效果需求,简单的宏任务微任务两条消息队列已经不足以应对。
根据 WHATWG(web超文本应用技术工作组) 社区制定的HTML生活水平标准8.1.7(最近一次更新于2023年6月16日)
<地址:网页标准 (whatwg.org)>
在8.1.7.1《定义》中最新解释:
事件循环具有一个或多个任务队列。任务队列是一组任务。
任务队列是集合,而不是队列,因为事件循环处理模型从所选队列中获取第一个可运行的任务,而不是使第一个任务出列。
微任务队列不是任务队列。
浏览器执行js的机制是通过事件循环实现的。事件循环允许有多个任务队列。任务队列是一组任务的集合。每一次事件循环都会从这个集合(任务队列)中取出第一个任务来执行。
整个意思大体还是说明了js的运行机制是单线程的,即由 渲染主线程 通过每一次事件循环拿到一个事件进行执行操作。
在执行完一个操作之后,渲染主线程中的任务没了,就会再去消息队列中取一个出来执行。直到所有的消息队列全部清空(此处仅指js任务,不包含其他工作。)。
在消息队列清空后,此时再添加任务时,先进入消息队列中进行排队。如果这个任务是消息队列中第一个,则会被主线程取出并执行。
(我这蹩脚英语真的看不明白,所以直接上翻译了。结果翻译出来还是看不懂。)
理解出来的意思是:
如果微任务队列中有任务,必须优先从微任务队列中拿第一个任务并执行。
另外,根据这次规定,不同的事件可以放在同一队列,但一个队列中只能存放同类型事件。因此在微任务队列以外,还延伸出了一些其他的队列,比如用户交互任务队列(点击事件、输入事件、失焦等类似于监听器的事件),延时任务队列(如定时器、延时器等计时事件),网络请求任务队列(ajax请求之类)......
如果所有队列中都存在任务,除微任务队列中必定先执行以外,其他事件又是怎么样的先后顺序呢?或者说,消息队列中的任务执行完后,检查了微任务队列后,发现微任务都做完了,接下来是先做用户交互还是延时任务还是什么任务呢?
这个就是不同浏览器自行决定的了。一般都是有相应的权重比,权重高的会优先执行。但并不会无限优先。无限优先的结果就可能会导致进程饿死(好比一家子普通客人进了一家餐厅,但后来的几百位客人全是vip,那这一家子几个人不全都饿死了?)。
为了避免进程饿死,浏览器可能会有对应的措施,在连续执行多次同一类型的事件后,下一次事件循环可能会优先选择其他队列中的任务。具体优先级看不同浏览器如何决定。
这种情况姑且不论,只讨论通常情况。此处以微任务队列、交互任务队列、延时队列为例。
大致的样子是这样的
已知JavaScript的运行机制是单线程的,同步任务按从上往下的顺序执行。 此时有一份.js文件开始被解析。所有任务先按照顺序在消息队列中排队,就按照.js文件中写的顺序来排,从上到下的顺序排先后。
同步任务,直接执行,该输出的输出。解析到中间,发现有异步任务了,按照这次异步任务的类型,就像垃圾分类到垃圾桶一样,给他分类投放到不同类型的消息队列中去。是延时任务的放进延时队列,是交互事件的放到交互队列去,如果有微队列(可以通过promise.resolve().then(fn())去创建微任务)就放进微队列。
然后分类并投放掉本次解析到的异步任务之后,再继续进行全局同步任务,往下走。等到下面又遇到异步任务之后,就会进行下一次分类投放。直到所有的全局中的同步任务全部执行完后,这时候消息队列空了,就会去那些二级队列中去挑任务。
首先先检查微任务队列,微任务队列中有任务,那就先从微任务队列中把第一个任务取出来,然后开始做。做完之完这件事之后,微任务队列又空了,就继续去二级队列中挑选,依然是先从微任务队列中先拿。
那这时候微任务队列已经没有了,也空了。接下来就要去别的任务队列中拿任务了。如果是谷歌浏览器,应该是优先拿用户交互任务队列中的任务,因为权重高于延时队列(谷歌认为延时可以不做,但用户的交互必须响应,否则会影响用户体验。而延时任务都已经延时了,也不差这几毫秒。)
等用户交互队列中的任务清空了,再去权重更低的延时队列中取第一个任务。
倘若延时队列中的第一个任务取出来之后,发现又是有同步任务,有微任务,有用户交互任务,有延时任务,那么还是按照上面的顺序依次进行。
说的很抽象,换成生活中比较具体的例子(纯粹是例子,没有任何象征含义):
假如现在有一个非常非常有职责感的老师在上课,那么现在的主线程任务就是上课,不管发生什么,就一定要把课给讲了,有什么事都下课再说,校长来了都不行。
但是课上有几个偷偷摸摸干坏事的学生在讲小话、看小说,另一批玩手机的学生,刷抖音、打王者,但都没有影响课堂秩序,虽然看着烦心,课还能讲的下去,就拿小本本记了一下。这时候有两个打王者的,越玩越暴躁,开始互喷,在班上大吵大叫,甚至直接打起来了。
老师大喝一声:都给我坐下!也许是凶狠的样子震慑住了闹事的,就又继续讲课了。
但是一下课,老师首先处理的是哪一类学生?肯定是严重干扰课堂秩序的学生呀!这就好比是给任务上了promise,一下子就严重起来了,必须最先处理。(也许它叫微任务,但它的事儿是真的不小!)
但是还有两类学生,不能让他们出去玩吧?到时候还能找到人影吗?老师就说,让玩手机的学生,先在旁边站着去(装上监听器,等到promise完成后,优先执行交互任务队列的任务),让看小说讲小话的学生先回去等着下节课再找他们(装上个定时器,定时器到点了之后,再执行延时任务)。
这么一说,应该差不多就很好理解了。有promise的,就是十万火急,主线程消息队列完成上课这样的全局任务之后,就要立刻处理性质恶劣的打架行为微任务。微任务完成之后再处理那些玩手机让自己看着就很糟心的交互任务。等交互任务完成之后,再处理不是非常恶劣的看小说和讲话的延时任务。
话不多说,就用几个案例来验证这套理论是否正确
例1:
function a() {
console.log(1)
Promise.resolve().then(function () {
console.log(2)
})
}
setTimeout(function () {
Promise.resolve().then(a)
console.log(3)
},0)
Promise.resolve().then(function () {console.log(4)})
console.log(5)
按照刚才说的理论,首先,js引擎从上往下解析js代码,function a()是定义,但未被执行,因此不进行操作。下一步是setTimeout,延时三秒后执行。延时任务放进延时队列等待条件满足后执行。再下一步是promise微任务,放进微任务队列。最后解析到console.log任务,这是个全局中的同步任务,因为即使他处在代码块最下端,但它仍然会最先执行,因为他无须进入队列排队,而是在主线程中被解析后直接执行操作。因此最先打印出来的是(5)。
当全局中的同步任务全部完成后,先去微任务队列中检查。发现有微任务(第十一行),优先执行此行,因此第二个打印出来的是(4)。
微任务队列也清空了。接下来去别的队列中找任务。发现只有延时任务,那也只能执行延时任务。此时,主线程将延时任务逐行解析,先将promise放进微任务队列中等待主线程结束,console.log(3)在主线程中不进入消息队列而直接执行。执行完后,主线程清空,再去拿微任务队列的事情做。然后就是执行方法a()。
a()的执行步骤也没有什么区别,一样是:逐行解析——>执行主线程同步任务——>异步任务投放队列——>事件循环优先拿取微任务——>微任务全部结束后拿取其他异步任务。
所以最后的结果是: 5 4 3 1 2
例2:
const btn = document.querySelector("button");
btn.addEventListener('click', btnClick);
function btnClick() {
console.log(3)
}
console.log(1);
setTimeout(() => console.log(2), 0);
btn.click();
Promise.resolve().then(function () {
console.log(4);
});
console.log(5);
首先是定义了一个btn绑定了html文档中的一个button按钮。并为其注册点击事件,点击后触发btnClick方法,会控制台输出3.
全局同步任务:
1、第七行log(1)
2、第十五行log(5)
微任务队列:第十二行Promise,里面log(4)
异步任务有两个:
1、第九行延时0秒后log(2)
2、第十行js控制点击事件触发log(3)
按照刚才的理论,其实结果似乎应该是15432,因为先执行同步,然后微队列,然后点击事件,最后是延时事件。
但实际上却是:
这一开始也让我思绪费解,直到我F12后,断点调试查看了整个流程,发现在解析完setTimeout行之后,并没有立即执行,而是走到下面一行,但btn.click()却被立即执行了,顺序在最后一行的log(5)同步任务之前。这个问题我不明白为什么按钮点击事件会被作为同步任务给执行,于是问了工作年限更高的人之后,才得到一个从没关注过的细节问题:
点击事件本身是属于异步操作。如果是浏览器默认的鼠标点击后触发,则何时点击何时触发;
如果是通过js控制的btn.click()事件,则会在js中被当做同步任务解析并立即执行。
因此,在js中执行上面的代码,实际上给出的顺序是:
1、同步任务:log(1), btn.click() ——> log(3), log(5)
2、微任务:Promise.resolve().then(function () {console.log(4)});
3、延时任务:setTimeout() ——> log(2)
也就是1 3 5 4 2
总结:
1、事件循环使得渲染主线程每次从消息队列中拿出一件任务进行操作并执行(js是单线程的),消息队列中的任务没有优先级,遵循先进先执行的原则(js依照书写顺序,自上而下执行);
2、异步任务会被分发给不同类型的任务队列(宏任务队列被拆分得更细,不再是简单的单条宏任务队列,而是众多类型事件集合);
3、主线程中,当前任务完成后,必须优先从微任务队列中拿任务(所有异步任务中微任务优先级最高);
4、当微任务队列清空后,优先从权重更高的队列中拿新任务。