前言
一直想对异步处理做一个研究,在查阅资料时发现了这篇文章,非常深入的解释了事件循环中重的任务队列。原文中有代码执行工具,强烈建议自己执行一下查看结果,深入体会task执行顺序。
建议看这篇译文之前先看这篇全面讲解事件循环的文章:https://mp.weixin.qq.com/s/vI...
翻译参考了这篇文章的部分内容:https://juejin.im/entry/55dbd...
正文
原文地址:Tasks, microtasks, queues and schedules
当我告诉我的同事 Matt Gaunt 我想写一篇关于microtask、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');
控制台上的输出顺序是怎样的呢?
Try it
正确的答案是:
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 竟然结果都是对的(一致的)。
Why this happens
要想弄明白这些,你需要知道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 内。好了,我们快讲完了,剩下一点我需要你们坚持下……
Microtasks队列通常用于存放一些任务,这些任务应该在正在执行的脚本之后立即执行,比如对一批动作做出反应,或者操作异步执行避免创建整个新任务造成的性能浪费。 只要没有其他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 发生了退化,而之前的版本是对的。我在想这是否只是巧合。
How to tell if something uses tasks or 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
现在,让我们看一个更复杂的例子。一个有心的学徒 :“但是他们还没有准备好”。别管他,你已经准备好了,让我们开始……
Level 1 bossfight
在发出这篇文章之前,我犯过一个错误。下面是一段html代码:
给出下面的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可能会发生多次。
Test it
点击inner区域触发click事件:
click div.inner :
click
promise
mutate
click
promise
mutate
timeout
timeout
click div.outer :
click
promise
mutate
timeout
和你猜想的有不同吗?如果是,你得到的结果可能也是正确的。不幸的是,浏览器实现并不统一,下面是各个浏览器下测试结果:
Who's right?
触发 click 事件是一个 task,Mutation observer 和 promise 的回调 加入microtask列队,setTimeout 回调加入task列队。因此运行过程如下:
点击内部区域触发内部区域点击事件 -> 冒泡到外部区域 -> 触发外部区域点击事件
这里要注意一点: setTimeout 执行时机在冒泡之后,因为也是在microtask之后,准确的说是在最后的时机执行了。
堆栈为空之后将会执行microtasks里面的任务。
由于冒泡, click函数再一次执行。
最后将执行setTimeout。
所以 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"。
What did browsers get wrong?
对于 mutation callbacks,Firefox 和 Safari 都正确地在内部区域和外部区域单击事件之间执行完毕,清空了microtask 队列,但是 promises 列队的处理看起来和chrome不一样。这多少情有可原,因为 jobs 和 microtasks 的关系不清楚,但是我仍然期望在事件回调之间处理。Firefox ticket. Safari ticket.
对于 Edge,我们已经看到它错误的将 promises 当作 task,它也没有在单击回调之间清空 microtask 队列,而是在所有单击回调执行完之后清空,于是总共只有一个 mutate 在两个 click 之后打印。 Bug ticket.
Level 1 boss's angry older brother
仍然使用上面的例子,假如我们运行下面代码会怎么样:
inner.click();
跟之前一样,它会触发 click 事件,不过是通过代码而不是实际的交互动作。
Try it
下面是各个浏览器的运行情况:
我发誓我一直在Chrome 中得到不同的结果,我已经更新了这个表许许多次了。我觉得我是错误地测试了Canary。假如你在 Chrome 中得到了不同的结果,请在评论中告诉我是哪个版本。
Why is it different?
这里介绍了它是怎样发生的:
将Run srcipt加入Tasks队列,将inner.click加入执行堆栈:
执行click函数:
按顺序执行,分别将setTimeout加入Tasks队列,将Promise MultationObserver加入microtasks队列:
click函数执行完毕之后,我们没有去处理microtasks队列的任务,因为此时堆栈不为空:
我们不能将 MultationObserver加入microtasks队列,因为有一个等待处理的 MultationObserver:
现在堆栈为空了,我们可以处理microtasks队列的任务了:
最终结果:
通过对比事件触发,我们要注意两个地方:JS stack是否是空的决定了microtasks队列里任务的执行;microtasks队列里不能同时有多个MultationObserver。
正确的顺序是: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: 回调之后的清理第三步
之前,这意味着 microtasks 在事件回调之间运行,但是现在.click()让事件同步触发,因此调用.click()的脚本仍处于回调之间的堆栈中。上面的规则确保了 microtasks 不会中断正在执行的JS代码。这意味着 microtasks 队列不会在事件回调之间处理,而是在它们之后处理。
Does any of this matter?
重要,它会在偏角处咬你(疼)。我就遇到了这个问题,我在尝试为IndexedDB创建一个使用promises而不是奇怪的IDBRequest对象的简单包装库时遇到了此问题。它让 IDB 用起来很有趣。
当 IDB 触发成功事件时,相关的 transaction 对象在事件之后转为非激活状态(第四步)。如果我创建的 promise 在这个事件发生时被resolved,回调应当在第四步之前执行,这时这个对象仍然是激活状态。但是在 Chrome 之外的浏览器中不是这样,导致这个库有些无用。
实际上你可以在 Firefox 中解决这个问题,因为 promise polyfills 如 es6-promise 使用 mutation observers 执行回调,它正确地使用了 microtasks。而它在 Safari 下似乎存在竞态条件,不过这可能是因为他们糟糕的 IDB 实现。不幸的是 IE/Edge 不一致,因为 mutation 事件不在回调之后处理。
希望不久我们能看到一些互通性。
You made it!
总结:
- tasks 按序执行,浏览器会在 tasks 之间执行渲染。
microtasks 按序执行,在下面情况时执行:
- 在每个回调之后,只要没有其它代码正在运行。
- 在每个 task 的末尾。
希望你现在明白了事件循环,或者至少得到一个借口出去走一走,躺一躺。
呃,还有人在吗?Hello?Hello?
感谢 Anne van Kesteren, Domenic Denicola, Brian Kardell 和 Matt Gaunt 校对和修正。是的,Matt 最后还是看了此文,我不必把他整成发条橙了。
后记
总结
1.microtask队列就会被处理的时机
(1)只要没有其他JavaScript代码在执行中,
(2)并且在每个task队列的任务结束时, microtask队列就会被处理。也就是说可以在执行一个task之后连续执行多个microtask。
2. promise相关
(1)promise一旦创建就会马上执行
(2)当状态变为settled的时候,callback才会被加入microtask 队列
所以要注意promise创建和callback被执行的时机。
面试题
async function async1(){
console.log('async1 start');
await async2();
console.log('async1 end')
}
async function async2(){
console.log('async2')
}
console.log('script start');
setTimeout(function(){
console.log('setTimeout')
},0);
async1();
new Promise(function(resolve){
console.log('promise1');
resolve();
}).then(function(){
console.log('promise2')
});
console.log('script end')
答案:
script start
async1 start
async2
promise1
script end
promise2
async1 end
setTimeout