
大家好,我卡颂。
前端框架中经常有「将多个自变量变化触发的更新合并为一次执行」的批处理场景,框架的类型不同,批处理的时机也不同。
比如如下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 宏任务结果:3Svelte:同步结果:0 微任务结果:3 宏任务结果:3Legacy React:同步结果:0 微任务结果:3 宏任务结果:3Concurrent React:同步结果:0 微任务结果:0 宏任务结果:34种实现的
Demo地址:React[1]Vue3[2]Svelte[3]
本质原因在于:有的框架使用宏任务实现批处理,有的框架使用微任务实现批处理。
本文接下来会讲解宏任务、微任务的起源,以及他们与批处理的关系。
先放上完整流程图,方便有个整体印象:

事件循环流程图
默认情况下,浏览器(以Chrome为例)中每个Tab页对应一个渲染进程,渲染进程包含主线程、合成线程、IO线程等多个线程。
主线程的工作非常繁忙,要处理DOM、计算样式、处理布局、处理事件响应、执行JS等。

这里有两个问题需要解决:
第一个问题的答案是:「消息队列」
所有参与调度的任务会加入任务队列中。根据队列「先进先出」的特性,最早入队的任务会被最先处理。用伪代码描述如下:
// 从任务队列中取出任务
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最终的延迟时间是大于设定时间的。
加入任务队列的新任务需要等待队列中其他任务都执行完后才能执行,这对于「突发情况下需要优先执行的任务」是不利的。
为了解决时效性问题,任务队列中的任务被称为宏任务,在宏任务执行过程中可以产生微任务,保存在该任务执行上下文中的微任务队列中。
即流程图中右边的部分:

事件循环流程图
在宏任务执行结束前会遍历其微任务队列,将该宏任务执行过程中产生的微任务批量执行。
微任务是如何解决时效性问题同时又兼顾性能呢?
考虑用于监控DOM变化的微任务API —— MutationObserver。
当同一个宏任务中发生多次DOM变化,会产生多个MutationObserver微任务,其执行时机是该宏任务执行结束前,相比于作为新的宏任务进入队列等待执行,保证了时效性。
同时,由于微任务队列内的微任务被批量执行,相比于每次DOM变化都同步执行回调,性能更佳。

框架中批处理的实现本质和MutationObserver非常类似。利用了宏任务、微任务异步执行的特性,将更新打包后执行。
只不过不同框架由于更新粒度不同,比如Vue3、Svelte更新粒度很细,所以使用微任务实现批处理。
React更新粒度很粗,但内部实现复杂,即有宏任务场景也有微任务的场景。
[1]
React: https://codesandbox.io/s/react-concurrent-mode-demo-forked-t8mil?file=/src/index.js
[2]
Vue3: https://codesandbox.io/s/crazy-rosalind-wqj0c?file=/src/App.vue
[3]
Svelte: https://svelte.dev/repl/1e4e4e44b9ca4e0ebba98ef314cfda54?version=3.44.1