Blog.

React Fiber 如何调度任务的

LiuuY

React Fiber 如何做到性能提升的呢?

超极简版本:

  1. 将 React 需要执行的任务分解。

  2. 在每个固定的时间片内执行任务,然后释放主线程(yield to the main thread),以执行绘制和响应用户交互事件等。

这样无论多复杂的任务,都不会阻塞主线程,保证了体验。

我们通过最简单的代码来还原上述的逻辑:

我们有一个大任务,如果直接执行它会导致阻塞主线程:

/**
 * 大任务
 */
function bigCpuIntensiveTask() {
  for (let i = 0; i < 1000000000; i++) {
  }
}

我们将其分解为 100 个小任务:

/**
 * 小任务
 */
function smallCpuIntensiveTask() {
  for (let i = 0; i < 10000000; i++) {
  }
}

但是如果我们直接执行这 100 个小任务,则还是无法避免阻塞主线程:

for (let i = 0; i < 100; i++) {
  smallCpuIntensiveTask();
}

此时就需要在每个时间片后,然后释放主线程,即将后面未执行的任务,放入 Task Queue 中,待浏览器主线执行优先级更高的任务后,再执行 Task Queue 中我们的任务。

例如我们可以使用 setTimeout 将后续任务放入 Task Queue,将超时时间设为 0ms(虽然设置为 0ms,一般浏览器最少超时时间 4ms,不过这个说法已经过时了)。

这样在每个小任务执行后,就释放了主线程:

/**
 * 运行 100 个小任务
 */
let count1 = 100;
async function runTasks1() {
  if (count1-- > 0) {
    smallCpuIntensiveTask()
    setTimeout(smallCpuIntensiveTask, 0); // 释放主线程,将后续任务加入 Task Queue
  }
}

因为 setTimeout 的 4ms 最短超时时间限制,React 使用的是 MessageChannel,以减小不必要的「超时」。

/**
 * 运行 100 个小任务
 */
let count2 = 100;
const channel = new MessageChannel();
const port = channel.port2;

channel.port1.onmessage = () => {
  if (count2-- > 0) {
    smallCpuIntensiveTask()
    port.postMessage(null); // 释放主线程,将后续任务加入 Task Queue
  }
}

function runTasks2() {
  port.postMessage(null);
}

最后,我们运行下面的例子,红色方块代表高优先级的动画,三个按钮分别代表了,执行一个大任务、使用 setTimeout/MessageChannel 释放主线程。

可以看到如果我们点击「大任务」按钮,红色方块动画就会被卡住,而分解任务则不会出现:


但是使用 setTimeout/MessageChannel 的方案也并不完美,例如可能有其他的代码也将任务加入了 Task Queue 中并且在我们的 Task 前面,这就会导致更加长的延迟执行。

针对这个情况,有个新的 API:scheduler.yield(),专门处理「释放主线程」这个问题:

async function yieldy () {
  // Do some work...
  // ...

  // Yield!
  await scheduler.yield();

  // Do some more work...
  // ...
}

总结

React 就是通过分解任务和让出主线程来优化性能的,这也就是 React Fiber 架构调度任务的的核心逻辑。同时,「释放主线程(yield to the main thread)」这个方式也是一个通用的优化长任务的逻辑:例如处理输入延迟的问题