Tasks, microtasks, queues 和 schedules
原文,由 FTAndy 翻译,Ivan Yan 校正,“署名-非商用-相同方式共享”,译文反馈。
我告诉我的同事 Matt Gaunt,我想写一篇文章,介绍 mircrotask 队列和浏览器事件循环。他说:“实话告诉你,我不会看的。” 嗯,我无论如何都要写。大家坐下来,让我们开始享受这段旅程,ok?
如果你更喜欢视频,Philip Roberts 在 JSConf 上就事件循环有一个很棒的演讲——没有讲 microtasks,不过很好的介绍了其它概念。好,继续!
下面 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 在 promise1
和 promise2
之前打印 setTimeout
——尽管这似乎是竞争条件导致的。很奇怪的是,Firefox 39 和 Safari 8.0.7 是对的。
为什么会这样
要想弄明白为什么,你需要知道事件循环如何处理 tasks 和 microtasks。第一次接触需要花些功夫才能弄明白。深呼吸……
每个线程都有自己的事件循环,所以每个 web worker 有自己的事件循环(event loop),所以它能独立地运行。而所有同源的 window 共享一个事件循环,因为它们能同步的通讯。事件循环持续运行,执行 tasks 列队。一个事件循环有多个 task 来源,并且保证在 task 来源内的执行顺序(IndexedDB 等规范定义了自己的 task 来源),在每次循环中浏览器要选择从哪个来源中选取 task,这使得浏览器能优先执行敏感 task,例如用户输入。Ok ok, 留下来陪我坐会儿……
Tasks 被列入队列,于是浏览器能从它的内部转移到 Javascript/DOM 领地,并且确使这些 tasks 按序执行。在 tasks 之间,浏览器可以更新渲染。来自鼠标点击的事件回调需要安排一个 task,解析 HTML 和 setTimeout
同样需要。
setTimeout
延迟给定的时间,然后为它的回调安排一个新的 task。这就是为什么 setTimeout
在 script end
之后打印,script end
在第一个 task 内,setTimeout
在另一个 task 内。好了,我们快讲完了,剩下一点我需要你们坚持下……
Mircotasks 通常用于安排一些事,它们应该在正在执行的代码之后立即发生,例如响应操作,或者让操作异步执行,以免付出一个全新 task 的代价。mircotask 队列在回调之后处理,只要没有其它执行当中的(mid-execution)代码;或者在每个 task 的末尾处理。在处理 microtasks 队列期间,新添加的 microtasks 添加到队列的末尾并且也被执行。 microtasks 包括 mutation observer 回调。上面的例子中的 promise 的回调也是。
promise 一旦解决(settled)或者已解决,便为它的回调安排一个 microtask。这确使 promise 回调是异步的,即便 promise 已经解决。因此一个已解决的 promise 调用 .then(yey, nay)
将立即把一个 microtask 加入队列。这就是为什么 promise1
和 promise2
在 script end
之后打印,因为正在运行的代码必须在处理 microtasks 之前完成。promise1
和 promise2
在 setTimeout
之前打印,因为 microtasks 总是在下一个 task 之前执行。
好,一步一步的运行:
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');
Tasks |
Run script
setTimeout callback
|
---|---|
Microtasks |
Promise then
Promise then
|
JS stack | |
Log |
script start
script end
promise1
promise2
setTimeout
|
是的,我做了一个 step-by-step 动画图解。你周六是怎么过的?和朋友们出去晒太阳?我没有。嗯,如果不明白我的 UI 设计,点击上面的箭头。
其它浏览器有什么不同?
一些浏览器的打印结果:script start
, script end
, setTimeout
, promise1
, promise2
。在 setTimeout
之后运行 promise 的回调,就好像将 promise 的回调当作一个新的 task 而不是 microtask。
这多少情有可原,因为 promise 来自 ECMAScript 规范而不是 HTML 规范。ECAMScript 有一个概念 job,和 microtask 相似,但是两者的关系在邮件列表讨论中没有明确。不过,一般共识是 promise 应该是 microtask 队列的一部分,并且有充足的理由。
将 promise 当作 task 会导致性能问题,因为回调可能不必要地被与 task 相关的事(比如渲染)延迟。与其它 task 来源交互时它也导致不确定性,也会打断与其它 API 的交互,不过后面再细说。
Edge 有一条 反馈,希望它让 promises 使用 microtasks。WebKit nightly 没问题,所以我认为 Safari 最终会修复,Firefox 43 似乎已经修复。
有趣的是 Safari 和 Firefox 发生了退化,而之前的版本是对的。我在想这是否只是巧合。
怎么知道是 task 还是 microtask?
测试是一种办法,查看相对于 promise 和 setTimeout
如何打印,尽管这取决于实现是否正确。
一种方法是查看规范。例如,setTimeout 的第十四步将一个 task 加入队列,mutation record 的第五步将 microtask 加入队列。
如上所述,ECMAScript 将 microtask 称为 job。PerformPromiseThen 的第八步 调用 EnqueueJob 将一个 microtask 加入队列。
现在,让我们看一个更复杂的例子。(一个有心人 :“但是他们还没有准备好”。别管他,你已经准备好了,让我们开始……)
等级 1 BOSS 战
在写这篇文章之前,我没弄对。下面一段 html 代码:
<div class="outer">
<div class="inner"></div>
</div>
有如下的 Javascript 代码,假如我点击 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 事件:
你的猜测是否不同?若是,你也可能是对的。但不幸的是各浏览器不一致:
-
- click
- promise
- mutate
- click
- promise
- mutate
- timeout
- timeout
-
- click
- mutate
- click
- mutate
- timeout
- promise
- promise
- timeout
-
- click
- mutate
- click
- mutate
- promise
- promise
- timeout
- timeout
-
- click
- click
- mutate
- timeout
- promise
- timeout
- promise
谁是对的?
触发 click 事件是一个 task,Mutation observer 和 promise 回调作为 microtask 加入列队,setTimeout
回调作为 task 加入列队。因此运行过程如下:
// 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);
Tasks |
Dispatch click
setTimeout callback
setTimeout callback
|
---|---|
Microtasks |
Promise then
Mutation observers
Promise then
Mutation observers
|
JS stack | |
Log |
click
promise
mutate
click
promise
mutate
timeout
timeout
|
所以 Chrome 是对的。对我来说新发现是,microtasks 在回调之后运行(只要没有其它的 JavaScript 在运行,我原以为它只能在 task 的末尾运行。这个规则来自 HTML 规范,调用一个回调:
If the stack of script settings objects is now empty,perform a microtask checkpoint。
— HTML: 回调之后的清理第三步
一个 microtask checkpoint 逐个检查 microtask 队列,除非我们已经在处理一个 microtask 队列。类似地,ECMAScript 规范这么说 jobs:
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
尽管在 HTML 中”can be”变成了”must be”。
其它浏览器哪里错了?
对于 mutation 回调,Firefox 和 Safari 正确地在单击回调之间清空 microtask 队列,但是 promises 列队似乎不一样。这多少情有可原,因为 jobs 和 microtasks 的关系不清楚,但是我仍然期望在事件回调之间处理。Firefox bug,Safari bug。
对于 Edge,我们已经看到它错误的将 promises 当作 task,它也没有在单击回调之间清空 microtask 队列,而是在所有单击回调执行完之后清空,于是总共只有一个 mutate
在两个 click
之后打印。Edge bug。
等级 1 BOSS 愤怒的老大哥
仍然使用上面的例子,假如我们运行下面代码会怎么样:
inner.click();
跟之前一样,它会触发 click 事件,不过是通过代码而不是实际的交互动作。
试一试
各浏览器的结果:
-
- click
- click
- promise
- mutate
- promise
- timeout
- timeout
-
- click
- click
- mutate
- timeout
- promise
- promise
- timeout
-
- click
- click
- mutate
- promise
- promise
- timeout
- timeout
-
- click
- click
- mutate
- timeout
- promise
- timeout
- promise
我发誓我在 Chrome 中始终得到不同的结果,我更新了这个表许多次才意识到我测试的是 Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。
为什么不同?
它应该像下面这样运行:
// 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); inner.click();
Tasks |
Run script
setTimeout callback
setTimeout callback
|
---|---|
Microtasks |
Promise then
Mutation observers
Promise then
|
JS stack | |
Log |
click
click
promise
mutate
promise
timeout
timeout
|
正确的顺序是:click
, click
, promise
, mutate
, promise
, timeout
, timeout
,似乎 Chrome 是对的。
在每个事件回调调用之后:
If the stack of script settings objects is now empty,perform a microtask checkpoint.
— HTML: 回调之后的清理第三步
之前,这意味着 microtasks 在事件回调之间运行,但是 .click()
让事件同步触发,所以调用 .click()
的代码仍然在事件回调之间的栈内。上面的规则确保了 microtasks 不会中断执行当中的代码。这意味着 microtasks 队列在事件回调之间不处理,而是在它们之后处理。
这重要吗?
重要,它会在偏角处咬你(疼)。我就遇到了这个问题,在我尝试用 promises 而不是用怪异的 IDBRequest
对象为 IndexedDB 创建一个简单的包装库 时。它让 IDB 用起来很有趣。
当 IDB 触发成功事件时,相关的 transaction 对象在事件之后转为非激活状态(第四步)。如果我创建的 promise 在这个事件发生时被履行(resolved),回调应当在第四步之前执行,这时这个对象仍然是激活状态。但是在 Chrome 之外的浏览器中不是这样,导致这个库有些无用。
实际上你可以在 Firefox 中解决这个问题,因为 promise polyfills 如 es6-promise 使用 mutation observers 执行回调,它正确地使用了 microtasks。而它在 Safari 下似乎存在竞态条件,不过这可能是因为他们糟糕的 IDB 实现。不幸的是 IE/Edge 不一致,因为 mutation 事件不在回调之后处理。
希望不久我们能看到一些互通性。
你做到了!
总结:
- tasks 按序执行,浏览器会在 tasks 之间执行渲染。
- microtasks 按序执行,在下面情况时执行:
- 在每个回调之后,只要没有其它代码正在运行。
- 在每个 task 的末尾。
希望你现在明白了事件循环,或者至少得到一个借口出去走一走躺一躺。
呃,还有人在吗?Hello?Hello?
感谢 Anne van Kesteren, Domenic Denicola, Brian Kardell 和 Matt Gaunt 校对和修正。是的,Matt 最后还是看了此文,我不必把他整成发条橙了。
译注:setImmediate.js 也提到了 tasks 与 microtasks 的区别,可以参考。