任务微任务队列和时间表

38次阅读

共计 3978 个字符,预计需要花费 10 分钟才能阅读完成。

为了保证的可读性,本文采用意译而非直译。

想阅读更多优质文章请猛戳 GitHub 博客, 一年百来篇优质文章等着你!

思考下面 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 打印 promise1promise2 之前会先打印 setTimeout —— 这似乎是浏览器厂商相互竞争导致的实现不同。这真的很奇怪,因为 Firefox 39 和 Safari 8.0.7 结果总是正确的。

为什么会这样

要理解这一点,需要了解 事件循环 <event loop>如何处理任务和微任务。

每个“线程”都有自己的 事件循环 <event loop>,因此每个 web worker 都有自己的事件循环,因此可以独立执行,而来自同域的所有窗口共享一个事件循环,所以它们可以同步地通信。

事件循环持续运行,直到清空 Tasks 队列的任务。一个事件循环有多个任务源,这些任务源保证了该源中的执行顺序(比如 IndexedDB 定义了它们自己的规范),但是浏览器可以在每次循环中选择哪个源来执行任务。这允许浏览器优先选择性能敏感的任务,比如用户输入等。

Tasks 被放到任务源中,这样浏览器就可以从内部进入 JavaScript/DOM 领域,并确保这些操作按顺序进行。在 Tasks 执行期间,浏览器可能更新渲染。从鼠标点击到事件回调需要调度一个任务,解析超文本标记语言也是如此。

setTimeout迟给定的时间,然后为它的回调调度一个新任务。这就是为什么 setTimeout 在打印 script end 之后打印,因为打印 script end 是第一个任务的一部分,而 setTimeout 在一个单独的任务中。

微任务 <Microtasks>通常是针对当前执行脚本之后应该立即发生的事情进行调度的,比如对一批操作进行响应,或者在不影响整个新任务的情况下进行异步处理。

只要没有其他 JavaScript 处于执行中期,并且在每个任务的末尾,微任务队列就在回调之后处理。在微任务期间排队的任何其他微任务都会被添加到队列的末尾并进行处理。微任务 包括 MutationObserver callbacks。例如上面的例子中的 promisecallback

一个 settled 状态的 promise 或者已经变成 settled 状态 (异步请求被 settled) 的 promise,会立刻将它的 callback(then) 放到微任务队列里面。

这确保了 promise 回调是异步的,即便 promise 已经变为 settled 状态。因此一个已 settledpromise调用 .then(yey,nay) 时将立即把一个微任务加入微任务队列中。

这就是为什么 promise1promise2会在 script end 后打印,因为当前运行的脚本必须在处理微任务之前完成。promise1promise2setTimeout之前打印,因为微任务总是在下一个任务之前发生。

好,一步一步的运行:

浏览器之间会有什么不同?

一些浏览器的打印的顺序是 script start, script end, setTimeout, promise1, promise2。它们在 setTimeout 之后运行 promise 回调。很可能他们调用 promise 回调是作为新任务的一部分,而不是作为一个微任务。

这也是可以理解的,因为 promise 来自 ECMAScript 而不是 HTML。ECMAScript 有“作业 ”的概念,类似于微任务,但是除了模糊的邮件列表讨论之外,这种关系并不明确。然而,普遍的共识是,promise 应该是微任务队列的一部分并且有充足的理由。

promise 看作任务会导致性能问题,因为回调没有必要因为任务相关的事(比如渲染)而延迟执行。它还会由于与其他任务源的交互而导致非确定性,并可能中断与其他 api 的交互,稍后将详细介绍。

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

如何判断某些东西是否使用任务或微任务

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

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

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

如上所述,ECMAScript 将微任务称为作业:调用 EnqueueJob 将一个 微任务加入队列:step 8.a of PerformPromiseThen

等级一 boss 打怪

下面是一段 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‘ 事件是一项任务。Mutation observer 和 promise 回调被列为微任务。setTimeout 回调列为任务。因此运行过程如下:

所以 Chrome 是对的。对我来说新发现是,微任务在回调之后运行(只要没有其它的 Javascript 在运行),我原以为它只能在一个任务的末尾执行。

浏览器出了什么问题?

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

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

等级一 boss 打怪升级

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

inner.click();

跟之前一样,它会触发 click 事件,但这次是通过 JS 调用的。

试一试

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

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

为什么不同?

应该是这样的:

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

以前,这意味着微任务在侦听器回调之间运行,但 .click() 会导致事件同步调度,因此调用 .click() 的脚本仍然在回调之间的堆栈中。上述规则确保微任务不会中断执行中期的 JavaScript。这意味着我们不处理侦听器回调之间的微任务队列,它们在两个侦听器之后处理。

总结

任务按顺序执行,浏览器可以在它们之间进行渲染:

微任务按顺序执行,并执行:

  • 在每个回调之后,只要没有其它代码正在运行。
  • 在每个任务的末尾。

交流

干货系列文章汇总如下,觉得不错点个 Star,欢迎 加群 互相学习。

https://github.com/qq44924588…

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复 福利,即可看到福利,你懂的。

正文完
 0