Tasks, microtasks, queues and schedules【译】
正文
当我把我想写一篇关于microtask队列和浏览器event loop的文章的想法告诉我的同事Matt Gaunt的时候,他说:“老弟,实话跟你说,我绝逼不会去读你写的这篇文章”。然而我最后还是写了,所以各位都坐下,耐心阅读这篇文章吧!
当然,如果比起文字,视频更是你的菜的话,Philip Roberts在JSConf上已经做了一个非常精彩的关于event loop的演讲,这是传送门(里面没有涉及到microtask,但是对除了microtask的其他部分是个很好的讲解)
ok!是时候表演真正的技术了,showtime!
执行这一小段JavaScript:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');控制台会按照什么顺序打印日志呢?
正确的答案是:script start、script end、script end、promise1、promise2、setTimeout,但是就浏览器的实现而言却是非常混乱的。
Microsoft Edge、Firefox 40、iOS Safari、以及mac端的Safari 8.0.8打印setTimeout在promise1和promise2之前,当然这可能是浏览器竞争导致的结果。
BUT!
Firefox 39和Safari 8.0.7是按照正确顺序打印的,就问你惊不惊喜、意不意外
为什么会这样执行呢?
为了理解这样执行的理由,你需要知道event loop是如何处理task和microtask的,当你初次邂逅event loop的时候也许你会削(xué)微有点头痛,但是不要方张,先深呼吸一口.....
每个线程都有自己的event loop,所以每个web worker也有自己的event loop,所以他才能独立的运行。鉴于浏览器的所有窗口共享一个event loop(因为不同窗口可以同步通信),event loop不间断的循环(所以才叫事件循环嘛 -_- ),执行排在队列中的task。一个event loop有多个task source,每个task source保证了在该task source下的task执行顺序(比如IndexDB就定义了自己的task source),但是浏览器可以在每次event loop中选择从哪个task source开始执行task,这就允许浏览器优先执行对性能敏感的任务,比如用户输入user-input。
是不是有点晕了,跟着我一起逐步分析下
task
安排了task,以便浏览器从内部访问JavaScript/DOM,并且保证这些操作能按顺序发生,在task之间,浏览器可能会更新渲染。
从接收鼠标点击事件到执行回调函数需要安排任务,同样解析HTML也需要安排任务,上文举到的例子setTimeout也需要安排任务
setTimeout等待一个给定的延迟时间,然后给他的回调任务安排一个task,这就是打印setTimeout在script end之后的原因,并且打印script end是第一个task的一部分,打印setTimeout是另一个单独的task
ok,这一部分我们暂时通关了,接下来这个部分需要你keep strong
microtask
通常情况下,在当前正在执行的script之后会立即执行的事会被安排为microtask,比如对一些批量操作做出反应、或者是做一些异步的事(将异步操作安排为microtask不需要负担安排一整个新的task的代价),只要没有在执行中的JavaScript,microtask队列就会在回调之后开始处理,在每一个task结束之后也会开始处理,在microtask运行中添加到队列末端的microtask也会执行,microtask包括mutation observer callbacks,以及上文提到的promise callback。
当一个promise完成或是已经完成,那么将会为了他的回调函数塞一个microtask进入队列,这样可以确保即使一个promise已经完成,他的回调函数也是异步执行的。所以,针对一个已经完成的promise调用.then(yey, nay),会立刻将一个microtask塞进队列。这就是promise1、promise2,在script end后面打印的原因,因为当前正在执行的script必须在处理microtask之前执行完毕。promise1和promise2在setTimeout之前打印是因为microtasks总是在下一个task之前开始执行。
所以,跟着我左手右手一个慢动作,一步一步的调试
是的,我写了一个一步步执行的动画程序。你会怎样过周末呢?和你的盆友们出去晒个太阳?然而!兄弟我什么都没干,全在思考这个程序,以防我的ui表达的意思不那么清楚,点击上方的箭头执行程序吧。
浏览器之间的表现有何不同?
一些浏览器打印的是script start、script end、setTimeout、promise1、promise2。这些浏览器执行promise回调函数在setTimeout之后,有可能是他们讲promise回调函数当做一个新的task塞进队列而不是一个microtask。
这是可以理解的,因为promise来源于ECMAScript而不是HTML。ECMAScript有一个和microtask相似的概念叫做job,但是这个相关性在模糊的在线讨论之外并没有明确下来。但是普遍共识是promise应该是微任务,并且有充分的理由
把promise当做task会导致性能问题,因为回调函数会因为一些与task相关的事而延迟,比如说渲染,但是事实上这是不必要的。这种情况还会导致与其他task源交互的不确定性,甚至打断与其他API的交互,后面会更详细的介绍。
这是一个让promise使用microtask的edge文档,而WebKit正在连夜做这件事(让promise使用microtask),我想Safari最终也会修复这个问题,并且Firefox好像会在firefox43中修复这个问题。
怎么辨别是task还是microtask
测试是一个办法。
观察promise和setTimeout什么时候打印,但是运行代码测试有个问题,就是你不知道这个浏览器是不是正确的实现(比如上述的浏览器问题导致的打印顺序不一致)。
最主要的方法还是读文档。举个栗子,step 14 of setTimeout排了一个task,而step 5 of queuing a mutation record排了一个microtask。
顺便提一嘴,ECMAScript把microtask叫做job,可以看下这个文档step 8.a of PerformPromiseThen。
现在让我们看下更为复杂的场景!
第一个Boss
在写这篇文章之前我还差点把这段代码写错了23333,这是一个html片段:
<div class="outer">
<div class="inner"></div>
</div>给出下面这一段js,如果我点击div.inner会打印些什么东西呢?
// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');
// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
});
// Here's a click listener…
function onClick() {
console.log('click');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise');
});
outer.setAttribute('data-random', Math.random());
}
// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);在看答案之前先自己试一下。
线索:click事件不止触发一次
试一下
你猜到不同浏览器有不同的打印顺序了吗?如果有的话,那你就猜对了。
谁是正确的?
dispatchclick事件是一个task,Mutation observer和promise callback是microtask,setTimeout callback是一个task,下面是运行过程。
这是chrome的正确打印,对我来说有一些新的感悟,只要没有任何的javascript在执行(即调用栈为空)microtask就会执行,也可以理解为一个task结束后就会观察有没有microtask在队列中,如果有就会执行microtask。这个规则来自于调用callback的HTML文档。
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
并且一个microtask checkpoint包含检查microtask队列,除非我们已经在执行microtask。类似的,ECMAScript这样描述job。
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
不同的是在HTML文档里面是“must be”而在ECMAScript中是“can be”
浏览器出了什么问题
Firefox和Safari在click listener之间正确的执行了microtask,从mutation的回调的执行可以看出。但是看起来promise所处的队列跟chrome不同,这是可以原谅的,因为job和microtask之间的联系并不明确,但是我还是觉得promise应该在两个listener回调之间执行。Firefox ticket. Safari ticket.
对于Edge来说,我们已经知道Edge排列promise的方法就不对,并且也没有在两个click listener之间正确的执行microtask,而是在所有listener调用后执行microtask,这就导致了在两次click打印后孤零零的打印了一个mutate。Bug ticket
第一个Boss的暴躁老哥
使用和上面一样的例子,我们执行下面的代码会发生啥?
inner.click();试一下
这是不同浏览器的打印
为什么不同浏览器之间有差异
下面是正确的执行流程
所以这是正确的打印顺序:click,click,promise,mutate,promise,timeout,timeout
在所有的listener callback都已经调用之后....
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
显然,这表示microtask在listener callbacks之间执行,但是.click()导致事件同步的触发,所以调用.click()的script在listener callbacks之间还是会在JS stack中,上面的规则保证microtask不会打断执行中的JavaScript。所以这就表示不会再listener callbacks之间执行microtask,而是会等到listener callbacks都调用完之后才执行。
挑战成功
打个总结:
- task按顺序执行,而且浏览器可能会在其之间render
- microta按顺序执行,并且在如下情况下执行:
- 在每个回调之后(比如eventListener回调),只要没有正在执行中的JavaScript
- 在每个task执行完之后
希望你现在已经弄清楚了event loop,至少可以长舒一口气,我终于弄懂了!
注:
- 原博写于2015年,有些浏览器修复bug后可能已经和谷歌一致了,我试了些是已经一致了。
- 有些与event loop关系不大的略去了,但是基本上涵盖了整个文章的内容。
- gif有大小限制,可能播放速度有点快,可以自行去原博调式哟