前言: 有关任务,微任务的文章很多,这篇文章是国外一个大佬专门讲解这方面的知识,涉及到一些实际的 DOM 用例,自己看完后觉得非常有收获,在这里翻译出来,也希望对看到这篇文章的你有所帮助。
原文链接:jakearchibald.com/2015/tasks-...
当我告诉我的同事 Matt Gaunt 我正在考虑写一篇关于浏览器事件循环中的微任务队列和执行的文章时,他说"我对你说实话,杰克,我不会读这篇文章"。好吧,无论如何我已经写好了,所以我们都坐在这里享受它,好吗?
实际上,如果你更喜欢视频,Philip Roberts 在great talk at JSConf on the event loop 上就发表了精彩的演讲 - 没有涉及微任务,但它是对其余内容的精彩介绍。不管怎样,展示继续吧....
就拿这段 JavaScript 来说
js
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
});
console.log('srcipt end');
日志应该以什么样的顺序出现?
js
script start
script end
promise1
promise2
setTimeout
正确的答案是: 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 线程也有事件循环,所以它可以独立执行,而在同源上的所有窗口共享一个事件循环,因为它们可以同步通信。事件循环不断地运行,执行任何排队的任务。一个事件循环有多个任务源,保证了在该任务源内的执行顺序(例如: IndexedDB 自定义规格),但是浏览器可以在每次循环的开始选择从哪个任务源中执行任务。这允许浏览器优先处理对性能敏感的任务,例如:用户输入框。
任务的调度,使得浏览器能够从其内部进入 JavaScript/DOM 环境,并确保这些操作按照顺序执行。在任务之间,浏览器可能会渲染更新。从鼠标点击到事件回调需要调度一个任务,解析 HTML 也是这样,就像上面的 setTimeout 例子。
setTimeout
会等待一个给定的延迟时间,然后为它的回调函数调度一个新的任务。这就是为什么 setTimeout
会在 script end
后被打印,因为打印script end
是第一个任务的一部分,而 setTimeout
是在一个d独立的任务中被打印的。
微任务通常被调度用于在当前正在执行的脚本之后立即发生的事情,例如对一批操作做出反应,或者在不承担一个创建一个全新任务的代价下,使某些事情异步。在每个任务结束时,只要没有其他 JavaScript 代码正在执行,微任务队列会在回调函数之后被处理。任何一个附加的微任务,在微任务排队期间都会被添加到队列的末尾,也会被处理。微任务包括 DOM 变动观察器(mutation observer)
的回调函数,以及上面例子中 Promise
的回调函数。
一旦一个 promise 落定(settles),或者如果它已经解决(settled),它会为其对应的回调函数排队一个微任务。这确保即使 promise 已经完成,promise 的回调函数也是异步的。因此,对已经解决(settled)的 promise 调用 .then(yey, nay) 会立即将微任务排队。这就是为什么promise1
和promise2
会在script end
之后被打印,因为当前正在运行的脚本必须在处理微任务之前完成。而promise1
和 promise2
在 setTimeout
之前被打印,是因为微任务总是在下一个任务之间执行。
所以,一步一步解释一下代码(这里作者给出了一个动画,展示上面代码的执行流程)
动画由四个部分组成 Tasks 任务 Microtasks 微任务 JS stack JS执行栈 Log 打印日志
执行顺序:
- JS 执行栈执行 script 脚本代码, Tasks 任务队列运行脚本,Log 输出 script start 信息
- setTimeout 的回调函数是作为任务被排队,进入 Tasks 队列
- Promise 的回调函数是作为微任务进入微任务队列
- Log 输出 script end 信息
- 在 JS 执行栈的结束时,处理微任务队列,Promise 的回调函数压入栈中,输出 promise1
- promise 回调函数返回'undefined',这将把下一个promise 回调函数作为微任务
- 微任务完成后,我们继续处理队列中的下一个微任务
- 输出 promise2
- 微任务队列已经完成,浏览器有可能会渲染
- JS 执行栈执行 setTimeout 的回调函数
- 输出 setTimeout
不同浏览器的处理有什么不同?
一些浏览器会打印 script start
, script end
, setTimeout
, promise1
, promise2
。 它们在setTimeout
之后运行 Promise 回调函数。很可能它们将 Promise 回调函数作为一个新任务的一部分来调用,而不是作为微任务。
这是情有可原的,因为 Promise 来自ECMAScript
而不是HTML
。ECMAScript 有类似于微任务
的概念,称为"jobs",但除了一些模糊的邮件列表讨论外,这种关系并不明确。然而,普遍的共识是 Promise 应该是微任务队列的一部分,而且这是有充分理由的。
将Promise
视为任务会导致性能问题,因为回调可能会被任务相关的事情(例如渲染)不必要的延迟。由于它与其他任务源交互,还会导致不确定性,并且可能破坏与其他 API 的交互,更详细的内容会在后面介绍。
如何判断一件事是使用任务还是微任务
测试是一种方式。查看日志相对于 promise 和 setTimeout 出现的时间,这个依赖于你的代码实现是正确的。
另一种方法就是查看规范。例如, setTimeout
的第 14 步 是进入任务队列,而Queuing a mutation record
的第 5 步是将 matation record
排入微任务队列。
如前所述,在 ECMAScript中,微任务被称为 "jobs"。在PerformPromiseThen 的步骤 8.a中,调用 EnqueueJob
来为微任务排队。
现在,我们来看一个更复杂的例子。
一级BOSS挑战
在写这篇文章之前,我可能有些错误。这里有一点HTML:
html
<div class="outer">
<div class="inner"></div>
</div>
给定以下 JS 代码,如果单击div.inner,将记录什么?
js
// 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 事件是一个任务(Task)。 Mutation observer 和 Promise 的回调函数被排队为微任务。setTimeout 的回调函数被排队为任务。因此,事情是这样的:
因此,Chrome 浏览器是正确的。让我感到 "新奇 "的是,微任务可以在回调后处理(只要没有其他 JavaScript 正在执行),我以为它仅限于任务(Task)结束时被调用。这条规则来自 HTML 规范中的回调调用:
如果脚本设置对象栈现在为空,执行微任务检查点 HTML: 回调后的清理步骤 3。(下图为对应的 HTML 标准中的规范)
微任务检查点涉及到微任务队列,除非我们已经在处理微任务队列。同样的, ECMAScript 对 jobs 的描述也是这样:
在没有正在运行的执行上下文且执行上下文栈为空时候,才能开始 Job 的执行... (下图为对应的 ECMAScript 中的规范)
浏览器出了什么问题?
像 mutation 的回调函数展示的, Firefox 和 Safari 在点击监听器之间正确地耗尽了微任务队列,但是 promise 排队似乎有些不同。这在某种程度上是可以理解的,因为 jobs 和 microtasks 的联系就是模糊不清的,但是我仍然希望它们在事件监听器的回调函数之前执行。
在 Edge 中,我们已经看到它错误的将 promise 排队,但是它也未能在点击事件监听之间消耗完微任务队列,而是在调用所有监听器之后才耗净队列,这说明在两个 click 日志之后出现了单个的 mutate 日志。
一级BOSS的愤怒哥哥
哦,好家伙。使用上面的相同示例,如果我们执行:
js
inner.click();
这将像以前一样启动事件调度,但使用脚本并不是真正的交互。
下面是不同浏览器的输出内容:
我发誓我在Chrome浏览器上得到的结果总是不一样,我已经更新了这个图表很多次,我以为我测试Canary的时候出错了。如果您在Chrome浏览器中得到了不同的结果,请在评论中告诉我是哪个版本。
为什么不同
具体的操作如下所示:
因此正确的顺序是:click、click、promise、mutate、promise、timeout、timeout。
调用每个事件监听器回调函数后...
如果脚本设置对象栈现在为空,执行微任务检查点
HTML: 回调后的清理步骤 3
以前,这意味着微任务在监听器回调之间运行,但是 .click()事件会导致事件同步调度,因此调用 .click() 事件的脚本仍然在回调之间的栈中。上述规则确保微任务不会中断正在运行的JavaScript
。这意味着我们不能在监听器回调之间处理微任务队列,而是在监听器之后去处理它们。
这些很重要吗?
是的,它会在不显眼的地方产生不良影响。我在尝试为 IndexedDB 创建一个简单的包装器库时遇到了这个问题,该问题使用 promise 而不是其他的 IDBRequest 对象,它几乎使IDB的使用变得有趣。
当IDB触发一个成功事件时,相关的事务对象在调度(步骤4)后会变成非活动状态。如果我创建了一个在事件触发时解析的 promise,那么回调就应该在步骤4之前运行,而此时事务仍处于活动状态,但这在Chrome以外的浏览器中不会发生,这使得该库有点无用。
实际上,你可以在 Firefox 中解决这个问题,因为 promise polyfills(例如 es6-promise)为回调使用了 mutation observer,从而正确地使用了微任务。Safari 似乎在使用该修复时出现了竞赛条件,但这可能只是他们的 IDB 实现出了问题。不幸的是,在IE/Edge中,由于 mutation events 没有在回调后处理,所以一直出现故障。
希望我们很快就能在这里看到一些互操作性。
你成功了
总结:
- 任务按顺序执行,浏览器可以在他们之间进行渲染
- 微任务按顺序执行,被调用的时机有:
- 在每次回调后,只要没有 JavaScript 正在运行
- 在每个任务结束时
希望您现在已经熟悉事件循环,或者至少有借口去休息一下了。
实际上,还有人在看吗?有人吗?有人吗?