前言
首先来看一个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任务的操作包括:
scrip(整体代码)
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
具体的可视化操作演示可以参考。
上面代码运行到最后一句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是按照定义来实现,具体的实现留给各浏览器厂商
NodeJS中的Event loop是基于libuv实现
下面来说说浏览器环境下的Event loop,首先借用一幅图:
根据对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绑定的数据发生变化时,页面视图不会立即重新更新,需要等到当前任务执行完毕时进行更新。例如下面代码:
export default { data () { return { test: 'begin' }; }, methods () { handleClick () { this.test = 'end'; console.log(this.$refs.test.innerText);//打印“begin” } }}{ {test}}
上面代码在执行this.test = 'end'后,页面视图绑定数据test发生变化,若按照同步执行代码,视图应该能马上获取到对应dom的内容,但是并没有获取到。这是因为Vue采用异步视图更新的。具体来说就是Vue在侦听到数据变化时,异步更新视图最终是通过nextTick
来完成的,而该方法默认采用microtask任务来实现异步任务,具体的可以参考;这样在 microtask 中就完成数据更新,task 结束就可以得到最新的 UI 了。上面代码如下:
handleClick () { this.test = 'end'; this.$nextTick(() => { console.log(this.$refs.test.innerText);//打印"end" });}
按照描述,macrotask、microtask和UI 渲染的执行顺序:
一个macrotask任务 --> 所有microtask任务 --> UI 渲染。
既然nextTick是按照microtask来实现异步的,那么microtask任务应该是在UI渲染前执行的,为什么表现的是microtask在UI 渲染之后执行的呢?可能有人对上面提出过质疑。猜测原因如下,具体原因可以参考。
JS更新dom是同步完成的,但是UI渲染是异步的。
microtask跨浏览器实现
从Vue的nextTick
方法的实现以及的实现可以看出,怎么实现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
可以从以下几个步骤来简单分析,具体执行步骤如下图所示: