宏任务、微任务和 Promise 的性能

背景

咱们都知道 setTimeout 和 Promise 并不在一个异步队列中,前者属于宏任务(MacroTask),然后者属于微任务(MicroTask)。html

不少文章在介绍宏任务和微任务的差别时,每每用一个相似于 ++i++++ 同样的题目让你们猜想不一样任务的执行前后。这么作虽然能够精确的理解宏任务和微任务的执行时序,但却让人对于它们之间真正的差别摸不着头脑。c++

更重要的是,咱们彻底不该该依赖这个微小的时序差别进行开发(正如同在 c++ 中不该该依赖未定义行为同样)。虽然宏任务和微任务的定义是存在于标准中的,可是不一样的运行环境并不必定可以精准的遵循标准,并且某些场景下的 Promise 是各类千奇百怪的 polyfill。git

总之,本文不关注执行时序上的差别,只关注性能。

github

异步

不管是宏任务仍是微任务,首先都是异步任务。在 JavaScript 中的异步是靠事件循环来实现的,拿你们最多见的 setTimeout 为例。web

// 同步代码
let count = 1;

setTimeout(() => {
    // 异步
  count = 2;
}, 0);

// 同步
count = 3;
复制代码
Copy

一个异步任务会被丢到事件循环的队列中,而这部分代码会在接下来同步执行的代码后面才执行(这个时序老是可靠的)。每次事件循环中,浏览器会执行队列中的任务,而后进入下一个事件循环。编程

当浏览器须要作一些渲染工做时,会等待这一帧的渲染工做完成,再进入下一个事件循环vim

image.png

那么,为何已经有了这么一个机制,为何又要有所谓的微任务呢,难道只是为了让你们猜想不一样异步任务的执行时序么?promise

为何要有微任务

咱们来看一个 async function 的例子浏览器

const asyncTick = () => Promise.resolve();

(async function(){
    for (let i = 0; i < 10; i++) {
    await asyncTick();
  }
})()
复制代码
Copy

咱们看到这里明明其实没有异步等待的任务,可是若是 Promise.resolve 每次都和 setTimeout 同样往异步队列里丢一个任务而后等待一个事件循环来执行。看起来彷佛没有什么大的问题,由于『事件循环』和一个 for 循环听起来彷佛并无什么本质上的不一样。bash

而后在事实上,一次事件循环的耗时是远远超出一次 for 循环的。

咱们都知道 setTimeout(fn, 0) 并不是真的是当即执行,而是要等待至少 4ms (事实上多是 10ms)才会执行。

MDN 相关文档

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();
复制代码
Copy

你会发现 setTimeout 版本的页面仍然可以操做,而控制台上 test 的输出次数在不断增长。

Promise.resolve 和直接递归的表现是同样的(其实有一些区别, Promise.resolve 仍然是异步执行的),标签页被卡住,Chrome Devtools 上的输出次数隔一段时间蹦一下。

不得不说 Chrome 的 Devtools 优化的确实不错,其实这里已是死循环的状态了,JS 线程被彻底阻塞

Promise 的性能

了解宏任务和微任务的差别有助于咱们理解 Promise 的性能。

咱们在实际生产中经常发现某些环境下的 Promise 的性能表现很是不如意,有些是不一样容器的实现,另外一些则是不一样版本的 polyfill 实现。尤为是一些开发者会更倾向于体积更小的 polyfill ,例如这个有 1.3k Star 的实现

github.com/taylorhakes…

默认就是使用 setTimout 模拟的 Promise.resolve ,咱们在 jsperf.com/promise-per… 能够看到性能的对比已经有了数量级的差距(事实上比较复杂的异步任务会感受到明显的延迟)。

image.png

如何正确的模拟 Promise.resolve

除了 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)
}
复制代码
Copy

原理其实很是简单,手动构造一个 MutationObserver 而后触发 DOM 元素的改变,从而触发异步任务。

使用这种方式就明显把数量级拉了回来

image.png

因为这个 Promise 自己实现偏向于体积的缘故,这里的 benchmark 性能仍有数倍差距,但其实 bluebird 等注重性能的实现方式在 timer 函数用 MutationObserver 构造的状况下性能和原生不相上下,某些版本的浏览器下甚至更快

image.png

固然实际上 Vue 中的 NextTick 实现要更细致一些,例如经过复用 MutationObserver 的方式避免屡次建立等。不过可以让 Promise 实如今性能上拉开百倍差距的就只有宏任务和微任务之间的差别。

MutationObserver 外还有不少其余的 API 使用的也是微任务,但从兼容性和性能角度 MutationObserver 仍然是使用最普遍的。

总结

宏任务和微任务在机制上的差别会致使不一样的 Promise 实现产生巨大的性能差别,大到足以直接影响用户的直接体感。因此咱们仍是要避免暴力引入 Promise polyfill 的方式,在现代浏览器上优先使用 Native Promise ,而在须要 polyfill 的地方则须要避免性能出现破坏性下滑的状况。

另外,哪条 console.log 先执行看懂了就行了,真的不是问题的关键,由于你永远不该该依赖宏任务和微任务的时序差别来编程。

拓展阅读

image.png

相关文章
相关标签/搜索