浏览器事件循环中的宏任务与微任务

828 阅读6分钟

在浏览器的一次render中,同步任务执行完毕后,首先会检查宏任务中有没有优先级很高的操作(比如ui事件),如果有会优先执行,没有则执行微任务,如果在清空微任务队列中又有新的微任务被添加进来,继续执行微任务,直至清空微任务队列,然后再去执行宏任务(如此循坏)。

事件循环中可能会有一个或多个任务队列,这些队列分别为了处理:

  1. 鼠标和键盘事件
  2. 其他的一些 Task

浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件保证用户的输入得到最高优先级的响应,而剩下的优先级交给其他 Task,并且保证不会“饿死”它们。

流程

  1. 从任务队列中取出一个宏任务并执行。

  2. 检查微任务队列,执行并清空微任务队列,如果在微任务的执行中又加入了新的微任务,也会在这一步一起执行。

  3. 进入更新渲染阶段,判断是否需要渲染,这里有一个 rendering opportunity 的概念,也就是说不一定每一轮 event loop 都会对应一次浏览器渲染,要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定,通常来说这个渲染间隔是固定的。(所以多个 task 很可能在一次渲染之间执行)

注意:浏览器渲染与dom更新并不相同, dom更新是在当前宏任务的所有微任务执行后,下个宏任务前 进行

```js
// 执行下面代码,看到只有1次浏览器刷新渲染 ,
// 因为两个宏任务的dom更新间隔时间特短,在1次屏幕时间(通常16.7ms)内,所以只看到了1次刷新
setTimeout(() => {
  document.body.style.background = "red"
  setTimeout(() => {
    document.body.style.background = "blue"
  })
})
```

类型

任务类型事件类型
宏任务setTimeOut 、 setInterval 、 setImmediate 、 I/O 、 各种callback、 UI渲染 、messageChannelajax网络请求fs.readFile
微任务process.nextTick 、Promise 、MutationObserver 、async(实质上也是promise)

微任务

MutationObserver

MutationObserver 接口提供了监视对DOM树所做的更改能力。它被设计为旧的MutationEvents功能的替代品。

MutationObserver是一个构造器,他能够在指定的DOM发生变化的时候被调用。

process.nextTick

process.NextTick是nodeJS中的概念,在浏览器中并不能够使用哦,node官网传送门👉
准备另开一篇文章:去学习node_eventLoop,传送门👉

console.log('start');
process.nextTick(() => {
  console.log('nextTick callback');
});
console.log('scheduled');

// start
// scheduled
// nextTick callback

注意:每次事件轮询后,在额外的I/O执行前,next tick队列都会优先执行。 递归调用nextTick callbacks 会阻塞任何I/O操作,就像一个while(true); 循环一样。

宏任务

  1. ui交互事件 是优先级高的宏任务(click,input,mouseenter,keyup ,scroll等)
    比如scroll,并不是到了这一步才去执行滚动和缩放,那岂不是要延迟很多?浏览器当然会立刻帮你滚动视图,根据**CSSOM 规范**所讲,浏览器会保存一个 pending scroll event targets,等到事件循环中的 scroll这一步,去派发一个事件到对应的目标上,驱动它去执行监听的回调函数而已。resize等也是同理。就是说浏览器让该类交互事件,ui层面先完成,其余事件处理等遵循时间循环中宏任务的执行原理

  2. postMessage (优先级不高的宏任务

    基本用法
    let ch = new MessageChannel()
    let p1 = ch.port1;
    let p2 = ch.port2;
    
    p1.postMessage("你好我是 p1");
    // port2 receive 你好我是 p1
    
    p2.postMessage("这样啊,我是p2,吃了吗?");
    // port1 receive 这样啊,我是p2,吃了吗?
    
    MDN 示例
    // 使用MessageChannel构造函数实例化了一个channel对象
    var channel = new MessageChannel();
    var para = document.querySelector('p');
    
    // 获取到iframe对象    
    var ifr = document.querySelector('iframe');
    var otherWindow = ifr.contentWindow;
    
    // 当iframe加载完毕
    ifr.addEventListener("load", iframeLoaded, false);
    
    // 我们使用MessagePort.postMessage方法把一条消息和MessageChannel.port2传递给iframe
    function iframeLoaded() {
      otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);
    }
    
    // 
    channel.port1.onmessage = handleMessage;
    function handleMessage(e) {
      para.innerHTML = e.data;
    }
    
  3. webworker

    后来笔者在工作使用到了service-worker帮助进行排序计算,所以补充一下,主页面和worker之间的通信也是是用了 MessageChannel 机制进行实现

    // page.js
    let worker = new Worker('./counting.js');
    worker.postMessage({id:666})
    worker.on('message',result=>{
       rending(result); // 渲染结果
    })
    
    // counting.js
    self.on('message',message=>{
       let result = countintMethod(message.id);
       self.postMessage(result);
    })
    

