消息队列中这种粗时间颗粒度的任务已经不能胜任部分领域的需求,所以又出现了一种新的技术⸺微任务微任务。微任务可以在实时性和效率之间做一个有效的权衡。
从目前的情况来看,微任务已经被广泛地应用,基于微任务的技术有MutationObserver、Promise以及以Promise为基础开发出来的很多其他的技术。所以微任务的重要性也与日俱增,了解其底层的工作原理对于你读懂别人的代码,以及写出更高效、更具现代的代码有着决定性的作用。
有微任务,也就有宏任务,那这二者到底有什么区别?它们又是如何相互取⻓补短的?
宏任务
前面我们已经介绍过了,⻚面中的大部分任务都是在主线程上执行的,这些任务包括了: 渲染事件(如解析DOM、计算布局、绘制);
用戶交互事件(如鼠标点击、滚动⻚面、放大缩小等);
JavaScript脚本执行事件;
网络请求完成、文件读写完成事件。
为了协调这些任务有条不紊地在主线程上执行,⻚面进程引入了消息队列和事件循环机制,渲染进程内部会维护多个消息队列,比如延迟执行队列和普通的消息队列。然后主线程采用一个for循环,不断地从这些任
务队列中取出任务并执行任务。我们把这些所有消息队列中的任务称为宏任务宏任务。
消息队列中的任务是通过事件循环系统来执行的,这里我们可以看看在WHATWG规范中是怎么定义事件循环机制的。
WHATWG规范定义的大致流程
先从多个消息队列中选出一个最老的任务,这个任务称为oldestTask;
然后循环系统记录任务开始执行的时间,并把这个oldestTask设置为当前正在执行的任务
当任务执行完成之后,删除当前正在执行的任务,并从对应的消息队列中删除掉这个oldestTask;
最后统计执行完成的时⻓等信息。
宏任务可以满足我们大部分的日常需求,不过如果有对时间精度要求较高的需求,宏任务就难以胜任了,下面我们就来分析下为什么宏任务难以满足对时间精度要求较高的任务。
function timerCallback2(){
console.log(2)
}
function timerCallback(){
console.log(1)
setTimeout(timerCallback2,0)
}
setTimeout(timerCallback,0)
可以看到两个setTimeout中间被插入了很多任务。虽然都是0
setTimeout函数触发的回调函数都是宏任务,如图中,左右两个⻩色块就是setTimeout触发的两个定时器任务。
现在你可以重点观察上图中间浅红色区域,这里有很多一段一段的任务,这些是被渲染引擎插在两个定时器任务中间的任务。试想一下,如果中间被插入的任务执行时间过久的话,那么就会影响到后面任务的执行了。
微任务
执行时机是在主函数执行结束之后、当前宏任务结束之前执行回调函数,这通常都是以微任务形式体现的。形式体现。
微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前
我们知道当JavaScript执行一段脚本的时候,V8会为其创建一个全局执行上下文,在创建全局执行上下文的同时,V8引擎也会在内部创建一个微任务队列微任务队列。顾名思义,这个微任务队列就是用来存放微任务的,因为在当前宏任务执行的过程中,有时候会产生多个微任务,这时候就需要使用这个微任务队列来保存这些微任务了。不过这个微任务队列是给V8引擎内部使用的,所以你是无法通过JavaScript直接访问的。
就是说每个宏任务都关联了一个微任务队列
我们先来看看微任务是怎么产生的?在现代浏览器里面,产生微任务有两种方式
第一种方式是使用MutationObserver监控某个DOM节点,然后再通过JavaScript来修改这个节点,或者为这个节点添加、删除部分子节点,当DOM节点发生变化时,就会产生DOM变化记录的微任务。
第二种方式是使用Promise,当调用Promise.resolve()或者Promise.reject()的时候,也会产生微任务。
通过DOM节点变化产生的微任务或者使用Promise产生的微任务都会被JavaScript引擎按照顺序保存到微任务队列中。
通常情况下,在当前宏任务中的JavaScript快执行完成时,也就在JavaScript引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript引擎会检查全局执行上下文中的微任务队列,然后按照顺序执行队列中的微任务。WHATWG把执行微任务的时间点称为检查点WHATWG把执行微任务的时间点称为检查点。当然除了在退出全局执行上下文式这个检查点之外,还有其他的检查点,不过不是太重要,这里就不做介绍了。
如果在执行微任务的过程中,产生了新的微任务,同样会将该微任务添加到微任务队列中,V8引擎一直循环!直到队列没内容了就执行下一轮宏任务【如果一直有任务就一直执行】。
该示意图是在执行一个ParseHTML的宏任务,在执行过程中,遇到了JavaScript脚本,那么就暂停解析流程,进入到JavaScript的执行环境。从图中可以看到,全局上下文中包含了微任务列表。
在JavaScript脚本的后续执行过程中,分别通过Promise和removeChild创建了两个微任务,并被添加到微任务列表中。接着JavaScript执行结束,准备退出全局执行上下文,这时候就到了检查点了,JavaScript引擎会检查微任务列表,发现微任务列表中有微任务,那么接下来,依次执行这两个微任务。等微任务队列清空之后,就退出全局执行上下文
微任务和宏任务是绑定的,每个宏任务在执行时,会创建自己的微任务队列。
微任务的执行时⻓会影响到当前宏任务的时⻓。比如一个宏任务在执行过程中,产生了100个微任务,执行每个微任务的时间是10毫秒,那么执行这100个微任务的时间就是1000毫秒,也可以说这100个微任务让宏任务的执行时间延⻓了1000毫秒。所以你在写代码的时候一定要注意控制微任务的执行时⻓。
在一个宏任务中,分别创建一个用于回调的宏任务和微任务,无论什么情况下,微任务都早于宏任务执行。
监听DOM变化方法演变
虽然监听DOM的需求是如此重要,不过早期⻚面并没有提供对监听的支持,所以那时要观察DOM是否变化,唯一能做的就是轮询检测,比如使用setTimeout或者setInterval来定时检测DOM是否有改变。这种方式简单粗暴,但是会遇到两个问题:如果时间间隔设置过⻓,DOM变化响应不够及时;反过来如果时间间隔设置过短,又会浪费很多无用的工作量去检查DOM,会让⻚面变得低效。
直到2000年的时候引入了MutationEvent,MutationEvent采用了观察者的设计模式观察者的设计模式,当DOM有变动时就会立刻触发相应的事件,这种方式属于同步回调
采用MutationEvent解决了实时性的问题,因为DOM一旦发生变化,就会立即调用JavaScript接口。但也正是这种实时性造成了严重的性能问题,因为每次DOM变动,渲染引擎都会去调用JavaScript,这样会产生较大的性能开销。比如利用JavaScript动态创建或动态修改50个节点内容,就会触发50次回调,而且每个回调函数都需要一定的执行时间,这里我们假设每次回调的执行时间是4毫秒,那么50次回调的执行时间就是200毫秒,若此时浏览器正在执行一个动画效果,由于MutationEvent触发回调事件,就会导致动画的卡顿
为了解决了MutationEvent由于同步调用JavaScript而造成的性能问题,从DOM4开始,推荐使MutationObserver来代替MutationEvent。MutationObserverAPI可以用来监视DOM的变化,包括属性的变化、节点的增减、内容的变化等。
首先,MutationObserver将响应函数改成异步调用,可以不用在每次DOM变化都触发异步调用,而是等多次DOM变化后,一次触发异步调用一次触发异步调用,并且还会使用一个数据结构来记录这期间所有的DOM变化。这样即使频繁地操纵DOM,也不会对性能造成太大的影响
我们通过异步调用和减少触发次数来缓解了性能问题,那么如何保持消息通知的及时性呢?如果采用setTimeout创建宏任务来触发回调的话,那么实时性就会大打折扣,因为上面我们分析过,在两个任务之间,可能会被渲染进程插入其他的事件,从而影响到响应的实时性。
这时候,微任务微任务就可以上场了,在每次DOM节点发生变化的时候,渲染引擎将变化记录封装成微任务,并将微任务添加进当前的微任务队列中。这样当执行到检查点的时候,V8引擎就会按照顺序执行微任务了。
通过异步异步操作解决了同步操作的性能问题性能问题;
通过微任务微任务解决了实时性的问题实时性的问题。