前言
什么是 macroTask ? 什么是 microTask ?
本人在阅读 macroTask 和 microTask 相关文献的之前,其实也只是有印象的了解过,但具体不清楚其含义的;
直到在阅读 React 调度机制 Scheduler 源码的时候,了解到 React 使用了 MessageChannel
进行调度执行任务的触发方法的时候才注意到的;
React 为什么不直接进行 “方法的调用”,而是需要通过 MessageChannel
的 postMessage 的方法来触发调用呢?
由此引发了我对这一块的思考,从而了解到了 macroTask 和 microTask 的机制,对于这个机制的认识;
这是有利于应对我们经常在面试中遇到的,需要回答面试官给出的一段 JS 代码执行后,输出的 log 结果的顺序;
还有就是有利于 React 针对 requestIdleCallback
兼容性实现调度机制的源码阅读的理解;
相信对于前端知识来说,这一块是必须要认识的内容。
案例
首先这里先来看几个 JavaScript 事件循环机制在处理任务的时候,实际执行情况是如何的:
console.log(1);
setTimeout(function(){
console.log(2);
}, 0);
console.log(3);
输出结果为: 1、3、2
上面这个案例很容易可以得到答案,因为 setTimeout 会进入异步任务队列(尽管设置的延迟时间为 0), JavaScript 主栈队列会按照从上到下的执行 1、3 输出,当主栈队列清空后,就会从 异步任务队列中出栈进入主栈队列执行,因为对于 JavaScript 的内部机制来说,是单线程的。
接下来看第二个案例:
console.log(1);
setTimeout(function(){
console.log(2);
}, 0);
var res = Promise.resolve(6).then(function(value){
console.log(3);
return value
}).then(function(value){
console.log(4);
return value
});
res.then(function(value){
console.log(value);
});
console.log(5);
输出结果为:1、5、3、4、6、2
这里的第二个案例,应该在前端的面试中,会经常遇到的这种类型的题目,其实这个题目是很简单的,当然是在已经能够正确认识异步任务队列中的 macrotask 和 microtask 之后,这类型的题目是能够非常简单的回答出来的,因为如果未了解异步队列的执行机制的话,常常会被 Promise
、setTimeout
、 then
搞得很混乱。
上面第二个案例中,Promise
异步任务会优先于 setTimeout
的延时为 0 的任务先执行。
原因是任务队列分为 macroTask 和 microTask, 而 Promise
中的 then
方法的函数会被推入到 microTask 队列中,而 setTimeout
函数会被推入到 macroTask 任务队列中;
在每一次事件循环中,macroTask 只会提取一个执行,而 microTask 会一直提取,直到 microTask 队列为空为止。
也就是说如果某个 microTask 任务被推入到执行中,那么当主线程任务执行完成后,会循环调用该队列任务中的下一个任务来执行,直到该任务队列到最后一个任务为止。而事件循环每次只会入栈一个 macroTask ,主线程执行完成该任务后又会检查 microTask 队列并完成里面的所有任务后再执行 macroTask的任务。
任务队列
一般来说,前端人员都知道 JavaScript 的一个特点是单线程,即同一个时间只能做一件事,这样设计主要与其作为浏览器脚本语言有关,JavaScript 主要用途是用户交互以及操作dom,这决定其是单线程设计,否则会带来复杂的同步问题。
比如: 如果 JavaScript 是多线程,那就回产生一个线程删除一个节点,而另一个线程要操作该节点,这个时候游览器需要判断那个线程的顺序之类的会变得十分复杂,游览器也不知道到底先执行谁。
JavaScript 单线程意味着任务需要排队,如果前一个任务耗时长,那么就会阻塞后续任务的执行。为此 JavaScript 出现了同步和异步任务,二者都需要在主线程执行栈中执行;其中异步任务需要进入任务队列(Task queue)进行排队,其具体运行机制如下:
- 同步任务在主线程上执行,形成一个执行栈
- 异步任务,JavaScript 会将任务入栈形式,放入到异步队列中进行排队
- 主线程上的任务执行完后,通过 Event loop ,JavaScript 会从异步任务队列中出栈,将任务进栈到主线程队列中开始实行
JavaScript 事件机制如图:
macroTask 与 microTask 具体指的是什么?
macroTask 与 microTask 指的就是宏任务和微任务,在异步队列中,把异步任务分成了宏任务和微任务两种;
macroTask 与 microTask 任务都是异步任务,执行的时候都会被入栈到异步任务队列中,并且等待某个时机被主线程入栈执行;
macroTask
macroTask(宏任务) 在浏览器端,其可以理解为该任务执行完后,在下一个 macroTask 执行开始前,浏览器可以进行页面渲染。
触发 macroTask 任务的操作包括:
-
script(整体代码)
-
setTimeout、setInterval、setImmediate(浏览器完成其他操作(例如事件和显示更新)后立即运行回调函数-链接)
-
I/O、UI交互事件
-
postMessage、MessageChannel
microTask
microTask(微任务)可以理解为在 macroTask 任务执行后,页面渲染前立即执行的任务。
触发 microTask 任务的操作包括:
-
Promise.then
-
MutationObserver(提供了监视对DOM树所做的更改的功能-链接)
-
process.nextTick(Node环境)
这时候我们继续看上面的第二个例子:
console.log(1);
setTimeout(function(){
console.log(2);
}, 0);
var res = Promise.resolve(6).then(function(value){
console.log(3);
return value
}).then(function(value){
console.log(4);
return value
});
res.then(function(value){
console.log(value);
});
console.log(5);
该程序的结果也已经知道:1、5、3、4、6、2
具体是如何执行输出这个结果的呢?
通过对 macroTask 与 microTask 的了解,所知道这两个队列分别为哪些任务:
macroTasks: [setTimeout回调]
microTasks: [setTimeout回调1, setTimeout回调2, setTimeout回调3]
对于宏任务和微任务,为什么结果输出的时候,是给人感觉就是微任务优先级更高呢?这也是由于宏任务和微任务在 JavaScript 的运行机制所导致的了:
-
执行一个 macroTask(包括整体 Script 代码),若 Js 执行栈空闲则从任务队列中取出异步任务
-
执行一个 macroTask(包括整体 Script 代码)过程中遇到 microTask,则将其添加到 Micro task queue中;同样遇到 macroTask 则添加到 Macro task queue 中
-
这时 macroTask 执行完毕后,立即按序执行 Micro task queue 中的所有 Microtask(因为 microTask 是在 macroTask 执行完成之后,页面渲染之前调用的);如果在执行 microTask的过程中,又产生了 microTask,那么会加入到队列的末尾,也会在这个周期被调用执行;
-
所有 microTask 执行完毕后,浏览器开始渲染,GUI 线程接管渲染
-
渲染完毕,从 Macro task queue 中取下一个 macroTask 开始执行
Event loop
在主线程执行栈空闲的情况下,从任务队列中读取任务入执行栈执行,这个过程是循环不断进行的,所以又称 Event loop(事件循环)。
Event loop 是一个 js 实现异步的规范,在不同环境下有不同的实现机制,例如浏览器和 NodeJS 实现机制不同:
- 浏览器的 Event loop 是按照 html 标准定义来实现,具体的实现留给各浏览器厂商
下面来说说浏览器环境下的Event loop:
按先进先出原则选择最新进入 Event loop 任务队列的一个 macroTask,若没有则直接进入第6步的 microTask
选择当前 Event loop 的任务准备进入执行栈进行执行
Event loop 当前任务进栈运行
当执行栈运行完毕所有任务后,循环机制 Event loop 会再次查询异步队列是否有任务
执行完一次 microTask,Event loop 会优先查询是否含有 microTask 任务,如果有
出栈 microTask 再次进执行栈
更新并进行UI渲染
返回第一步执行
- NodeJS 中的 Event loop 是基于 libuv 实现
根据上图,Node.js的运行机制如下。
(1)V8 引擎解析 JavaScript 脚本。
(2)解析后的代码,调用 Node API。
(3)libuv 库负责 Node API 的执行。它将不同的任务分配给不同的线程,形成一个 Event Loop(事件循环),以异步的方式将任务的执行结果返回给 V8 引擎。
(4)V8 引擎再将结果返回给用户。
除了 setTimeout
和 setInterval
这两个方法,Node.js 还提供了另外两个与"任务队列"有关的方法:process.nextTick
和 setImmediate
。它们可以帮助我们加深对"任务队列"的理解。
process.nextTick
方法可以在当前"执行栈"的尾部----下一次 Event Loop(主线程读取"任务队列")之前----触发回调函数。也就是说,它指定的任务总是发生在所有异步任务之前。setImmediate
方法则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务总是在下一次 Event Loop 时执行,这与 setTimeout(fn, 0)
很像。
microTask 的应用
Vue 的应用
microTask 异步任务的应用,在 Vue 中就有使用,其中在官网就有明确的说明和解释(链接),以下是 Vue 官方解释和案例:
Vue 在更新 DOM 时是异步执行的。只要侦听到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。然后,在下一个的事件循环“tick”的时候,Vue 刷新队列并执行实际工作,通过 microTask 安排这些任务,执行这些缓存(这时候就会更新 Dom 树)。Vue 在内部对异步队列会尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。
例如,当你设置 vm.someData = 'new value',该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环“tick”中更新。多数情况我们不需要关心这个过程,但是如果你想基于更新后的 DOM 状态来做点什么,这就可能会有些棘手。虽然 Vue.js 通常鼓励开发人员使用“数据驱动”的方式思考,避免直接接触 DOM,但是有时我们必须要这么做。为了在数据变化之后等待 Vue 完成更新 DOM,可以在数据变化之后立即使用 Vue.nextTick(callback)。这样回调函数将在 DOM 更新完成后被调用。例如:
Vue.component('example', {
template: '<span>{{ message }}</span>',
data: function () {
return {
message: '未更新'
}
},
methods: {
updateMessage: function () {
this.message = '已更新'
console.log(this.$el.textContent) // => '未更新'
this.$nextTick(function () {
console.log(this.$el.textContent) // => '已更新'
})
}
}
})
这个官方例子中,this.$nextTick
就会触发一个 microTask 任务,在进行数据赋值发生变化之后,离开调用 this.$nextTick
,这时候就会把回调任务一同放到Vue 刷新队列并执行实际工作 同次时间循环中的 microTask 队列后面,这用 Vue 实际更新完 Dom 数据并在游览器渲染之前,就会执行这个回调,进而就可以拿到 this.$el.textContent
已更新的值
因为 $nextTick()
返回一个 Promise 对象,所以你可以使用新的 ES2017 async/await
语法完成相同的事情:
methods: {
updateMessage: async function () {
this.message = '已更新'
console.log(this.$el.textContent) // => '未更新'
await this.$nextTick()
console.log(this.$el.textContent) // => '已更新'
}
}
core-js 的模块应用
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 () {
// Node 方式
process.nextTick(flush);
};
// browsers with MutationObserver
} else if (Observer) {
// MutationObserver 方式
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) {
// Promise.then 方式
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;
};
};
最后实战一下
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);
这是一个面试题中常见的题目,通过阅读上面的内容,这样的题,相信各位应该很容易可以解答出来了,哈哈~
答案:1、4、7、5、2、3、6
解析:
- 首先,1、4、7 都是一开始在执行栈直接执行的任务
- 之后会先去执行 microTask,因为 1、4、7 执行的时候算一个 macroTask - script(整体代码),所以输出 5
- microTask 队列清空后,就会执行下一个 macroTask ,这时候输出了 2,并且 通过
Promise.resolve()
入栈一个 microTask- 执行完 2 这个 macroTask 后,会在执行 3 这个 microTask,所以这时候输出了 3
- 最后清空完 microTask 队列,执行下一个 macroTask 6,这时就会输出 6 了