specialCoder

任务、微任务、队列和时间表

原文链接: jakearchibald.com

当我告诉我的同事 Matt Gaunt 我想写一篇关于mircrotask、queueing和浏览器的Event Loop的文章。他说:“我实话跟你说吧,我是不会看的。” 好吧,无论如何我已经写完了,那么我们坐下来一起看看,好吧?

如果你更喜欢视频,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 竟然结果都是对的(一致的)。

为什么会这样?

要想弄明白这些,你需要知道Event Loop是如何处理 tasks 和 microtasks的。如果你是第一次接触它,需要花些功夫才能弄明白。深呼吸。。。

每个线程都有自己的事件循环,所以每个 web worker 都有自己的事件循环,因此web worker才可以独立执行。而来自同域的所有窗口共享一个事件循环,所以它们可以同步地通信。事件循环持续运行,直到清空 tasks 列队的任务。事件循环包括多种任务源,事件循环执行时会访问这些任务源,这样就确定了各个任务源的执行顺序(IndexedDB 等规范定义了自己的任务源和执行顺序),但浏览器可以在每次循环中选择从哪个任务源去执行一个任务。这允许浏览器优先考虑性能敏感的任务,例如用户输入。Ok ok, 留下来陪我坐会儿……

Tasks 被放到任务源中,浏览器内部执行转移到JavaScript/DOM领域,并且确保这些 tasks按序执行。在tasks执行期间,浏览器可能更新渲染。来自鼠标点击的事件回调需要安排一个task,解析HTML和setTimeout同样需要。

setTimeout延迟给定的时间,然后为它的回调安排一个新的task。这就是为什么 setTimeout在 script end 之后打印:script end 在第一个task 内,setTimeout 在另一个 task 内。好了,我们快讲完了,剩下一点我需要你们坚持下……

Mircotasks队列通常用于存放一些任务,这些任务应该在正在执行的脚本之后立即执行,比如对一批动作作出反应,或者操作异步执行避免创建整个新任务造成的性能浪费。 只要没有其他JavaScript代码在执行中,并且在每个task队列的任务结束时,microtask队列就会被处理。在处理 microtasks 队列期间,新添加到 microtasks 队列的任务也会被执行。 microtasks 包括 MutationObserver callbacks。例如上面的例子中的 promise的callback。

一个settled状态的promise(直接调用resolve或者reject)或者已经变成settled状态(异步请求被settled)的promise,会立刻将它的callback(then)放到microtask队列里面。这就能保证promise的回调是异步的,即便promise已经变为settled状态。因此一个已settled的promise调用.then(yey,nay)时将立即把一个microtask任务加入microtasks任务队列。这就是为什么 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');
});

没错,就是上面这个,我做了一个 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(macrotask)会带来一些性能问题,因为回调没有必要因为task相关的事(比如渲染)而延迟执行。与其它 task 来源交互时它也产生不确定性,也会打断与其它 API 的交互,不过后面再细说。

我提交了一条 Edge 反馈,它错误地将 promises 当作 task。WebKit nightly 做对了,所以我认为 Safari 最终会修复,而 Firefox 43 似乎已经修复。

有趣的是 Safari 和 Firefox 发生了退化,而之前的版本是对的。我在想这是否只是巧合。

怎样区分用 tasks 还是用 microtasks

动手试一试是一种办法,查看相对于promise和setTimeout如何打印,尽管这取决于实现是否正确。

一种方法是查看规范: 将一个 task 加入队列: step 14 of setTimeout

将 microtask 加入队列:step 5 of queuing a mutation record

如上所述,ECMAScript 将 microtask 称为 job: 调用 EnqueueJob 将一个 microtask 加入队列:step 8.a of PerformPromiseThen

现在,让我们看一个更复杂的例子。一个有心的学徒 :“但是他们还没有准备好”。别管他,你已经准备好了,让我们开始……

等级一 boss打怪

在发出这篇文章之前,我犯过一个错误。下面是一段html代码:

`<div>`
  `<div>``</div>`
`</div>`

给出下面的JS代码,如果click 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);

继续,在查看答案之前先试一试。 线索:logs可能会发生多次。

试一试

点击inner区域触发click事件:

和你猜想的有不同吗?如果是,你得到的结果可能也是正确的。不幸的是,浏览器实现并不统一,下面是各个浏览器下测试结果:

  • Chrome
    • click
    • promise
    • mutate
    • click
    • promise
    • mutate
    • timeout
    • timeout
  • Firefox
    • click
    • mutate
    • click
    • mutate
    • timeout
    • promise
    • promise
    • timeout
  • Safari
    • click
    • mutate
    • click
    • mutate
    • promise
    • promise
    • timeout
    • timeout
  • Edge
    • click
    • click
    • mutate
    • timeout
    • promise
    • timeout
    • promise

谁是正确的?

触发 click 事件是一个 task,Mutation observer 和 promise 的回调 加入microtask列队,setTimeout 回调加入task列队。因此运行过程如下:

所以 Chrome 是对的。对我来说新发现是,microtasks 在回调之后运行(只要没有其它的 Javascript 在运行),我原以为它只能在一个task 的末尾执行。这个规则来自 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 规范这么说 jobs:

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…

ECMAScript: Jobs and Job Queues

尽管在 HTML 中"can be"变成了"must be"。

浏览器会出什么错?

对于 mutation callbacks,Firefox 和 Safari 都正确地在内部区域和外部区域单击事件之间执行完毕,清空了microtask 队列,但是 promises 列队的处理看起来和chrome不一样。这多少情有可原,因为 jobs 和 microtasks 的关系不清楚,但是我仍然期望在事件回调之间处理 Firefox ticket. Safari ticket.

对于 Edge,我们已经看到它错误的将 promises 当作 task,它也没有在单击回调之间清空 microtask 队列,而是在所有单击回调执行完之后清空,于是总共只有一个 mutate 在两个 click 之后打印。 Bug ticket.

等级一 boss打怪升级

仍然使用上面的例子,假如我们运行下面代码会怎么样:

inner.click();

跟之前一样,它会触发 click 事件,不过是通过代码而不是实际的交互动作。

试一试

下面是各个浏览器的运行情况:

  • Chrome
    • click
    • click
    • promise
    • mutate
    • promise
    • timeout
    • timeout
  • Firefox
    • click
    • click
    • mutate
    • timeout
    • promise
    • promise
    • timeout
  • Safari
    • click
    • click
    • mutate
    • promise
    • promise
    • timeout
    • timeout
  • Edge
    • click
    • click
    • mutate
    • timeout
    • promise
    • timeout
    • promise

我发誓我一直在Chrome 中得到不同的结果,我已经更新了这个表许许多次了。我觉得我是错误地测试了Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。

为什么会有不同?

这里介绍了它是怎样发生的:

所以正确的顺序是:click, click, promise, mutate, promise, timeout, timeout,似乎 Chrome 是对的。

在每个listerner callback被调用之后:

If the stack of script settings objects is now empty, perform a microtask checkpoint

HTML: Cleaning up after a callback step 3

Previously, this meant that microtasks ran between listener callbacks, but .click() causes the event to dispatch synchronously, so the script that calls .click() is still in the stack between callbacks. The above rule ensures microtasks don't interrupt JavaScript that's mid-execution. This means we don't process the microtask queue between listener callbacks, they're processed after both listeners.

这个会有什么问题吗?

重要,它会在偏角处咬你(疼)。我就遇到了这个问题,我在尝试为IndexedDB创建一个使用promises而不是奇怪的IDBRequest对象的简单包装库时遇到了此问题。它让 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 最后还是看了此文,我不必把他整成发条橙了。