引言
microtask 这一名词是 JS 中比较新的概念,几乎所有人都是在学习 ES6 的 Promise 时才接触这一新概念,我也不例外。当我刚开始学习 Promise 的时候,对其中回调函数的执行方式特别着迷,于是乎便看到了 microtask 这一个单词,但是困难的是国内很少有关于这方面的文章,有一小部分人探讨过不过对其中的原理和机制的讲解也是十分晦涩难懂。直到我看到了 Jake Archibald 的文章,我才对 microtask 有了一个完整的认识,所以我便想把这篇文章翻译过来,供大家学习和参考。
本篇文章绝大部分翻译自 Jake Archibald 的文章 Tasks, microtasks, queues and schedules。有英文功底的同学建议阅读原著,毕竟人家比我写的好...
适合人群:有一定的 JavaScript 开发基础,对 JavaScript Event Loop 有基本的认识,掌握 ES6 Promise 。
初识 Microtask
让我们先来看一段代码,猜猜它将会以何种顺序输出:
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 却又是按照正确的顺序输出。
为什么?
要理解上面代码的输出原理,你就需要了解 JavaScript 的 event loop 是如何处理 tasks 以及 microtasks,当你第一次看到这一堆概念的时候,相信你也是和我一样的一头雾水,别急,让我们先深呼吸一下,然后开始我们的 microtask 之旅。
每一个“线程”都有一个独立的 event loop,每一个 web worker 也有一个独立的 event loop,所以它可以独立的运行。如果不是这样的话,那么所有的窗口都将共享一个 event loop,即使它们可以同步的通信。event loop 将会持续不断的,有序的执行队列中的任务(tasks)。每一个 event loop 都有着众多不同的任务来源(task source),这些 task source 能够保证其中的 task 能够有序的执行(参见标准 Indexed Database API 2.0)。不过,在每一轮事件循环结束之后,浏览器可以自行选择将哪一个 source 当中的 task 加入到执行队列当中。这样也就使得了浏览器可以优先选择那些敏感性的任务,例如用户的的输入。(看完这段话,估计大部分人都晕了,别急... be patient)
Task 是严格按照时间顺序压栈和执行的,所以浏览器能够使得 JavaScript 内部任务与 DOM 任务能够有序的执行。当一个 task 执行结束后,在下一个 task 执行开始前,浏览器可以对页面进行重新渲染。每一个 task 都是需要分配的,例如从用户的点击操作到一个点击事件,渲染HTML文档,同时还有上面例子中的 setTimeout。
setTimeout 的工作原理相信大家应该都知道,其中的延迟并不是完全精确的,这是因为 setTimeout 它会在延迟时间结束后分配一个新的 task 至 event loop 中,而不是立即执行,所以 setTimeout 的回调函数会等待前面的 task 都执行结束后再运行。这就是为什么 'setTimeout' 会输出在 'script end' 之后,因为 'script end' 是第一个 task 的其中一部分,而 'setTimeout' 则是一个新的 task。这里我们先解释了 event loop 的基本原理,接下来我们会通过这个来讲解 microtask 的工作原理。
Microtask 通常来说就是需要在当前 task 执行结束后立即执行的任务,例如需要对一系列的任务做出回应,或者是需要异步的执行任务而又不需要分配一个新的 task,这样便可以减小一点性能的开销。microtask 任务队列是一个与 task 任务队列相互独立的队列,microtask 任务将会在每一个 task 任务执行结束之后执行。每一个 task 中产生的 microtask 都将会添加到 microtask 队列中,microtask 中产生的 microtask 将会添加至当前队列的尾部,并且 microtask 会按序的处理完队列中的所有任务。microtask 类型的任务目前包括了 MutationObserver 以及 Promise 的回调函数。
每当一个 Promise 被决议(或是被拒绝),便会将其回调函数添加至 microtask 任务队列中作为一个新的 microtask 。这也保证了 Promise 可以异步的执行。所以当我们调用 .then(resolve, reject) 的时候,会立即生成一个新的 microtask 添加至队列中,这就是为什么上面的 'promise1' 和 'promise2' 会输出在 'script end' 之后,因为 microtask 任务队列中的任务必须等待当前 task 执行结束后再执行,而 'promise1' 和 'promise2' 输出在 'setTimeout' 之前,这是因为 'setTimeout' 是一个新的 task,而 microtask 执行在当前 task 结束之后,下一个 task 开始之前。
下面这个 demo 将会逐步的分析 event loop 的运作方式:
通过以上的 demo 相信大家对 microtask 的运作方式有了了解了吧,不得不说我十分佩服 Jake Archibald ,人家自己一个字一个字的码了一个事件轮循器出来。作为一位膜拜者,我也一个字一个字的码了一个出来!...详情可参见引言中贴出的文章。
浏览器的兼容性
有一些浏览器会输出:'script start'、'script end'、'setTimeout'、'promise1'、'promise2'。这些浏览器将会在 'setTimeout' 之后输出 Promise 的回调函数,这看起来像是这类浏览器不支持 microtask 而将 Promise 的回调函数作为一个新的 task 来执行。
不过这一点也是可以理解的,因为 Promise 是来自于 ECMAScript 而不是 HTML。ES 当中有一个 “jobs” 的概念,它和 microtask 很相似,不过他们之间的关系目前还没有一个明确的定义。不过,普遍的共识都认为,Promise 的回调函数是应该作为一个 microtask 来运行的。
如果说把 Promise 当做一个新的 task 来执行的话,这将会造成一些性能上的问题,因为 Promise 的回调函数可能会被延迟执行,因为在每一个 task 执行结束后浏览器可能会进行一些渲染工作。由于作为一个 task 将会和其他任务来源(task source)相互影响,这也会造成一些不确定性,同时这也将打破一些与其他 API 的交互,这样一来便会造成一系列的问题。
Edge 浏览器目前已经修复了这个问题(an Edge ticket),WebKit 似乎始终是标准的,Safari 终究也会修复这个问题,在 FireFox 43 中这个问题也已被修复。
如何判断 task 和 microtask
直接测试输出是个很好的办法,看看输出的顺序是更像 Promise 还是更像 setTimeout,趋向于 Promise 的则是 microtask,趋向于 setTimeout 的则是 task。
还有一种明确的方式是查看标准。例如,timer-initialisation-steps 标准的第 16 步指出 “Queue the task task”。(注意原文中指出的是 14 步,正确是应该是 16 步。)而 queue-a-mutation-record 标准的第 5 步指出 “Queue a mutation observer compound microtask”。
同时需要注意的是,在 ES 当中称 microtask 为 “jobs”。比如 ES6标准 8.4节当中的 “EnqueueJob” 意思指添加一个 microtask。
现在,让我们来一个更复杂的例子...
进阶 microtask
在此之前,你需要了解 MutationObserver 的使用方法
<div class="outer">
<div class="inner"></div>
</div>
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner'); // 给 outer 添加一个观察者
new MutationObserver(function() {
console.log('mutate');
}).observe(outer, {
attributes: true
}); // click 回调函数
function onClick() {
console.log('click'); setTimeout(function() {
console.log('timeout');
}, 0); Promise.resolve().then(function() {
console.log('promise');
}); outer.setAttribute('data-random', Math.random());
} inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
先试着猜猜看程序将会如何输出,你可以在这里查看输出结果:
猜对了吗?不过在这里不同的浏览器可能会有不同的结果。
Chrome | FireFox | Safari | Edge |
click | click | click | click |
promise | mutate | mutate | click |
mutate | click | click | mutate |
click | mutate | mutate | timeout |
promise | timeout | promise | promise |
mutate | promise | promise | timeout |
timeout | promise | timeout | promise |
timeout | timeout | timeout |
谁是正确答案?
click 的回调函数是一个 task,而 Promise 和 MutationObserver 是一个 microtask,setTimeout 是一个 task,所以让我们一步一步的来:
通过以上 demo 我们可以看出,Chrome 给出的是正确答案,这里有一点与之前 demo 不同之处在于,这里的 task 是一个回调函数而不是当前执行的脚本,所以我们可以得出结论:用户操作的回调函数也是一个 task ,并且只要一个 task 执行结束且 JS stack 为空时,这时便检查 microtask ,如果不为空,则执行 microtask 队列。我们可以参见 HTML 标准:
If the stack of script settings objects is now empty, perform a microtask checkpoint
— HTML: Cleaning up after a callback step 3
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
注意在 ES 当中称 microtask 为 jobs。
为什么不同的浏览器表现不同?
通过上面的例子可以测试出,FireFox 和 Safari 能够正确的执行 microtask 队列,这一点可以通过 MutationObserver 的表现中看出,不过 Promise 被添加至事件队列中的方式好像有些不同。 这一点也是能够理解的,由于 jobs 和 microtasks 的关系以及概念目前还比较模糊,不过人们都普遍的期望他们都能够在两个事件监听器之间执行。这里有 FireFox 和 Safari 的 BUG 记录。(目前 Safari 已经修复了这一 BUG)
在 Edge 中我们可以明显的看出其压入 Promise 的方式是错误的,同时其执行 microtask 队列的方式也不正确,它没有在两个事件监听器之间执行,反而是在所有的事件监听器之后执行,所以才会只输出了一次 mutate 。Edge bug ticket (目前已修复)
驾驭 microtask
到了这里,相信大家已经习得了 microtask 的运行机制了吧,不过我们用以上的例子再做一点点小变化,比如我们运行一个:
inner.click();
看看会发生什么?
同样,这里不同的浏览器表现也是不一样的:
Chrome | FireFox | Safari | Edge |
click | click | click | click |
click | click | click | click |
promise | mutate | mutate | mutate |
mutate | timeout | promise | timeout |
promise | promise | promise | promise |
timeout | promise | timeout | timeout |
timeout | timeout | timeout | promise |
奇怪的是,在 Chrome 的个别版本里可能会得到不同的结果,究竟谁是正确答案?让我们一步一步的分析:
从上面 demo 可以看出,正确的答案应该是:'click'、'click'、'promise'、'mutate'、'promise'、'timeout'、'timeout'。所以看来 Chrome 给出的是正确答案。
在前一个 demo 中,microtask 将会在两个 click 时间监听器之间运行,但是在这个 demo 中,由于我们调用 .click() ,使得事件监听器的回调函数和当前运行的脚本同步执行而不是异步,所以当前脚本的执行栈会一直压在 JS 执行栈 当中。所以在这个 demo 中 microtask 不会在每一个 click 事件之后执行,而是在两个 click 事件执行完成之后执行。所以在这里我们可以再次的对 microtask 的检查点进行定义:当执行栈(JS Stack)为空时,执行一次 microtask 检查点。这也确保了无论是一个 task 还是一个 microtask 在执行完毕之后都会生成一个 microtask 检查点,也保证了 microtask 队列能够一次性执行完毕。
总结
关于 microtask 的讲解就到此结束了,同学们有没有一种渐入佳境的感觉呢?现在我们来对 microtask 进行一下总结:
- microtask 和 task 一样严格按照时间先后顺序执行。
- microtask 类型的任务包括 Promise callback 和 Mutation callback。
- 当 JS 执行栈为空时,便生成一个 microtask 检查点。
JS 的 Event Loop 一直以来都是一个比较重要的部分,虽然在学完了过后一下子感觉不出有什么具体的卵用...但是,一旦 Event Loop 的运行机制印入了你的脑海里之后,对你的编程能力和程序设计能力的提高是帮助很大的。关于 Event Loop 的知识很少有相关的书籍有写到,一是因为这一块比较晦涩难懂,短时间内无法领略其精髓,二是因为具体能力提升不明显,不如认识几个 API 来的快,但是这却是我们编程的内力,他能在潜意识中左右着我们编程时思考问题的方式。
本文的 demo 都放在了 jsfiddle 上面,可随意转载(还是注明一下出处吧...)。