当我告诉我的同事 Matt Gaunt 我正在考虑写一篇关于浏览器事件循环中的微任务队列和执行的文章时,他说:“我跟你说实话,Jake,我不打算读这个。”好吧,反正我已经写好了,所以我们都要坐在这里享受它,好吗?
实际上,如果你更喜欢视频,Philip Roberts在 JSConf 上就事件循环做了一个很棒的演讲——微任务没有涉及,但它是对其余内容的一个很好的介绍。不管怎样,继续往下看…
执行下面小段 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 是对的。
为什么会发生这样的情况?
要理解这一点,你需要知道事件循环如何处理任务和微任务。当你第一次遇到这个问题,这可能会让你头疼。深呼吸……
每个“线程”都有自己的事件循环,因此每个web worker
都有自己的事件循环,所以它可以独立执行,而所有同源的windows
共享一个事件循环,因此它们能同步的通讯。事件循环持续运行,执行所有排队的任务。事件循环具有多个任务源,这些任务源保证了该源中的执行顺序(如 IndexedDB 之类的规范定义了它们的执行顺序),但是浏览器可以在循环的每个循环中选择从哪个源中执行任务。这使浏览器可以优先执行对性能敏感的任务,例如用户输入。OK OK,继续和我一起探索.....
任务被调度,这样浏览器就可以从内部获取 JavaScript/DOM,并确保这些操作按顺序发生,在任务之间,浏览器可能呈现更新。从鼠标单击到事件回调需要调度任务,解析 HTML 也是如此,在上面的示例中是setTimeout
。
setTimeout
延迟给定的时间,然后为它的回调调度一个新任务。这就是为什么setTimeout
被记录在script end
之后,因为日志script end
是第一个任务的一部分,而setTimeout
被记录在一个单独的任务中。好了,我们快结束了,但我需要你在坚持下.....
微任务通常安排在当前执行的脚本之后立即发生的事情上,比如响应操作,或者在不承担新任务的代价的情况下使某些事情异步。只要没有其他 JavaScript 在执行中,微任务队列在回调后处理,并在每个任务结束时处理。在微任务期间排队的任何其他微任务都被添加到队列的末尾并进行处理。微任务包括 mutation observer 回调,以及如上示例中所示的 promise 回调。
一旦promise
完成,或者已解决,它就会为它的回调在微任务进行排队,这确保promise
回调是异步的,即使promise
已经解决。因此一个已解决的 promise
调用 .then(yey, nay) 将立即把一个微任务加入队列。这就是为什么 promise1
和 promise2
在 script end
之后打印,因为正在运行的代码必须在处理微任务之前完成。promise1
和 promise2
在 setTimeout
之前打印,因为微任务总是在下一个任务之前执行。
是的,我创建了一个动画步骤图。你星期六是怎么过的?和你的朋友出去晒太阳了?我没有。嗯,如果我的 UI 设计不清楚,点击上面的箭头前进。
有些浏览器有些不同?
有些浏览器 log 记录 script start
、script end
、setTimeout
、promise1
、promise2
。它们在 setTimeout 之后运行 promise
回调。它们很可能将 promise 回调作为新任务的一部分而不是作为微任务调用。
这是可以原谅的,因为promise
来自于 ECMAScript 规范而不是 HTML 规范。ECMAScript 规范有类似于微任务的“jobs”概念,但是除了模糊的邮件列表讨论之外,它们之间的关系并不明确。但是,一般的共识是promise
应该是微任务队列的一部分,并且有充分的理由。
将promise
视为任务会导致性能问题,因为回调可能会被与任务相关的事情(比如呈现)不必要地延迟。它还会由于与其他任务源的交互而导致不确定性,并可能破坏与其他 api 的交互,稍后将详细介绍这一点。
我提交了一份[Edge 反馈],promise
应该使用微任务,WebKit nightly 在做正确的事情,所以我假设 Safari 最终会修复这个问题,而且似乎在 Firefox 43 中得到了修复。
有趣的是,Safari 和 Firefox 都经历了一次回归,此问题已得到修复。 我想知道这是否只是一个巧合。。
如何判断某个东西使用的是任务还是微任务?
测试是一种办法,查看相对于 promise 和 setTimeout 如何打印,尽管这取决于实现是否正确。 一种方式是查看规范,例如,setTimeout 的第十四步将一个 任务 加入队列,mutation record 的第五步将 微任务 加入队列。 如上所述,ECMAScript 将 微任务 称为 job。PerformPromiseThen 的第八步 调用 EnqueueJob 将一个 微任务 加入队列。 现在,让我们看一个更复杂的例子
Level 1 挑战 BOSS
在写这篇文章之前,我会弄错这个。 这是一些 html 给定以下 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 事件:
你的猜测是否不同?若是,你也可能是对的。不幸的是各浏览器不一致:
那谁是对的?
触发 click 事件是一个 任务,Mutation observer
和 promise
回调作为 微任务 加入列队,setTimeout
回调作为 任务 加入列队。因此运行过程如下:
所以 Chrome 是对的。对我来说新发现是,微任务是在回调之后处理的(只要没有其他 JavaScript 在执行中),我原来认为它仅限于任务结束。这个规则来自于调用回调的 HTML 规范:
See More
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
一个 microtask checkpoint 逐个检查微任务队列,除非我们已经在处理一个微任务队列。类似地,ECMAScript 规范这么说 jobs:
See More
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 的关系不清楚,但是我仍然期望在事件回调之间处理。
对于 Edge,我们已经看到它错误的将 promises 当作 task,它也没有在单击回调之间清空 microtask 队列,而是在所有单击回调执行完之后清空,于是总共只有一个 mutate 在两个 click 之后打印。
Level 1 BOSS 愤怒的老大哥
仍然使用上面的例子,假如我们运行下面代码会怎么样:
inner.click();
跟之前一样,它会触发 click 事件,不过是通过代码而不是实际的交互动作。
试一试
看看浏览器是怎么情况
我发誓我从 Chrome 中得到了很多不同的结果,我已经更新了这个图表很多次了,我还以为我错的测试了 Canary。如果你在 Chrome 中得到不同的结果,请在评论中告诉我是哪个版本。
为什么会不一样?
它应该像下面这样运行:
正确的顺序是:click
, click
, promise
, mutate
, promise
, timeout
, timeout
,似乎 Chrome 是对的。
在每个事件回调调用之后:
See More
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
之前,这意味着 微任务 在事件回调之间运行,但是 .click() 让事件同步触发,所以调用 .click() 的代码仍然在事件回调之间的栈内。上面的规则确保了 微任务 不会中断执行当中的代码。这意味着 微任务 队列在事件回调之间不处理,而是在它们之后处理。
这重要吗?
重要,它会在偏角处咬你(疼)。我就遇到了这个问题,在我尝试用 promises 而不是用怪异的 IDBRequest
对象为 IndexedDB 创建一个简单的包装库 时。它让 IDB 用起来很有趣。
当 IDB 触发成功事件时,相关的 transaction 对象在事件之后转为非激活状态(第四步)。如果我创建的 promise 在这个事件发生时被履行(resolved),回调应当在第四步之前执行,这时这个对象仍然是激活状态。但是在 Chrome 之外的浏览器中不是这样,导致这个库有些无用。
实际上你可以在 Firefox 中解决这个问题,因为 promise polyfills 如 es6-promise 使用 mutation observers 执行回调,它正确地使用了 microtasks。而它在 Safari 下似乎存在竞态条件,不过这可能是因为他们 糟糕的 IDB 实现。不幸的是 IE/Edge 不一致,因为 mutation 事件不在回调之后处理。
希望不久我们能看到一些互通性
你做到了
总结:
- 任务按序执行,浏览器会在任务之间执行渲染。
- 微任务按序执行,在下面情况时执行:
- 在每个回调之后,只要没有其它代码正在运行。
- 在每个 task 的末尾。
希望你现在明白了事件循环,或者至少得到一个借口出去走一走躺一躺。
呃,还有人在吗?Hello?Hello?
感谢 Anne van Kesteren, Domenic Denicola, Brian Kardell 和 Matt Gaunt 校对和修正。是的,Matt 最后还是看了此文,我不必把他整成发条橙了。