任务(宏任务):
- 渲染事件(如解析 DOM、计算布局、绘制);
- 用户交互事件(如鼠标点击、页面滚动、放大缩小等);
- JavaScript 脚本执行事件;
- 网络请求完成、文件读写完成事件。
微任务:MutationObserver、Promise
以下内容来自 Google Chrome 的开发者Jake,此文为中文翻译版本。原文地址>>>
当我告诉我的同事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
要理解这一点,您需要知道事件循环如何处理任务和微任务。当你第一次遇到它时,这可能会让你头疼。深呼吸……
每个“线程”都有自己的事件循环,因此每个web worker都有自己的事件循环,因此它可以独立执行,而在同一源上的所有窗口都共享一个事件循环,因为它们可以同步通信。事件循环持续运行,执行任何排队的任务。事件循环有多个任务源,这些任务源保证了执行顺序(诸如IndexedDB之类的规范定义了它们自己的),但是浏览器在每次循环中都要选择从哪个源执行任务。这允许浏览器优先选择对性能敏感的任务,比如用户输入。好吧好吧,跟着我……
任务被调度,这样浏览器就可以从内部获取到JavaScript/DOM,并确保这些操作按顺序发生。在任务之间,浏览器可能呈现更新。从鼠标单击到事件回调需要调度任务,解析HTML也是如此,在上面的示例中,还有setTimeout。
setTimeout等待一个给定的延迟,然后为它的回调调度一个新任务。这就是为什么setTimeout被记录在脚本结束之后,因为日志脚本结束是第一个任务的一部分,而setTimeout被记录在一个单独的任务中。好了,我们就快结束了,但我需要你在接下来的时刻保持坚强…
微任务通常安排在当前执行的脚本之后立即发生的事情上,比如对一批操作做出反应,或者在不承担整个新任务的代价的情况下使某些事情异步。只要没有其他JavaScript在执行中,微任务队列就在回调后处理,并在每个任务结束时处理。在微任务期间排队的任何其他微任务都被添加到队列的末尾并进行处理。微任务包括mutation observer回调,以及如上示例中所示的promise回调。
一旦Promise达成,或者如果它已经达成,它就会对微任务进行排队,以获得它的反动回调。这确保Promise回调是异步的,即使Promise已经解决。然后(耶,不)在一个已确定的Promise之前,立即排队处理一个微任务。这就是为什么在脚本结束后记录promise1和promise2的原因,因为当前运行的脚本必须在处理微任务之前完成。promise1和promise2在setTimeout之前被记录,因为微任务总是在下一个任务之前发生。
所以,一步一步地(原文制作了步骤动画效果这里截取了关键的几步)
执行到setTimeout:
执行到Promise:
Tasks——Microtasks(添加新的微任务并执行):
有些浏览器有什么不同?
有些浏览器记录脚本启动、脚本结束、setTimeout、promise1、promise2。它们在setTimeout之后运行Promise回调。它们很可能将Promise回调作为新任务的一部分而不是作为微任务调用。
这是可以原谅的,因为Promise来自于ECMAScript而不是HTML。ECMAScript有类似于微任务的“工作”概念,但是除了模糊的邮件列表讨论之外,它们之间的关系并不明确。然而,一般的共识是Promise应该是微任务队列的一部分,并且有充分的理由。
将Promise视为任务会导致性能问题,因为回调可能会因为与任务相关的事情(比如呈现)而不必要地延迟。它还会由于与其他任务源的交互而导致不确定性,并可能破坏与其他api的交互,稍后将详细介绍这一点。
这是使用微任务做出Promise的有利条件。WebKit nightly在做正确的事情,所以我假设Safari最终会修复这个问题,而且它似乎在Firefox 43中得到了修复。
有趣的是,Safari和Firefox都经历了一次回归,现在已经修复了。我想这是不是巧合。
如何判断某物使用的是任务还是微任务
测试是一种方法。查看与promise和setTimeout相关的日志何时出现,尽管您依赖于正确的实现。
例如,setTimeout的第14步将一个任务排队,而第5步将一个突变记录排队将一个微任务排队。
如前所述,在ECMAScript领域中,他们将微任务称为“作业”。在步骤8。然后,调用EnqueueJob对微任务进行排队。
现在,让我们看一个更复杂的例子。然后转向一个忧心忡忡的学徒说:“不,他们还没准备好!”别理他,你准备好了。让我们做这个…
1级bossfight
在写这篇文章之前,我就犯了这个错误。这里有一些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
promise
mutate
click
promise
mutate
timeout
timeout
你的猜测不同吗?如果是这样,你可能仍然是对的。不幸的是,浏览器对此并不认同:
谁是对的?
分派‘click’事件是一项任务。Mutation observer和Promise回调作为微任务排队。setTimeout回调作为任务排队。所以事情是这样的:
所以是chrome做对了。在我看来,微任务是在回调之后处理的(只要没有其他JavaScript在执行中),我认为它仅限于任务结束。这个规则来自于调用回调的HTML规范:
如果脚本设置对象堆栈现在为空,则执行微任务检查点
- HTML:清理后的回调步骤3
微任务检查点涉及到通过微任务队列,除非我们已经在处理微任务队列。类似地,ECMAScript这样描述作业:
只有在没有正在运行的执行上下文且执行上下文堆栈为空时,才能启动作业的执行。
- ECMAScript:作业和作业队列
尽管“can be”在HTML上下文中变成了“must be”。
浏览器哪里出错了?
正如突变回调所示,Firefox和Safari正确地耗尽了单击侦听器之间的微任务队列,但Promise的队列似乎不同。考虑到作业和微任务之间的联系是模糊的,这是可以原谅的,但我仍然希望它们在侦听器回调之间执行。Firefox的票。Safari的票。
对于Edge,我们已经看到它的队列Promise不正确,但是它也没有耗尽单击侦听器之间的微任务队列,而是在调用所有侦听器之后才会这样做,这就解释了在两次单击日志之后会有单个的突变日志。错误的票。
一级boss的愤怒哥哥
哦男孩。使用上面相同的例子,如果我们执行:
inner.click ();
这将像前面一样启动事件分派,但是使用脚本而不是真正的交互。
试一试
清除日志运行测试
click
click
promise
mutate
promise
timeout
timeout
浏览器是这么说的:
我发誓我从Chrome中得到了很多不同的结果,我已经更新了这个图表很多次了,我还以为我错测试了Canary。如果你在Chrome中得到不同的结果,请在评论中告诉我是哪个版本。
为什么会不同呢?
以下是它应该如何发生的:
我们不能处理微任务,堆栈不是空的
所以正确的顺序是:click,click,promise,mutation,promise,timeout,timeout,Chrome似乎做对了。
在每个侦听器回调被调用后…
如果脚本设置对象堆栈现在为空,则执行微任务检查点
- HTML:清理后的回调步骤3
在以前,这意味着微任务在侦听器回调之间运行,但是.click()导致同步调度事件,因此调用.click()的脚本仍然在回调之间的堆栈中。上述规则确保微任务不会中断正在执行中的JavaScript。这意味着我们不会在侦听器回调之间处理微任务队列,而是在两个侦听器之后处理它们。
这些重要吗?
是的,它会在不显眼的地方咬你(哎哟)。我在尝试为IndexedDB创建一个简单的包装器库(使用promise而不是奇怪的IDBRequest对象)时遇到了这种情况。它几乎使IDB使用起来很有趣。
IDB触发成功事件时,相关的事务对象后变得不活跃调度(步骤4)。如果我创建了一个Promise,解决当这个事件触发时,回调之前应该运行步骤4,而交易仍然是活跃的,但是这种情况不会发生在别的浏览器Chrome,使得IndexedDB有点形同虚设。
实际上,在Firefox中可以解决这个问题,因为Promise填充(如es6-promise)使用Mutation observer进行回调,从而正确地使用微任务。对于这个修复,Safari似乎受到了竞争条件的影响,但这可能只是它们对IDB的错误实现。不幸的是,在IE/Edge中,事情总是失败,因为在回调之后没有处理突变事件。
希望我们很快就能看到一些互操作性。
你成功了!
总而言之:
任务按顺序执行,浏览器可以在它们之间呈现
微任务按顺序执行,并执行:
在每个回调之后,只要没有其他JavaScript在执行中
在每个任务结束时
希望你现在知道了事情的循环,或者至少有借口去躺一躺。
实际上,还有人在读吗?喂?喂?
感谢Anne van Kesteren, Domenic Denicola, Brian Kardell和Matt Gaunt的校对和更正。是啊,马特最后确实读过了,我甚至不需要在他身上看《发条橙》。
在GitHub上查看此页
由Disqus支持的评论