头图

大家好,我卡颂。

前端框架中经常有将多个自变量变化触发的更新合并为一次执行的批处理场景,框架的类型不同,批处理的时机也不同。

比如如下Svelte代码,点击H1后执行onClick回调函数,触发三次更新。由于批处理,三次更新会合并为一次。

接着分别以同步、微任务、宏任务的形式打印渲染结果:

<script>
  let count = 0;
  let dom;
  const onClick = () => {
    // 三次更新合并为一次
    count++;
    count++;
    count++;
  
    console.log("同步结果:", dom.innerText);
  
    Promise.resolve().then(() => {
      console.log("微任务结果:", dom.innerText);
    });
  
    setTimeout(() => {
      console.log("宏任务结果:", dom.innerText);
    });
  }
</script>

<h1 bind:this={dom} on:click={onClick}>{count}</h1>

同样的逻辑用不同框架实现,打印结果如下:

  • Vue3:同步结果:0 微任务结果:3 宏任务结果:3
  • Svelte:同步结果:0 微任务结果:3 宏任务结果:3
  • Legacy React:同步结果:0 微任务结果:3 宏任务结果:3
  • Concurrent React:同步结果:0 微任务结果:0 宏任务结果:3
4种实现的Demo地址:React
Vue3
Svelte

本质原因在于:有的框架使用宏任务实现批处理,有的框架使用微任务实现批处理。

本文接下来会讲解宏任务微任务的起源,以及他们与批处理的关系。

欢迎加入人类高质量前端框架群,带飞

如何调度任务

先放上完整流程图,方便有个整体印象:

事件循环流程图

默认情况下,浏览器(以Chrome为例)中每个Tab页对应一个渲染进程,渲染进程包含主线程、合成线程、IO线程等多个线程。

主线程的工作非常繁忙,要处理DOM、计算样式、处理布局、处理事件响应、执行JS等。

这里有两个问题需要解决:

  1. 这些任务不仅来自线程内部,也可能来自外部,如何调度这些任务?
  2. 主线程在工作过程中,新任务如何参与调度?

第一个问题的答案是:消息队列

所有参与调度的任务会加入任务队列中。根据队列先进先出的特性,最早入队的任务会被最先处理。用伪代码描述如下:

// 从任务队列中取出任务
const task = taskQueue.takeTask();
// 执行任务
processTask(task);

其他进程通过IPC将任务发送给渲染进程的IO线程,IO线程再将任务发送给主线程的任务队列,比如:

  • 鼠标点击后,浏览器进程通过IPC将“点击事件”发送给IO线程,IO线程将其发送给任务队列
  • 资源加载完成后,网络进程通过IPC将“加载完成事件”发送给IO线程,IO线程将其发送给任务队列

如何调度新任务

第二个问题的答案是:事件循环

主线程会在循环语句中执行任务。随着循环一直进行下去,新加入的任务会插入队列末尾,老任务会被取出执行。用伪代码描述如下:

// 退出事件循环的标识
let keepRunning = true;

// 主线程
function MainThread() {
  // 循环执行任务
  while(true) {
    // 从任务队列中取出任务
    const task = taskQueue.takeTask();
    // 执行任务
    processTask(task);

    if (!keepRunning) {
      break;
    }
  }
}

延迟任务

除了任务队列,浏览器还根据WHATWG标准,实现了延迟队列,用于存放需要被延迟执行的任务(如setTimeout),伪代码如下:

function MainThread() {
  while(true) {
    const task = taskQueue.takeTask();
    processTask(task);

    //执行延迟队列中的任务 
    processDelayTask()

    if (!keepRunning) {
      break;
    }
  }
}

当本轮循环任务执行完后(即执行完processTask后),会执行processDelayTask检查是否有延迟任务到期,如果有任务过期则执行他。

介于processDelayTask的执行时机在processTask之后,所以当任务的执行时间比较长,可能会导致延迟任务无法按期执行。考虑如下代码:

function sayHello() { console.log('hello') }

function test() { 
  setTimeout(sayHello, 0); 
  for (let i = 0; i < 5000; i++) {
    console.log(i);
  }
}
test()

即使将延迟任务sayHello的延迟时间设为0,也需要等待test所在任务执行完后才能执行,所以sayHello最终的延迟时间是大于设定时间的。

宏任务与微任务

加入任务队列的新任务需要等待队列中其他任务都执行完后才能执行,这对于突发情况下需要优先执行的任务是不利的。

为了解决时效性问题,任务队列中的任务被称为宏任务,在宏任务执行过程中可以产生微任务,保存在该任务执行上下文中的微任务队列中。

即流程图中右边的部分:

事件循环流程图

宏任务执行结束前会遍历其微任务队列,将该宏任务执行过程中产生的微任务批量执行。

MutationObserver

微任务是如何解决时效性问题同时又兼顾性能呢?

考虑用于监控DOM变化的微任务API —— MutationObserver

当同一个宏任务中发生多次DOM变化,会产生多个MutationObserver微任务,其执行时机是该宏任务执行结束前,相比于作为新的宏任务进入队列等待执行,保证了时效性。

同时,由于微任务队列内的微任务被批量执行,相比于每次DOM变化都同步执行回调,性能更佳。

总结

框架中批处理的实现本质和MutationObserver非常类似。利用了宏任务微任务异步执行的特性,将更新打包后执行。

只不过不同框架由于更新粒度不同,比如Vue3Svelte更新粒度很细,所以使用微任务实现批处理。

React更新粒度比较粗,但内部实现比较复杂,即有宏任务的场景也有微任务的场景。


卡颂
3.1k 声望16.7k 粉丝