这样至少有一个好处就是能够不阻塞浏览器UI的渲染,让另一个进程去帮助我们进行计算,然后异步渲染。

  1. setTimeout、setInterval、setImmediate(优先级不高的宏任务

    几个定时器属于宏任务这个不多说。简单提一下setImmediate,它相当于setTimeout(fn,0),一般我们会将他用于把某个任务提取到异步的形式执行,而不阻塞当前任务。

requestAnimationFrame - 不是宏任务的任务 (会在下1次渲染前执行

window.requestAnimationFrame() 告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行 --- MDN ,解决了使用setTimeout等制作动画,时间上可能不靠谱的问题

严格意义上来说,raf并不是一个宏任务,因为

  • 执行时机和宏任务完全不一致。
  • raf任务队列被执行的时候,会将其此刻队列中所有的任务都执行完。

但是查阅了相关规范之后,我们可以知道一个eventLoop的完整过程是包含这浏览器的渲染过程的,再根据上面raf的定义可以知道,ref的执行会在一个eventLoop中的微任务结束后,下一个eventloop开始前去执行。

console.log(1)

setTimeout(function() {
 console.log(2)
}, 0)   // 注意这的数值 改为10试下

const p = new Promise((resolve, reject) => {
 resolve(3)
})

p.then(data => {
 console.log(data)
})

requestAnimationFrame(() => {
    console.log(4)
})

console.log(5)

解释:requestAnimationFrame是渲染前执行,setTimeout中的间隔数值不同导致,requestAnimationFrame的出现顺序不同 。因为间隔设置为0-1的时候,由于间隔时间短,执行到宏任务setTimeout,并没有导致渲染,浏览器是在一次渲染中,。但是大于等于1,导致增加1次渲染, 所以requestAnimationFrame显示的顺序不同了

判断宏任务与ui线程执行顺序

document.getElementsByTagName('h1')[0].addEventListener('click',function(){
    setTimeout(() => {
        while(true){
            console.log(1)
        }
    });
    this.style.color="red"
})
// 执行结果:元素变红,然后进入while循环打印 1
复制代码

判断微任务与ui线程执行顺序

document.getElementsByTagName('h1')[0].addEventListener('click',function(){
    Promise.resolve(2).then(res => {
        while(true){
            console.log(res)
        }
    }
    this.style.color="red"
 })
 // 执行结果:进入while循环打印 2

.为什么vue的真实dom操作会优先选择微任务呢?

答:因为微任务在ui更新队列之前,每一个真实dom操作都会给ui更新队列中推入一个新的任务,所以在拉起ui线程的时候,只需要拉取一次清空ui线程中的任务就行,如果放在宏任务的话,每一次宏任务所生产的ui更新队列任务都会造成一次ui线程的拉起,如果有一百个宏任务dom操作就会拉起100次ui线程,这样会对性能造成极大的影响

参考文章

[1] MessageChannel
[2] MutationObserver - MDN
[3] MutationObserver的使用 - 掘金
[4] requestAnimationFrame是一个宏任务么
[5] Living Standard — Last Updated 6 October 2020

[6] 渲染

[7] js线程中微任务、宏任务与浏览器ui线程之间的执行顺序