咱们都知道 setTimeout 和 Promise 并不在一个异步队列中,前者属于宏任务(MacroTask),然后者属于微任务(MicroTask)。html
不少文章在介绍宏任务和微任务的差别时,每每用一个相似于 ++i++++
同样的题目让你们猜想不一样任务的执行前后。这么作虽然能够精确的理解宏任务和微任务的执行时序,但却让人对于它们之间真正的差别摸不着头脑。c++
更重要的是,咱们彻底不该该依赖这个微小的时序差别进行开发(正如同在 c++ 中不该该依赖未定义行为同样)。虽然宏任务和微任务的定义是存在于标准中的,可是不一样的运行环境并不必定可以精准的遵循标准,并且某些场景下的 Promise
是各类千奇百怪的 polyfill。git
不管是宏任务仍是微任务,首先都是异步任务。在 JavaScript 中的异步是靠事件循环来实现的,拿你们最多见的 setTimeout 为例。web
// 同步代码 let count = 1; setTimeout(() => { // 异步 count = 2; }, 0); // 同步 count = 3; 复制代码
一个异步任务会被丢到事件循环的队列中,而这部分代码会在接下来同步执行的代码后面才执行(这个时序老是可靠的)。每次事件循环中,浏览器会执行队列中的任务,而后进入下一个事件循环。编程
当浏览器须要作一些渲染工做时,会等待这一帧的渲染工做完成,再进入下一个事件循环vim
那么,为何已经有了这么一个机制,为何又要有所谓的微任务呢,难道只是为了让你们猜想不一样异步任务的执行时序么?promise
咱们来看一个 async function
的例子浏览器
const asyncTick = () => Promise.resolve(); (async function(){ for (let i = 0; i < 10; i++) { await asyncTick(); } })() 复制代码
咱们看到这里明明其实没有异步等待的任务,可是若是 Promise.resolve
每次都和 setTimeout
同样往异步队列里丢一个任务而后等待一个事件循环来执行。看起来彷佛没有什么大的问题,由于『事件循环』和一个 for
循环听起来彷佛并无什么本质上的不一样。bash
而后在事实上,一次事件循环的耗时是远远超出一次 for 循环的。
咱们都知道 setTimeout(fn, 0)
并不是真的是当即执行,而是要等待至少 4ms
(事实上多是 10ms)才会执行。
In modern browsers,
setTimeout()
/setInterval()
calls are throttled to a minimum of once every 4 ms when successive calls are triggered due to callback nesting (where the nesting level is at least a certain depth), or after certain number of successive intervals.
Note: 4 ms is specified by the HTML5 spec and is consistent across browsers released in 2010 and onward. Prior to (Firefox 5.0 / Thunderbird 5.0 / SeaMonkey 2.2), the minimum timeout value for nested timeouts was 10 ms.
这意味着若是没有微任务的概念,咱们仍然采用宏任务的机制去执行 async function
(实际上就是 Promise
) ,性能会很是的糟糕。
并且对于正在执行一些复杂任务的页面(例如绘制)就更加糟糕了,整个循环都会被这个任务直接阻塞。
微任务就是为了适应这种场景,和宏任务最大的不一样在于,若是在执行微任务的过程当中咱们往任务队列中新增了任务,浏览器会所有消费掉为止,再进入下一个循环。这也是为何微任务和宏任务的时序上会存在差异。
看一个例子:
// setTimeout 版本 function test(){ console.log('test'); setTimeout(test); } test(); // Promise.resolve 版本 // 这会卡住你的标签页 function test(){ console.log('test'); Promise.resolve().then(test); } test(); // 同步版本 // 这会卡住你的标签页 function test(){ console.log('test'); test(); } test(); 复制代码
你会发现 setTimeout
版本的页面仍然可以操做,而控制台上 test
的输出次数在不断增长。
而 Promise.resolve
和直接递归的表现是同样的(其实有一些区别, Promise.resolve
仍然是异步执行的),标签页被卡住,Chrome Devtools 上的输出次数隔一段时间蹦一下。
不得不说 Chrome 的 Devtools 优化的确实不错,其实这里已是死循环的状态了,JS 线程被彻底阻塞
了解宏任务和微任务的差别有助于咱们理解 Promise 的性能。
咱们在实际生产中经常发现某些环境下的 Promise 的性能表现很是不如意,有些是不一样容器的实现,另外一些则是不一样版本的 polyfill 实现。尤为是一些开发者会更倾向于体积更小的 polyfill
,例如这个有 1.3k Star
的实现
默认就是使用 setTimout
模拟的 Promise.resolve
,咱们在 jsperf.com/promise-per… 能够看到性能的对比已经有了数量级的差距(事实上比较复杂的异步任务会感受到明显的延迟)。
除了 Promise
是微任务外,还有不少 API 也是经过微任务设定的异步任务,其实若是有了解过 Vue
源码的同窗就会注意到 Vue
的 $nextTick
源码中,在没有 Promise.resolve
时就是用 MutationObserver
模拟的。
看一个简化的的 Vue.$nextTick
:
const timerFunc = (cb) => { let counter = 1 const observer = new MutationObserver(cb); const textNode = document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true }) counter = (counter + 1) % 2 textNode.data = String(counter) } 复制代码
原理其实很是简单,手动构造一个 MutationObserver
而后触发 DOM 元素的改变,从而触发异步任务。
使用这种方式就明显把数量级拉了回来
因为这个 Promise 自己实现偏向于体积的缘故,这里的 benchmark 性能仍有数倍差距,但其实
bluebird
等注重性能的实现方式在timer
函数用MutationObserver
构造的状况下性能和原生不相上下,某些版本的浏览器下甚至更快
固然实际上 Vue 中的 NextTick
实现要更细致一些,例如经过复用 MutationObserver
的方式避免屡次建立等。不过可以让 Promise 实如今性能上拉开百倍差距的就只有宏任务和微任务之间的差别。
除
MutationObserver
外还有不少其余的 API 使用的也是微任务,但从兼容性和性能角度MutationObserver
仍然是使用最普遍的。
宏任务和微任务在机制上的差别会致使不一样的 Promise
实现产生巨大的性能差别,大到足以直接影响用户的直接体感。因此咱们仍是要避免暴力引入 Promise polyfill
的方式,在现代浏览器上优先使用 Native Promise
,而在须要 polyfill 的地方则须要避免性能出现破坏性下滑的状况。
另外,哪条 console.log
先执行看懂了就行了,真的不是问题的关键,由于你永远不该该依赖宏任务和微任务的时序差别来编程。