任务队列,宏任务与微任务

5,141 阅读6分钟

任务队列

首先task queue任务队列是不是一个队列? 不是.

他是一个set 集合, 因为不是在取任务的时候不是像队列那般先进先出就完了, 而是先把最老的任务获取, 执行, 执行完毕之后才删除.

微任务是不是task queue? 不是.

可以这么说, 微任务是宏任务的附属品, 而宏任务队列和微任务队列一起组成了任务队列

为什么我们需要对宏任务和微任务进行区分?就使用一个任务队列不行吗?

宏任务

说说浏览器的主线程, 也就是每个页面都会创建的页面进程, 主要承担以下任务, 均可作为宏任务:

  • 渲染(解析 DOM, 计算布局以及绘制)
  • 用户交互(点击, 拖动, 触摸,放大缩小)
  • JavaScript的脚本执行
  • 网络请求完成(网络进程通过IPC来的), 文件读写完成, history API

这些事件或响应应该采取什么样的方式? 以什么样的顺序来呢?此时便引入了消息队列和事件机制

渲染进程会维护多个消息队列, 比如延迟队列和普通的消息队列. 主线程采用一个for循环, 不断从这些任务队列中取出任务并执行任务. 这些消息队列中的任意一个任务就叫做宏任务

那么消息队列应该怎么取呢? 这里就涉及了事件循环EventLoop.也就是开篇说到的为什么task queue不是一个queue

  • 先从多个消息队列中选出一个最老的任务, oldestTask
  • 循环系统记录任务开始时间, 并将 oldestTask 记为 currentRunningTask
  • 任务执行完成后, currentRunningTask 设为null, 并从对应task queue中删除该任务
  • 统计任务执行完成的时长(这是为什么settimeout未按照预设时间进行回调时浏览器报警的原因)

其实宏任务已经能满足大多数的需求,在硬件条件还不够号的时候的早期浏览器实现并没有微任务这一说.宏任务时间粒度较大且执行间隔无法准确控制(settimeout的例子) 而且随着硬件性能提升, 需要对任务的时间精度有更高的需求, 宏任务就难以胜任.宏任务性能跟不上. 比如对DOM变化的监听

微任务

微任务的定义就是在当前宏任务结束之前(上下文调用栈已经只剩window了--这是checkpoint),进行的函数回调.

微任务的运作方式

当js在执行脚本时, V8会为其创建一个全局上下文, 同时创建一个微任务队列. 在全局上下文的函数执行过程中, 如果创建了微任务, 就将其放入微任务队列中. 这个队列只允许V8引擎的访问, js无法直接获取.

微任务的产生时机

主要有两种方式

  • MutationObserver 监控某个DOM节点的变化, 绑定回调, 然后通过js来修改这个节点时(包括添加删除部分子节点)即会将回调函数放入微任务队列中
  • Promise 当调用Promise.resolve()或Promise.reject()时会产生微任务放入到微任务队列中(分别对应then的第一个和第二个参数, 以及catch)

微任务队列的检查点(checkpoint)

当宏任务中的js主函数已经执行完毕, js引擎准备退出全局执行上下文并清空调用栈时, js引擎此刻回去查询微任务队列. 然后按照顺序执行. 除此之外还有其他的检查点(都不太重要了), 详情可参考 .此时如果在微任务执行期间产生了微任务会继续往微任务队列中进行添加. V8会一直循环执行, 直到队列为空.

举个例子

console.log(1)
setTimeout(() => {
    Promise.resolve(3).then(console.log)
    console.log(2)
}, 1000)

显而易见, 打印顺序为 1,2,3.我们来分析一下执行过程

  1. 创建window的全局执行上下文
  2. 调用console打印输出1
  3. 调用setTimeout函数, 往延迟队列中添加回调函数, 并记录定时时长为1s
  4. 等待1s后将回调函数从任务队列中取出执行, 此为宏任务
  5. 遇到Promise.resolve, 创建console.log的微任务放到微任务队列中
  6. 打印输出2
  7. 调用栈只剩全局上下文, 此刻检查微任务队列不为空, 执行微任务打印输出3
  8. 微任务为空, 退出当前宏任务执行

需要注意几点

  1. 微任务都是与宏任务绑定的, 每个宏任务会创建自己的微任务
  2. 微任务的执行时长会影响当前红任务的时长, 比如1个宏任务创建了100个微任务, 每个微任务需要10ms , 那么可以说执行微任务就使得宏任务的时间延长了1000ms, 所以一定要注意控制微任务的执行时长
  3. 在一个宏任务中既可以创建微任务也可以创建新的宏任务, 但是微任务总是早于宏任务执行

用得最多的可能就是Promise创建的微任务了, 但其实MutationOvserver也值得说道, 它的来历颇为波折. 他的前身时Mutation Event, 而MutationEvent的来源便是实时监控DOM变化的需求.(在没有MutationEvent之前都是使用settimeout或setInterval进行轮询的机制, 但就是做不到实时性). 但MutationEvent是一个同步的回调, 也就是意味着如果有DOM的更改, 会立即调用回调函数, 渲染引擎需要立即执行JavaScript, 比如在一次宏任务中更改了1个节点10次, 就会触发10此回调, 每个回调都需要假设100ms, 那么就是1s的演示, 这样假设浏览器正在执行一个动画效果, 那么就会造成卡顿.

正因同步调用的性能问题, MutationEvent 终被废弃, 取而代之的是 MutationObserver, 与MutationEvent很大的不同点是前者为异步的调用, 这样比如在一个宏任务中修改了1个节点10次,那么就指挥触发一次的异步调用, 这样即使比较频繁的操作DOM(当然也应尽力避免), 性能上也不会有很大问题.那么这个异步是应该使用宏任务还是微任务呢? 前面说了, 有实时性的要求, 所以需要在DOM更改后尽快的回调和执行, 那么放在微任务中就再适合不过, 所以MutationObserver采用了"异步+微任务"的策略, 异步解决阻塞主线程的性能问题, 微任务解决实时性的问题.

测试

最后还是来一段代码吧, 之前面试的时候有被问到

function executor(resolve, reject) {
    let rand = Math.random()
    console.log(1)
    if(rand > 0.5) resolve()
    reject()
}
const p0 = new Promise(executor)
const p1 = p0.then(_ => {
    console.log('success-0')
    return new Promise(executor)
})
const p2 = p1.then(_=> {
    console.log('success-1')
    return new Promise(executor)
})
p2.catch(_=>{
    console.log('error')
})
console.log(2)

注意, 以上的过程都是在同一个宏任务中进行的, 且答案不唯一, 但只要理解了微任务, 基本上都能说明白.