前言
本文是对于异步系列第一篇里提到的evenloop
模型中,所提到的任务队列(task queues)的展开分析
正文
说明:以下代码均使用chrome浏览器运行 关于浏览器表现的差异在最后做补充。
引子-奇怪的执行顺序
先看一个典型的例子:
console.log('script start')// 第一个异步任务setTimeout(()=>{ console.log('setTimeout')},0)// 第二个异步任务Promise.resolve().then(()=>{ console.log('promise1')}).then(()=>{ console.log('promise2');})console.log('script end')// 实际输出结果: // script start// script end// promise1// promise2// setTimeout
根据之前说过的evenloop
模型,先输出script start
和script end
,但是接下来却发现,先执行了Promise
指定的callback
而不是setTimeout
的callback
。
两种任务队列(microtask queue
¯otask queue
)
在之前讨论evenloop模型时,提到了任务队列有2种类型:microtask queue
和macrotask queue
,他们的区别在于:
macrotask
的执行:是在evenloop的每次循环过程,取出macrotask queue中可执行的第一个(注意不一定是第一个,因为我们说过例如setTimeout可以指定任务被执行的最少延迟时间,当前macrotask queue的首位保存的任务可能还没有到执行时间,所以queue只是代表callback
插入的顺序,不代表执行时也要按照这个顺序)。microtask
的执行:在evenloop的每次循环过程之后,如果当前的执行栈(call stack)为空,那么执行microtask queue
中所有可执行的任务
(某些文献内容中 直接把macrotask
称为task
,或者某些中文文章中把它们翻译成"微任务"和"宏任务",含义都是相似的:macrotask或者task代表相对单独占据evenloop过程一次循环的任务,而microtask有可能在一次循环中执行多个)
现在回头来解析前面的例子:
- 第一次执行主函数,输出
script start
- 遇到
setTimeout
,将对应的callback插入macrotask queue
- 遇到
promise
,将对应的callback插入microtask queue
- 输出
script end
,主函数运行结束,执行栈清空,此时开始检查microtask queue
,发现里面有可运行的任务,因此按顺序输出promise1
和promise2
microtask queue
执行完,开始新一轮循环,从macrotask queue
取出setTimeout
任务并执行,输出setTimeout
- 结束,呈现上面的输出结果。
常见异步操作对应的回调函数任务类型如下:
- macrotask:
setTimeout
,setInterval
,setImmediate
,requestAnimationFrame
,I/O
,UI rendering
- microtask:
process.nextTick
,Promises
,Object.observe
,MutationObserver
大概可以这样区分:和html交互密切相关的异步操作,一般是macrotasks
;由emcascript
的相关接口返回的异步操作,一般是microtasks
如何判断执行顺序
接下来看一个更复杂的例子,帮助理解不同异步任务的执行顺序
<style> .outer { padding: 30px; background-color: aqua; } .inner { height: 100px; background-color: brown; }</style><body> <div class="outer">outer <div class="inner">inner</div> </div></body><script> 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);
运行以上代码,可以在浏览器看到两个嵌套的div(如图):
点击inner部分,打开chrome的调试器,可以看到console打出的结果是:
click
promise
mutate
click
promise
mutate
timeout
timeout
接下来分析运行过程 (建议配合单步调试进行分析):
- 点击
inner
,触发对应的onClick
事件,此时inner对应的onClick
函数进入执行栈 - 运行
console.log('click')
,输出click
- 运行
setTimeout
,macrotask queue
添加对应的console
函数 - 运行
Promise
,此时microtask queue
添加对应的console
函数 - 运行
outer.setAttribute
,触发MutationObserver
,microtask queue
添加对应的console
函数(前面注明了MutationObserver创建的回调任务类型是microtask) - 当前函数执行完毕,由于执行栈清空,此时开始调度
microtask queue
,因此依次输出promise
和mutate
,此时当前执行栈call stack
和microtask queue
均为空,但是macrotask queue
里依然存储着两个东西--inner的Click触发的任务,以及先前setTimeout的回调函数。 - inner的
onclick
函数虽然执行完毕,但是由于事件冒泡
,紧接着要触发outer
的onClick
的执行函数,因此setTimeout
的回调暂时还无法执行。 outer
的onClick
函数执行过程,重复前面的2-5步骤,因此再次输出click
promise
mutate
- 此时执行栈
call stack
和microtask queue
均为空,macrotask queue
存储着两个setTimeout的回调函数。,根据evenloop模型,开始分别执行这两个task,于是输出了两个timeout
- 结束。
再次建议在调试器查看上面的步骤,尤其要注意观察call stack
、microtask queue
macrotask queue
的变化,会更加直观
如果已经理解了上面的例子,在上面的基础上,我们把点击inner部分的这个操作,改成直接在js代码的末尾加上innner.click()
,结果是否一致呢?
控制台的结果:click
click
promise
mutate
promise
timeout
timeout
与前一次的结果完全不同!
接下来再次进入调试分析:
- 由于是直接执行
inner.click()
,这次进入inner绑定的onclick函数时,与前面是有所不同的:通过chrome调试器可以看到,此时的call stack有两层-除了onClick函数之外,还有一层匿名函数,这层函数其实就是整个script,相当于window.onload绑定的处理函数。 这是很关键的一点,因为前面的例子的执行顺序是:页面加载后先运行了整个匿名函数,之后该函数出栈,到点击时触发inner的onclcik
,此时onClick
对应的函数进栈。这一个区别导致了整个执行结果的差异。两次执行到onclick时的callstck区别如图:
点击触发:
代码直接触发
- 接下来重复前面例子中,步骤
2-5
,输出一个click
- inner的
onClick
函数执行完毕,但是这次执行栈并未清空,因为当前匿名函数还在执行栈里,因此无法开始调度microtask queue
!!!(前面说了microtask queue的调度必须在当前执行栈为空的情况下),因此这时候会先进入冒泡事件触发的onClick
- 类似的,输出
clcik
之后,promise
的回调函数进入microtask queue
- 运行
outer.setAttribute
,触发MutationObserver
,但是此时microtask queue
无法再次添加对应的回调函数了,因为已经有一个存在的监听函数在pengding
- 两个
onclick
执行完毕,执行栈清空,接下来开始调度microtask queue
,输出promise
mutate
promise
- 此时当前执行栈
call stack
和microtask queue
均为空,macrotask queue
存储着两个setTimeout的回调函数。 - 结束
这两个例子的对比,着重说明了一点:microtask queue
存储的任务,必须要在当前函数执行栈为空时才会开始调度
,完整内容可参见html标准中的8.1.4部分
结论
macrotask
会按顺序执行,并且有可能被中途插入浏览器render
,例如上面的冒泡事件microtask
的执行有两个条件:- 在每个
macrotask
结束之后 - 当前
call stack
为空
- 在每个
ps:浏览器差异
上述代码在chrome的浏览器下测试结果,可能和在某些版本的firefox和ie浏览器下不一致,在某些浏览器中可能会把promise
的回调函数当做mascrotask
,但是:
普遍的共识把 Promise当做是miscrotask
,并且有比较充分的理由:如果把promose当做是task(即mascrotask)将会导致一些性能问题--因为task的调度是可以被其他task相关的任务如Render
打断,还会因为与其他任务源的交互导致不确定性。
参考文献
- Tasks, microtasks, queues and schedules
- HTML Living Standard
如果觉得写得不好/有错误/表述不明确,都欢迎指出
如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处。如果有问题也欢迎私信交流,主页有邮箱地址
如果觉得作者很辛苦,也欢迎打赏~