聊聊JavaScript异步中的macrotask和microtask
前言
首先来看一个JavaScript的代码片段:
console.log(1);
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3)
});
}, 0);
new Promise((resolve, reject) => {
console.log(4)
resolve(5)
}).then((data) => {
console.log(data);
})
setTimeout(() => {
console.log(6);
}, 0)
console.log(7);
如果你能知道正确的答案,那么后续的内容可以略过了;如果不能建议看看下面有关js异步的内容,百利无一害,😁😁。
任务队列
js的一大特点是单线程,即同一个时间只能做一件事,这样设计主要与其作为浏览器脚本语言有关,js主要用途是用户交互以及操作dom,这决定其是单线程设计,否则会带来复杂的同步问题。比如一个线程删除一个节点,而另一个线程要操作该节点,浏览器不知以哪个线程为准。
单线程意味着任务需要排队,如果前一个任务耗时长,那么就会阻塞后续任务的执行。为此js出现了同步和异步任务,二者都需要在主线程执行栈中执行;其中异步任务需要进入任务队列(task queue)进行排队,其具体运行机制如下:
-
同步任务在主线程上执行,形成一个执行栈
-
js会将主线程执行栈中的异步任务置于任务队列排队
-
一旦主线程执行栈同步任务执行完毕处于空闲状态时,就会将任务队列中任务入栈开始执行
还是先来看一个js片段:
console.log('script start')
setTimeout(function() {
console.log('timeout')
}, 0)
console.log('script end')
这段代码在进入主线程执行时,当执行到setTimeout时会将其放置到异步任务队列中,即使设置时间为0也不会马上执行,必须等到主线程执行栈空闲时(执行完console.log('script end')语句后)才会读取异步队列的任务执行。
macrotask与microtask
二者任务都会被放置于任务队列中等待某个时机被主线程入栈执行,其实任务队列分为宏任务队列和微任务队列,其中放置的分别为宏任务和微任务。
-
macrotask(宏任务) 在浏览器端,其可以理解为该任务执行完后,在下一个macrotask执行开始前,浏览器可以进行页面渲染。触发macrotask任务的操作包括:
-
script(整体代码)
-
setTimeout、setInterval、setImmediate
-
I/O、UI交互事件
-
postMessage、MessageChannel
-
-
microtask(微任务)可以理解为在macrotask任务执行后,页面渲染前立即执行的任务。触发microtask任务的操作包括:
-
Promise.then
-
MutationObserver
-
process.nextTick(Node环境)
-
下面通过例子来看看二者的不同:
console.log('script start');
setTimeout(function() {
console.log('timeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
上面一段代码输出结果为:
script start > script end > promise1 > promise2 > timeout
具体的可视化操作演示可以参考Tasks, microtasks, queues and schedules。
上面代码运行到最后一句console后,生成的任务队列:
macrotasks:【setTimeout回调】
microtasks:【Promise.then回调1, Promise.then回调2】
两种不同的任务队列,为啥microtask的任务会先执行呢,这就要说说macrotask与microtask的运行机制[3]如下:
-
执行一个macrotask(包括整体script代码),若js执行栈空闲则从任务队列中取
-
执行过程中遇到microtask,则将其添加到micro task queue中;同样遇到macrotask则添加到macro task queue中
-
macrotask执行完毕后,立即按序执行micro task queue中的所有microtask;如果在执行microtask的过程中,又产生了microtask,那么会加入到队列的末尾,也会在这个周期被调用执行;
-
所有microtask执行完毕后,浏览器开始渲染,GUI线程接管渲染
-
渲染完毕,从macro task queue中取下一个macrotask开始执行
Event loop
在主线程执行栈空闲的情况下,从任务队列中读取任务入执行栈执行,这个过程是循环不断进行的,所以又称Event loop(事件循环)。
Event loop是一个js实现异步的规范,在不同环境下有不同的实现机制,例如浏览器和NodeJS实现机制不同:
-
浏览器的Event loop是按照html标准定义来实现,具体的实现留给各浏览器厂商
-
NodeJS中的Event loop是基于libuv实现
下面来说说浏览器环境下的Event loop,首先借用一幅图:
根据HTML Standard - event loop processing model对Event loop规范描述来简单说明事件循环模型:
-
按先进先出原则选择最新进入Event loop任务队列的一个macrotask,若没有则直接进入第6步的microtask
-
设置Event loop的当前任务为上面一步选择的任务
-
进栈运行所选的任务
-
运行完毕设置Event loop的当前任务为null
-
将第一步选择的任务从任务队列中删除
-
执行microtask:perform a microtask checkpoint,具体执行步骤参考这里
-
更新并进行UI渲染
-
返回第一步执行
microtask的应用
根据Event loop机制,macrotask的一个任务执行完后就进行UI渲染,然后进行另一个macrotask任务执行,macrotask任务的应用就不做过多介绍。下面来说说microtask任务的应用场景,我们以vue的异步更新DOM来做说明,先看官网的说明:
Vue异步执行DOM更新,只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。
也就是说,Vue绑定的数据发生变化时,页面视图不会立即重新更新,需要等到当前任务执行完毕时进行更新。例如下面代码:
<template>
<div>
<div ref="test">{{test}}</div>
<button @click="handleClick">tet</button>
</div>
</template>
export default {
data () {
return {
test: 'begin'
};
},
methods () {
handleClick () {
this.test = 'end';
console.log(this.$refs.test.innerText);//打印“begin”
}
}
}
上面代码在执行this.test = 'end'后,页面视图绑定数据test发生变化,若按照同步执行代码,视图应该能马上获取到对应dom的内容,但是并没有获取到。这是因为Vue采用异步视图更新的。具体来说就是Vue在侦听到数据变化时,异步更新视图最终是通过nextTick
来完成的,而该方法默认采用microtask任务来实现异步任务,具体的可以参考从Vue.js源码看nextTick机制;这样在 microtask 中就完成数据更新,task 结束就可以得到最新的 UI 了。上面代码如下:
handleClick () {
this.test = 'end';
this.$nextTick(() => {
console.log(this.$refs.test.innerText);//打印"end"
});
}
按照HTML Standard描述,macrotask、microtask和UI 渲染的执行顺序:
一个macrotask任务 --> 所有microtask任务 --> UI 渲染。
既然nextTick是按照microtask来实现异步的,那么microtask任务应该是在UI渲染前执行的,为什么表现的是microtask在UI 渲染之后执行的呢?可能有人对上面提出过质疑。猜测原因如下,具体原因可以参考这篇文章。
JS更新dom是同步完成的,但是UI渲染是异步的。
microtask跨浏览器实现
从Vue的nextTick
方法的实现以及immediate的实现可以看出,怎么实现Event loop中的microtask实现呢?那就是借助js原生支持的Promise、MutationObserver(浏览器)、process.nextTick(nodejs环境)来实现,均不支持时使用setTimeout(fn, 0)
来兜底降级实现。下面就来简单说说microtask的实现思路:
-
浏览器是否原生实现Promise,有则使用Promise类似如下实现,否则走下一步。
if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() microTimerFunc = () => { p.then(handle) }
-
浏览器环境是否原生支持MutationObserver,支持可以这么实现,否则走下一步。
function microFun(handle) { var observer = new MutationObserver(handle); var element = document.createTextNode(''); observer.observe(element, { characterData: true }); return function () { element.data = blabla; }; }
-
浏览器是否支持
onreadystatechange
事件,支持则创建一个空的script标签,一旦插入到document中,其onreadystatechange事件将会异步地触发,比setTimeout(fn,0)快,否则走下一步function microFun(handle) { return function () { var scriptEl = document.createElement('script'); scriptEl.onreadystatechange = function () { handle(); scriptEl.onreadystatechange = null; scriptEl.parentNode.removeChild(scriptEl); scriptEl = null; }; document.documentElement.appendChild(scriptEl); return handle; }; };
-
使用setTimeout(fn, 0)来兜底实现
下面看一下core-js模块中Promise中对microtask的模拟实现,具体可以参考源码:
module.exports = function () {
var head, last, notify;
var flush = function () {
var parent, fn;
if (isNode && (parent = process.domain)) parent.exit();
while (head) {
fn = head.fn;
head = head.next;
try {
fn();
} catch (e) {
if (head) notify();
else last = undefined;
throw e;
}
} last = undefined;
if (parent) parent.enter();
};
// Node.js
if (isNode) {
notify = function () {
process.nextTick(flush);
};
// browsers with MutationObserver
} else if (Observer) {
var toggle = true;
var node = document.createTextNode('');
new Observer(flush).observe(node, { characterData: true }); // eslint-disable-line no-new
notify = function () {
node.data = toggle = !toggle;
};
// environments with maybe non-completely correct, but existent Promise
} else if (Promise && Promise.resolve) {
var promise = Promise.resolve();
notify = function () {
promise.then(flush);
};
// for other environments - macrotask based on:
// - setImmediate
// - MessageChannel
// - window.postMessag
// - onreadystatechange
// - setTimeout
} else {
notify = function () {
// strange IE + webpack dev server bug - use .call(global)
macrotask.call(global, flush);
};
}
return function (fn) {
var task = { fn: fn, next: undefined };
if (last) last.next = task;
if (!head) {
head = task;
notify();
} last = task;
};
};
问题答案
对于文章开头的js代码,其最终输出内容为:
1 -> 4 -> 7 -> 5 -> 2 -> 3 -> 6
可以从以下几个步骤来简单分析,具体执行步骤如下图所示: