React 并发渲染中断:源码层解析协调器如何保存当前进度并响应浏览器高优事件输入

各位好,欢迎来到“React 源码地狱”特别频道。我是你们的领路人,今天我们不聊 API,不聊 Hooks,我们要聊点更硬核的——并发渲染

在 React 18 之前,渲染就像是一个固执的老大爷。你让他渲染,他就得把所有 DOM 节点全部画完,画完了再告诉你结果。期间你如果想去摸鱼或者点个按钮,抱歉,界面会卡死,直到渲染结束。这叫“阻塞式渲染”。

而并发渲染,简单来说,就是让 React 变得“聪明”且“分身有术”。它能在渲染的过程中,听懂浏览器的“召唤”,把渲染任务切得碎碎的,一有机会就停下来,先去处理浏览器高优事件(比如你疯狂点击的按钮),等忙完了再回来接着画。

那么,React 是怎么做到的呢?它是如何保存进度,又如何在被打断后“复活”的?今天,我们就扒开 React 的源码,去看看那个藏在源码深处的“协调器”到底在搞什么鬼。

第一部分:Fiber 架构——不只是纤维,是命门

要理解中断,首先得理解数据结构。在并发模式之前,React 的 Virtual DOM 树虽然也是个树,但它是“扁平”的。一旦开始渲染,React 就是一条道走到黑,根本停不下来。

并发渲染的核心武器,就是 Fiber 架构

你可以在 packages/react-reconciler/src/FiberNode.js 里看到它的定义。Fiber 不仅仅是 React 的虚拟 DOM,它是一个任务调度单元

// 源码片段:FiberNode 的核心结构
function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // 1. 核心身份:我是谁?
  this.tag = tag;
  this.key = key;
  this.type = null;
  this.stateNode = null;

  // 2. 状态容器:我现在的样子是什么?
  // current: 当前屏幕上显示的树(旧树)
  // workInProgress: 我正在构建的新树(新树)
  this.return = null;
  this.child = null;
  this.sibling = null;
  this.index = 0;

  // 3. 草稿箱:如果渲染被打断,我该怎么恢复?
  // pendingProps: 父组件传给我的新属性(还没决定要不要用)
  // memoizedProps: 我上次渲染用的属性(已经确定的)
  // memoizedState: 我上次渲染的状态(比如 useState 的值)
  // updateQueue: 等待处理的更新队列
  this.pendingProps = pendingProps;
  this.memoizedProps = null;
  this.memoizedState = null;
  this.updateQueue = null;

  // 4. 效果标签:我这次渲染要做什么动作?
  this.effectTag = NoEffect;
  this.nextEffect = null;

  // 5. 调度优先级:我是优先级高还是低?
  this.lanes = NoLanes;
  this.subtreeLanes = NoLanes;

  // ... 更多属性
}

看这个 pendingPropsmemoizedProps,这俩兄弟就是并发渲染的“救命稻草”。

想象一下,React 正在从根节点往下遍历,渲染一个包含 10,000 个列表项的组件。它走到第 5,000 个项时,突然浏览器说:“嘿,用户点了个按钮,这个按钮的点击事件得赶紧处理!”

如果是旧版 React,它直接死机。但在 Fiber 架构下,React 检查到这是一个高优事件,于是它暂停了渲染。

暂停了怎么办?它不能把刚才渲染的第 5,000 个项给忘了。它得记下来:“好,我现在停在第 5,000 个节点,pendingProps 是 A,memoizedState 是 B,等我回来接着干。”

这时候,React 会把 workInProgress 树(正在构建的树)的指针指向某个地方,然后去处理浏览器事件。等事件处理完,浏览器说:“没事了,你可以继续了。” React 回头一看,发现 workInProgress 还在,于是从那个节点继续往下遍历。

第二部分:调度器——那个在旁边看表的家伙

