本文是Jake Archidald的一篇文章的翻译。翻译的初衷是我在查阅宏任务和微任务的时候,并没有找到比较权威的对于这两个概念的中文文档,能找到的中文资料也大都是技术博客,我目前能找到的比较权威的就是google的一位工程师Jake Archidald写的这篇文章。但是我本身的英文能力和技术能力都有限,直接阅读原文还挺吃力的,所以想把文章翻译出来,方便和我一样英文不好的朋友查阅。欢迎大家尽情指出翻译中不妥当的地方,或者给出一些建议和批判,谢谢大家,ღ( ´・ᴗ・` )比心。
原文的demo有炫酷的动态步骤演示,我这里画了静态图片替代。想看更生动的说明的同学猛戳原文(* ̄︶ ̄) → task-microtasks-queues-and-schedules!
任务,微任务,队列 和 调度
当我告诉我的同事 Matt Gaunt 我要写一篇关于在浏览器内事件循环中的微任务队列及其执行的相关文章时,他说,实话说,Jake,我不打算去看这个。好吧,我还是写了。所以现在让我们坐下来然后享受它,好吗?
实际上,如果你更喜欢视频,Philip Roberts在JSconf发表过一个主题为 event loop 的很棒的演讲,虽然这个演讲没提及微任务,但它还是对其他部分做了很棒的介绍。总之,我们继续~
请看下面的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
promise1
promise2
setTimeout
但是在不同的浏览器支持下,其打印的顺序不一。
Microsoft Edge,Firefox 40,iOS Safari 和 Safari 8.0.8 会先打印 setTimeout,再打印 promise1和 promise2虽然这似乎是一个race condition(注释见文末)。这真的很怪异,因为在 Firefox 39 和 Safari8.0.7 下则会始终表现正常。
为什么会这样呢?
要理解这些,你需要先知道事件循环是怎么处理宏任务和微任务的。当第一次遇到这些概念时,你可能会很头疼。深呼吸~
每一个线程都会有自己的事件循环,每个web worker(运行在后台的 JavaScript)也不例外。因此它可以独立执行。而同一个浏览器的所有窗口共享同一个事件循坏,因为他们可以同步通信。事件循环是持续不断的,执行任务队列中的任务。一个事件循环中会有多个任务来源,确保了同一来源任务可以按顺序执行(比如IndexedDB定义了它们自己的规范)。浏览器要在每一次事件循环中,选择要执行任务的源。浏览器会首先选择执行性能敏感的任务,例如,处理用户输入。好吧,好吧,我们继续……
任务是有既定的执行顺序的,所以浏览器内部在执行js代码和操作DOM时,能确保这些操作有序执行。在执行不同任务之间,浏览器可能会渲染并更新视图。鼠标点击事件的回调会进入任务队列,HTML解析也是如此。上面示例中提到的setTimeout也不例外。
setTimeout的回调函数会在给定的时间之后作为一个新的任务进入任务队列。这就是先打印script end再打印setTimeout的原因。因为打印script end是第一个任务的一部分,而打印setTimeout是属于另外一个任务的。好了,我们要知道的差不多就是这些,但是我希望你能保持耐心,把接下来这些看完...
微任务通常会在当前脚本执行完后立即执行,比如,对一些操作进行响应,或者不属于新的任务的异步操作。只要当前JavaScript代码执行完毕,或者每个任务执行完毕之后,就会去调用微任务队列里面的回调函数。如果微任务队列在等待的过程中有任何其他新的微任务,这个微任务也会插入到队列的末端。微任务包括MutationObserver的回调函数,还有上面提到的promise。
一旦改变一个promise实例的状态,或者这个实例的状态改变之后,它的回调函数就会进入微任务队列。这确保了当promise状态改变时的回调函数是异步的。所以紧接着.then()里的回调就会进入微任务队列。这就是promise1和promise2在 打印script end之后打印的原因。因为当前的脚本执行完毕之后才会去处理微任务。先打印promise1和promise2再打印setTimeout,因为总是先执行微任务再执行下一个任务。
所以,一步一步来:
是的,我创建了一个动态的步骤图。你的周六过得怎么样?有和你的朋友出去晒太阳吗?好吧,我没有。如果我的图画得不清楚,你可以点击箭头一步一步来。
为什么有些浏览器会表现不一致呢?
某些浏览器的打印顺序是:
script start
script end
setTimeout
promise1
promise2
它们会先执行setTimeout再执行promise。很有可能它们把promise的回调函数当成一个新的任务而不是微任务。
这很好理解,因为promise是来自于 ECMAScript 而不是 HTML。在 ECMAScript 中有一个和微任务类似的概念“jobs”,但是在 vague mailing list discussions,这两者的关系并不明确。然而,普遍共识是,promise是微任务队列的一部分,而且这是有充分理由的。
把promise归类为任务的话,会引发一些性能问题。因为这样promise的回调函数就会延迟一些和宏任务相关事情,比如渲染界面。这还会产生由于任务来源不同导致的不确定性,并且会可能会中断和其他API的连接。稍后做详细介绍。
Edge把promise当作微任务,WebKit 一直在做正确的事情,所以我想 Safari 最后会修复这个问题,而且,Firefox43 似乎也修复了。
非常有趣的是,Safari 和 Firefox 都试过把 promise 当作任务,并且后来又修复了这个问题。我很想知道这是不是巧合。
如何判断任务和微任务呢?
一个方法是测试。看一下promise & setTimeout打印的时间节点,尽管我们最终还是得依据执行过程做出判断。
更有把握的一个方法则是查阅文档。例如,setTimeout属于任务,而mutation record属于微任务。
如上面提到的,在 ECMAScript中,任务被称为“jobs”。In step 8.a of PerformPromiseThen中, EnqueueJob 属于微任务。
现在,让我们看一个更复杂的例子,可能会有一些新手说他们还没准备好,别理他,你准备好了,我们继续~
关卡一
在写这篇文章之前,我可能会弄错。这里有个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);
我们继续,在看答案之前先试一试。提示:打印的信息不止一条。
你的答案和上面的一致吗?如果不一致,你可能也是对的。在不同浏览器的表现并不一致
那究竟谁是对的呢?
派发的点击事件是一个任务, Mutation observer 和 promise 是微任务,setTimeout 的回调函数也宏任务,所以代码的执行过程是这样的:
所以chrome的输出是正确的。我觉得惊讶的一点是,在当前JavaScript代码没有正在执行任务的前提下,微任务是在点击的回调函数之后执行的。我以为它会被终止。这个规则来自于调用回调函数的HTML规范:
如果当前脚本的调用栈为空,则开始调用微任务队列。
——— HTML: Cleaning up after a callback step 3
微任务检查点包括遍历微任务队列,除非我们已经在处理微任务队列。近似地,ECMAScript 对于 jobs 是这样说明的:
任务的执行只能在没有运行的执行上下文切执行上下文堆栈为空时启动
尽管“可能”变成了HTML上下文中的“必定”。
那这些浏览器的问题在哪里呢?
Firefox 和 Safari 正确地两次点击事件监听之间调用了微任务队列,正如 mutation的回调函数所示,但 promise 似乎不在微任务队列。考虑到 jobs 和 microtask 之间的联系很模糊,这也是可以原谅的。但我仍然希望他们在监听回调函数之间执行,Firefox是这样,Safari也是这样。
在Edge中,我们已经看到它没有正确排列 promise的回调。但它也没有在两次点击事件监听之间正确调用微任务队列,而是在调用了所有点击回调函数之后才这样做,这就解释了为什么先打印两个click再打印mutate。这是一个bug。
关卡二
ohh,兄弟,仍然是上面的例子,如果我们只执行下面语句,会发生什么呢?
inner.click();
和上面一样,这里也会派发点击事件,但是脚本而不是实际交互触发的。
我发誓我在chrome运行会一直得到不同的结果,我已经更新这个图表很多次了,我甚至怀疑我的代码是错的。如果你在chrome浏览器中得到不同的运行结果,请在评论中告知我chrome版本。
为什么会不同呢?
所以正确的顺序是:click, click, promise, mutate, promise, timeout, timeout,chrome 的表现似乎是正确的。
每次调用事件监听回调之后...
如果当前脚本的调用栈为空,则开始调用微任务队列。
——— HTML: Cleaning up after a callback step 3
在上一个例子,这意味着微任务在两个点击事件回调之间运行,但是.click会导致事件同步派发,因此调用.click()的脚本仍然在两个回调之间的调用栈中。上面的规则确保微任务不会中断正在执行的JavaScript。这意味着我们不处理回调之间的微任务队列,而是在两个click侦听之后处理的。
这些都重要吗?
是的,这会在不经意间困扰你。我在尝试为IndexedDB创建一个简单的包装器库时就遇到了这种情况。这个库使用的是promise而不是奇怪的IDBRequest对象。它使IDB的使用变得有趣起来了。
IDB触发成功事件时,相关的 transaction 对象在时间派发之后变得不活跃(步骤4)。如果在触发这个事件的时候,我创建了一个状态变更为resolved 的 promise 对象,回调函数应该在步骤4之前运行,因为 transaction 仍然是 active 状态 的,但是这种情况不会出现在Chrome 之外的浏览器,这会使得 IndexedDB这个库变得有点形同虚设。
实际上,您可以在 Firefox 中解决这个问题,因为promise polyfill(如es6-promise)使用会正确地使用微任务的mutation observers 作为回调,Safari 似乎受到了 race conditions的影响,但这可能只是他们对IDB实现的破坏。不幸的是,IE/Edge 总是失败,因为回调函数执行后没有处理 mutation 事件。
希望我们很快就能在这里看到一些互操作性。
你看完啦!
总结一下:
- 任务是按顺序执行的,浏览器可能在不同的任务之间渲染视图
- 微任务也是按顺序排列,执行时间时:
- 每次回调之后(前提是js执行栈为空)
- 在每个任务结束时
希望您现在知道了如何处理事件循环,或者至少有一个借口去躺下休息。
其实还有人在吗?hello?hello?
原文记录在我的GitHub里面:传送门
维基百科对 race condition 的定义如下:
竞争冒险(race hazard)又名竞态条件、竞争条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出。
举例来说,如果计算机中的两个进程同时试图修改一个共享内存的内容,在没有并发控制的情况下,最后的结果依赖于两个进程的执行顺序与时机。而且如果发生了并发访问冲突,则最后的结果是不正确的。