React 之所以能“暂停”,是因为有一个叫 Scheduler 的模块在后面盯着时间。这个模块不在 React 核心库里,它是一个独立的 npm 包,专门负责“掐表”。

Scheduler 的核心 API 是 requestIdleCallback。这个 API 允许浏览器在主线程空闲的时候执行回调函数。但是,requestIdleCallback 有个问题:它太懒了,只有当浏览器真的没事干的时候才触发。而在我们点击按钮、用户输入文字的时候,浏览器其实很忙。

所以,React 源码里并没有直接用 requestIdleCallback,而是用了一个更狠的招数——MessageChannel

// 源码片段:Scheduler 的调度逻辑(简化版)
let scheduleCallbackImpl;

if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
  // 如果浏览器支持 isInputPending,那就用它,这可是性能神器
  const isInputPending = window.requestIdleCallback
    ? (cb) => window.requestIdleCallback(cb, { timeout: 1 })
    : (cb) => {
        const start = Date.now();
        window.requestAnimationFrame((rafTime) => {
          cb({
            didTimeout: false,
            timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
          });
        });
      };

  scheduleCallbackImpl = (priorityLevel, callback) => {
    // 根据优先级分配任务
    const timeout = getTimeoutForPriorityLevel(priorityLevel);
    return isInputPending(() => {
      // 这里的 callback 就是 React 的 performConcurrentWorkOnRoot
      callback();
    }, timeout);
  };
} else {
  // 兜底方案,MessageChannel
  scheduleCallbackImpl = (priorityLevel, callback) => {
    const channel = new MessageChannel();
    channel.port2.onmessage = callback;
    channel.port1.postMessage(null);
  };
}

这个 scheduleCallbackImpl 就像是 React 的“交通指挥官”。它把渲染任务扔给浏览器,并告诉浏览器:“嘿,兄弟,你大概给我 50 毫秒时间,或者如果有输入事件,就赶紧打断我。”

第三部分:并发渲染循环——在刀尖上跳舞

现在,我们进入最核心的部分:renderRootConcurrent。这是并发渲染的入口。

// 源码片段:renderRootConcurrent
function renderRootConcurrent(root, lanes) {
  // 初始化工作进度
  let exitStatus = renderRootSync(root, lanes);

  // 如果没有中断,那就完事
  if (exitStatus === RootCompleted) {
    return;
  }

  // 如果这里还没完事,说明发生了中断!
  // 我们需要重置状态,准备重新开始(或者继续)
  // 这里的逻辑非常复杂,涉及到 current 树和 workInProgress 树的切换
  // ... 省略复杂的 reset 函数 ...

  // 开始并发循环
  let lanes = getNextLanes(root, lanes);

  // 核心循环:performConcurrentWorkOnRoot
  const timeoutHandle = scheduleCallback(
    SchedulerPriorityLevel,
    performConcurrentWorkOnRoot.bind(null, root, lanes)
  );
}

看到了吗?如果渲染被中断了,React 并不会直接报错,而是会调用 scheduleCallback,再次把任务扔进去。这就是“重启”的过程。

接下来,我们看 performConcurrentWorkOnRoot,这是真正干活的地方。

// 源码片段:performConcurrentWorkOnRoot
function performConcurrentWorkOnRoot(root, lanes) {
  // 1. 先检查一下,是不是已经有新的更新进来了?
  // 如果有,说明浏览器高优事件已经把任务踢掉了,那就别干了
  const originalCallbackNode = root.callbackNode;
  if (originalCallbackNode !== null) {
    // 这里的 cancelCallback 逻辑省略...
  }

  // 2. 执行渲染
  const exitStatus = renderRootSync(root, lanes);

  // 3. 关键时刻:检查是否应该暂停
  // 这里用到了 deadline 对象
  if (shouldYieldToHost()) {
    // 如果浏览器说“我饿了”,那就暂停!
    // 把当前的工作节点保存下来,下次继续
    const node = root.current;
    root.callbackNode = scheduleCallback(
      SchedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root, lanes)
    );
    return;
  }

  // 4. 如果没暂停,说明渲染完成了
  if (exitStatus === RootCompleted) {
    const finishedWork = root.current.alternate;
    root.finishedWork = finishedWork;
    root.finishedLanes = lanes;
    // 提交阶段开始...
    commitRoot(root);
  } else {
    // 如果还在渲染中(比如没完成),继续下一帧
    root.callbackNode = scheduleCallback(
      SchedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root, lanes)
    );
  }
}

这段代码就是并发渲染的“心脏起搏器”。shouldYieldToHost() 这个函数是判断是否中断的唯一标准。

第四部分:中断与保存——Fiber 的“复活术”

shouldYieldToHost() 返回 true 时,React 需要保存当前进度。它怎么保存?靠的是 Fiber 树的遍历状态

renderRootSync 函数中,React 维护了一个全局变量 nextUnitOfWork。这个变量指向当前应该处理的那个 Fiber 节点。

// 源码片段:workLoopConcurrent
function workLoopConcurrent() {
  // 只要还有任务要处理,并且还没到时间限制
  while (nextUnitOfWork !== null && !shouldYieldToHost()) {
    // 处理当前的节点
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
}

当循环因为 shouldYieldToHost() 停下来时,nextUnitOfWork 就指向了那个被打断的节点。

React 会把当前正在构建的 workInProgress 树保存起来。注意,这棵树是“半成品”。此时,React 不会立即把 workInProgress 树变成 current 树(因为没画完),而是会把这个“半成品”挂在 FiberRoot 上,然后去处理浏览器的高优事件。

当高优事件处理完毕,浏览器再次调用 scheduleCallback,React 再次进入 performConcurrentWorkOnRoot

此时,React 会检查 root.current(旧树)和 root.finishedWork(刚才中断时保存的半成品树)。React 会根据它们的不同,决定是继续向后遍历,还是需要回溯处理副作用。

状态同步的魔法

这里有一个非常高级的技巧:双缓冲

当渲染被打断,React 回来继续时,它需要确保新旧状态的一致性。它通过 cloneFiber 机制来实现。

workInProgress 节点被打断时,React 不会轻易修改 current 节点的属性。相反,它会把 current 节点“克隆”成 workInProgress 节点,修改 workInProgress 节点的属性,然后标记这个节点需要更新。

// 源码片段:beginWork 中的逻辑(简化)
function beginWork(current, workInProgress, renderLanes) {
  // 如果 current 存在(说明不是首次渲染),说明是更新
  if (current !== null) {
    // 比较新旧 props,看看有没有变化
    // 如果有变化,就需要处理
    const update = current.updateQueue;
    if (update !== null) {
      // ... 处理 updateQueue ...
    }
  }

  // 根据不同的 tag 分发处理逻辑
  switch (workInProgress.tag) {
    case HostComponent:
      // 处理 DOM 节点
      return updateHostComponent(current, workInProgress, renderLanes);
    case HostText:
      // 处理文本节点
      return null;
    // ... 其他组件类型
  }
}

如果渲染被中断,React 会把 workInProgress 节点标记为 Incomplete(未完成)。当它回来时,它会再次调用 beginWork,这次它会检查 memoizedPropspendingProps 是否一致。如果不一致,它就继续进行 updateQueue 的处理。

第五部分:响应浏览器高优事件——isInputPending 的神助攻

你以为 React 只要暂停渲染就够了吗?不,React 还得跟浏览器“抢时间”。

shouldYieldToHost 函数中,React 会检查浏览器是否在等待用户输入。这就要用到 isInputPending API(Chrome/Edge 等现代浏览器支持)。

// 源码片段:shouldYieldToHost
function shouldYieldToHost() {
  // 1. 检查浏览器是否还有待处理的输入事件
  if (isInputPending()) {
    return true;
  }

  // 2. 检查当前帧是否已经过去了
  const currentTime = getCurrentTime();
  if (currentTime >= nextYieldTime) {
    // 超时了,必须让出控制权
    nextYieldTime = currentTime + frameInterval;
    return true;
  }

  return false;
}

这个 API 极其强大。它告诉 React:“嘿,用户正在疯狂敲键盘。” 这时候,React 就算还有渲染任务没做完,也会毫不犹豫地停下来,让浏览器先处理键盘输入。否则,用户打字会有延迟感,体验极差。

这就是并发渲染的精髓:优先级管理

  • 高优事件(点击、输入):触发 UserBlockingPriority,React 会立即中断渲染,优先处理这些事件。
  • 低优事件(定时器、普通渲染):触发 NormalPriorityIdlePriority,React 会利用浏览器空闲时间慢慢渲染。

第六部分:实战演练——打断一个列表渲染

让我们来模拟一个场景。

假设你有一个组件 List,里面渲染了 1000 个 Item。你给每个 Item 绑定了一个点击事件。

  1. 启动:React 开始渲染 List
  2. 中断:渲染到第 500 个 Item 时,你点击了第 501 个 Item。
  3. 保存:React 检测到点击事件(高优)。它暂停了 List 的渲染。此时,workInProgress 树停在 500 号节点。
  4. 响应:React 开始处理 501 号 Item 的点击事件。这通常非常快,因为只是更新一个局部状态。
  5. 恢复:点击事件处理完毕。浏览器再次有空闲时间。React 回到 renderRootConcurrent
  6. 继续:React 检查 workInProgress,发现 500 号节点还没处理完。于是它再次调用 performUnitOfWork,从 500 号节点继续向下遍历。

在这个过程中,React 并没有重新开始渲染整棵树。它只是“续杯”了。这种机制保证了即使在极复杂的列表中,用户交互也能保持流畅。

第七部分:副作用与重放

你可能会有个疑问:如果渲染被打断了,副作用(useEffect)怎么处理?

React 的处理逻辑是:如果渲染被打断,副作用会被推迟

在 Fiber 架构中,每个节点都有一个 effectTag。当渲染被打断时,React 不会提交这些副作用。只有当渲染完全完成(RootCompleted),React 才会进入 commitRoot 阶段,一次性执行所有的 DOM 更新和副作用。

所以,如果你在渲染过程中(比如在 beginWork 里)使用了 useEffect,它不会立即执行,而是会被标记为待执行。等渲染彻底结束后,才会统一执行。

总结

好了,各位同学,今天的源码探险就到这里。

我们今天深入探讨了 React 并发渲染的底层逻辑。

  1. Fiber 架构是基础,它把庞大的渲染任务拆解成了一个个小的 Fiber 节点,每个节点都记录了自己的状态(pendingProps, memoizedState)。
  2. Scheduler 是调度员,它利用 requestIdleCallbackMessageChannel,精准地掐着时间,在浏览器需要响应高优事件时,果断喊“停”。
  3. 中断与恢复是核心,React 通过 nextUnitOfWork 指针和双缓冲机制,实现了任务的暂停和续传。它就像一个耐心的工匠,画到一半累了,歇口气,喝口水,等客人走了,再拿起画笔接着画。

React 并发渲染并没有魔法,它只是用更聪明的方式管理了浏览器的单线程资源。它让 React 变得“不急不躁”,在保证性能的同时,让用户的每一次交互都能得到最及时的响应。

下次当你看到 useTransition 或者 startTransition 时,你就能明白,这不仅仅是 API 的改变,更是 React 内部调度哲学的一次大迁徙。它把控制权从 React 手里,交回给了浏览器,让浏览器来决定什么时候该休息,什么时候该工作。

这就是源码的魅力,枯燥,但深刻。好了,下课!记得把你的 useEffect 写在渲染结束后再执行,别让它们在并发的世界里迷路了!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注