React 并发模式:Fiber 架构与 Suspense 原理

引言 React 18 的并发模式(Concurrent Mode)不是一项单一功能,而是一套底层重构后的调度体系。它的核心目标只有一个:在保证 UI 正确性的前提下,让高优先级更新能够中断低优先级渲染,从而避免组件树过于庞大时出现的卡顿。

引言

React 18 的并发模式(Concurrent Mode)不是一项单一功能,而是一套底层重构后的调度体系。它的核心目标只有一个:在保证 UI 正确性的前提下,让高优先级更新能够中断低优先级渲染,从而避免组件树过于庞大时出现的卡顿。本文从 Fiber 架构出发,逐步深入 Work Loop、Lane 优先级模型、Suspense 边界以及双缓冲机制,帮助读者理解 React 并发渲染的完整链路。


一、Fiber 架构:链表树结构

在 React 16 之前,调和过程(Reconciliation)采用递归遍历组件树的方式,一旦开始便无法中断。当组件层级深、节点数量大时,主线程被长时间占用,导致动画掉帧、输入响应延迟。Fiber 架构彻底改变了这一局面。

1.1 Fiber 节点结构

每个 React 元素对应一个 Fiber 节点,节点之间通过链表连接:

// 简化版 Fiber 节点定义
function FiberNode(tag, pendingProps, key, mode) {
  this.tag = tag;                    // 组件类型标识(FunctionComponent/ClassComponent等)
  this.key = key;
  this.elementType = null;           // 创建该 Fiber 的 ReactElement.type
  this.type = null;                  // 对应组件的函数或类
  this.stateNode = null;             // 对于 HostComponent,指向真实 DOM 节点

  // 链表指针 —— 构成树结构的核心
  this.return = null;                // 父节点
  this.child = null;                 // 第一个子节点
  this.sibling = null;               // 下一个兄弟节点
  this.index = 0;                    // 在兄弟节点中的位置

  this.pendingProps = pendingProps;  // 待渲染的 props
  this.memoizedProps = null;         // 上一次渲染的 props
  this.memoizedState = null;         // 上一次渲染的 state
  this.updateQueue = null;           // 更新队列

  this.flags = NoFlags;              // 副作用标记(Placement/Update/Deletion 等)
  this.lanes = NoLanes;              // 该 Fiber 的更新优先级
  this.alternate = null;             // 指向另一棵树的对应节点(双缓冲核心)
}

与传统的树节点(同时拥有 left、right 或 children 数组)不同,Fiber 采用单链表 + 父指针的设计。组件树的遍历不再是递归调用栈,而是通过指针遍历完成的循环。这使得渲染过程随时可以从当前 Fiber 节点恢复,天然支持中断和恢复。

1.2 链表遍历策略

Fiber 树的遍历遵循深度优先规则,但使用显式的指针而非函数栈:

// 深度优先遍历的核心逻辑(简化版)
function performWorkOnFiber(fiber) {
  // 1. 处理当前节点
  const next = beginWork(fiber);

  if (next !== null) {
    // 有子节点,继续深入
    return next;
  }

  // 2. 叶子节点,开始回溯
  let node = fiber;
  while (node !== null) {
    completeWork(node);

    // 有兄弟节点则转向兄弟
    if (node.sibling !== null) {
      return node.sibling;
    }
    // 否则回到父节点继续回溯
    node = node.return;
  }

  return null; // 整棵树处理完毕
}

这种模式被称为 Unit of Work(工作单元) —— 每个 Fiber 节点就是一个可独立执行的工作单元。React 可以在这个链表的任意位置暂停,将控制权交还给浏览器。


二、时间切片:为什么 Fiber 能做到不阻塞

Fiber 的本质是将不可中断的大任务拆分为可中断的小任务。浏览器每帧约有 16.6ms(60fps),React 不再一次性构建整棵虚拟 DOM 树,而是在每个时间片内只执行少量工作单元,随后检查是否有更高优先级的任务需要处理。

// React Scheduler 的核心时间片逻辑(概念版)
const FRAME_YIELD_MS = 5; // 每帧留给 React 的工作时长

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

function shouldYield() {
  // 检查是否已超出当前时间片预算
  return getCurrentTime() >= deadline;
}

shouldYield() 返回 true 时,React 立即保存当前执行位置(当前正在处理的 Fiber 节点),将主线程交还给浏览器执行布局、绘制和用户事件响应。下一次调度时,直接从保存的位置继续。这种机制正是并发渲染的基石。


三、Work Loop:beginWork 与 completeWork

React 的渲染分为两个阶段(Phase):

  • Render Phase(可中断):构建 Fiber 树,计算新的 state 和 props,标记副作用。此阶段可以被打断和重新执行。
  • Commit Phase(不可中断):将 Render Phase 计算的副作用应用到真实 DOM,执行生命周期和 effect。此阶段必须同步一次性完成。

3.1 beginWork —— 向下遍历

beginWork 接收当前 Fiber 节点,根据组件类型执行不同的处理逻辑,并返回下一个需要处理的子 Fiber 节点:

function beginWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case FunctionComponent: {
      const Component = workInProgress.type;
      const resolvedProps = workInProgress.pendingProps;
      // 执行函数组件,获取子树
      const children = Component(resolvedProps);
      // 协调(reconcile)子节点
      reconcileChildren(current, workInProgress, children, renderLanes);
      return workInProgress.child;
    }
    case ClassComponent: {
      const instance = workInProgress.stateNode;
      const children = instance.render();
      reconcileChildren(current, workInProgress, children, renderLanes);
      return workInProgress.child;
    }
    case HostComponent: {
      // div、span 等原生 DOM 节点
      reconcileChildren(current, workInProgress, workInProgress.pendingProps.children, renderLanes);
      return workInProgress.child;
    }
    case HostText: {
      // 纯文本节点,无子节点,返回 null 触发回溯
      return null;
    }
    // ... 其他组件类型
  }
}

3.2 completeWork —— 向上回溯

beginWork 返回 null(到达叶子节点)时,触发 completeWork。在这个阶段,React 完成节点属性的计算,并为 HostComponent 创建或更新真实的 DOM 节点:

function completeWork(current, workInProgress, renderLanes) {
  switch (workInProgress.tag) {
    case HostComponent: {
      const type = workInProgress.type;
      const newProps = workInProgress.pendingProps;

      if (current !== null && workInProgress.stateNode !== null) {
        // 更新现有 DOM 节点
        updateHostComponent(current, workInProgress, type, newProps);
      } else {
        // 创建新 DOM 节点
        const instance = document.createElement(type);
        // 将子树挂接到新节点上
        appendAllChildren(instance, workInProgress, false);
        workInProgress.stateNode = instance;
      }
      return null;
    }
    case FunctionComponent: {
      // 函数组件无 DOM 节点,将副作用标记冒泡给父节点
      bubbleProperties(workInProgress);
      return null;
    }
  }
}

Render Phase 完成后,React 进入 Commit Phase,一次性将所有 DOM 变更写入页面,确保用户不会看到中间态。


四、Lane 优先级模型:31 条优先级车道

在 React 18 之前,更新优先级使用 expirationTime 模型,其本质是为每个更新分配一个到期时间戳,用时间数值的大小表示优先级高低。这个模型存在语义不够精确、无法批量处理同优先级更新的问题。React 18 引入了 Lane 模型,用 31 位二进制数的位运算来管理优先级,更加高效且语义清晰。

4.1 Lanes 的定义

// 31 个优先级车道(部分核心定义)
const TotalLanes = 31;

export const NoLanes = 0b0000000000000000000000000000000;
export const NoLane = 0b0000000000000000000000000000000;

export const SyncLane = 0b0000000000000000000000000000001;        // 同步更新(useLayoutEffect 触发)
export const InputContinuousHydrationLane = 0b0000000000000000000000000000010;
export const InputContinuousLane = 0b0000000000000000000000000000100;  // 连续输入事件
export const DefaultHydrationLane = 0b0000000000000000000000000001000;
export const DefaultLane = 0b0000000000000000000000000010000;        // 默认更新
export const TransitionHydrationLane = 0b0000000000000000000000000100000;
export const TransitionLane1 = 0b0000000000000000000000001000000;
export const TransitionLane2 = 0b0000000000000000000000010000000;
// ... TransitionLane3 ~ TransitionLane15
export const RetryLane = 0b0001000000000000000000000000000;           // Suspense 重试
export const SelectiveHydrationLane = 0b0010000000000000000000000000000;
export const IdleHydrationLane = 0b0100000000000000000000000000000;
export const IdleLane = 0b1000000000000000000000000000000;             // 空闲时执行

每个更新被分配到一个或多个 Lane 上。由于使用位掩码,合并多个 Lane 只需按位或运算,检查交集只需按位与运算,性能极高。

4.2 批量更新与过渡通道

import { useState, startTransition } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleInput = (e) => {
    const value = e.target.value;
    setQuery(value); // 分配到 SyncLane,立即更新输入框

    // startTransition 将此更新分配到 TransitionLane
    startTransition(() => {
      setResults(search(value)); // 可能被中断,优先保证输入框流畅
    });
  };

  return (
    <div>
      <input value={query} onChange={handleInput} />
      <ResultsList data={results} />
    </div>
  );
}

在 Lane 模型中,setQuery 进入高优先级的 SyncLane,setResults 进入低优先级的 TransitionLane。当 React 调度更新时,如果用户继续输入,TransitionLane 上的更新会被 SyncLane 的更新打断,重新从更高优先级开始渲染。这确保了用户交互永远不卡顿


五、Suspense 与边界:Promise 如何中断渲染

Suspense 不是单纯的加载占位符,它依赖于 React 内部对异常(其实是 Promise)的捕获机制。当一个组件在 Render Phase 中需要异步数据但数据尚未就绪时,它可以直接抛出一个 Promise。React 在渲染过程中捕获到这个 Promise 后,会暂停当前子树的渲染,并在该 Promise resolve 后恢复。

5.1 Suspense 的基本使用

import { Suspense } from 'react';

function UserProfile({ userId }) {
  // 读取缓存中的用户数据,若未就绪则直接 throw promise
  const user = readUser(userId);
  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <UserProfile userId={123} />
    </Suspense>
  );
}

5.2 内部实现原理

React 为每个 Fiber 节点维护一个 suspenseState。当子树中抛出的 Promise 被 Suspense 边界捕获时,React 会将该边界以下的渲染标记为暂停状态:

// 捕获和处理 Promise 的核心逻辑(概念版)
function tryRender(root) {
  try {
    workLoop();
  } catch (thrownValue) {
    if (thrownValue !== null && typeof thrownValue.then === 'function') {
      // 捕获到 Promise,这是一个 Suspend
      const wakeable = thrownValue;
      const suspenseBoundary = findNearestSuspenseBoundary(workInProgress);

      // 将边界标记为需要显示 fallback
      suspenseBoundary.flags |= DidCapture;
      suspenseBoundary.memoizedState = {
        dehydrated: null,
        retryLane: DefaultLane,
      };

      // 注册 Promise 完成后的回调
      attachRetryListener(suspenseBoundary, wakeable);
      throwException(root, returnFiber, workInProgress, thrownValue, renderLanes);
      return;
    }
    throw thrownValue; // 真正的异常,继续向上抛出
  }
}

Promise 的 resolve 会触发一个高优先级的重试更新,React 重新进入渲染流程,此时缓存命中,正常完成渲染。整个过程开发者无感知,但底层经历了完整的"渲染—暂停—恢复"生命周期。


六、useTransition 与 useDeferredValue:保持响应性

useTransitionuseDeferredValue 是 React 18 提供的两个并发钩子,它们都依赖 Transition Lane 的低优先级特性。

6.1 useTransition

useTransition 返回一个 isPending 标志和一个 startTransition 函数,用于标记那些可以被延迟的 UI 更新:

import { useTransition, useState } from 'react';

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  const switchTab = (newTab) => {
    startTransition(() => {
      setTab(newTab); // 低优先级,允许被交互打断
    });
  };

  return (
    <div>
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <TabContent tab={tab} />
      </div>
      <button onClick={() => switchTab('home')}>Home</button>
      <button onClick={() => switchTab('settings')}>Settings</button>
    </div>
  );
}

当用户点击按钮切换标签时,旧标签的卸载和新标签的渲染被分配到低优先级的 TransitionLane。如果用户在这期间点击了其他交互元素,这些交互会立即获得高优先级,中断标签的切换渲染。

6.2 useDeferredValue

useDeferredValue 适用于那些由 props 驱动的重渲染场景。它创建一个延迟版本的值,在并发渲染中,原始值会先保证 UI 响应,延迟值在后台慢慢更新:

import { useDeferredValue, memo } from 'react';

const SlowList = memo(function SlowList({ text }) {
  const items = []
  for (let i = 0; i < 250; i++) {
    items.push(<SlowItem key={i} text={text} />);
  }
  return <ul>{items}</ul>;
});

function App() {
  const [text, setText] = useState('');
  const deferredText = useDeferredValue(text); // 延迟值
  const isStale = text !== deferredText;       // 是否仍在处理旧值

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <SlowList text={deferredText} />
      {isStale && <div style={{ opacity: 0.5 }}>Loading...</div>}
    </>
  );
}

在这个例子中,用户输入时 text 立即更新,但 deferredText 的更新会被延后。如果 SlowList 渲染耗时较长,React 可以中断它并优先处理新的输入更新,避免输入框卡顿。


七、双缓冲:current 树与 workInProgress 树

React 采用**双缓冲(Double Buffering)**策略,始终维护两棵 Fiber 树:

  • current 树:当前屏幕上正在展示的 UI 对应的 Fiber 树,每个 Fiber 节点的 alternate 指向 workInProgress 树的对应节点。
  • workInProgress 树:正在构建中的新 Fiber 树, Render Phase 中的所有修改都发生在这棵树上。
// current 与 workInProgress 的交替切换
function createWorkInProgress(current, pendingProps) {
  let workInProgress = current.alternate;

  if (workInProgress === null) {
    // 首次渲染,创建新的 workInProgress 节点
    workInProgress = createFiber(current.tag, pendingProps, current.key, current.mode);
    workInProgress.elementType = current.elementType;
    workInProgress.type = current.type;
    workInProgress.stateNode = current.stateNode;
    workInProgress.alternate = current;
    current.alternate = workInProgress;
  } else {
    // 复用已有的 alternate 节点
    workInProgress.pendingProps = pendingProps;
    workInProgress.flags = NoFlags;
    workInProgress.lanes = NoLanes;
  }

  // 复制必要属性
  workInProgress.child = current.child;
  workInProgress.memoizedState = current.memoizedState;
  workInProgress.memoizedProps = current.memoizedProps;

  return workInProgress;
}

Render Phase 完成后,workInProgress 树已经完整构建。Commit Phase 中,React 只需将 root.current 指针从 current 树切换到 workInProgress 树,即可完成 UI 更新的原子切换:

function commitRoot(root) {
  const finishedWork = root.finishedWork;

  // 切换根节点的 current 指针
  root.current = finishedWork;

  // 执行 DOM 操作和 effect
  commitMutationEffects(root, finishedWork);
  commitLayoutEffects(finishedWork, root);
}

双缓冲的设计保证了用户在 Render Phase 中永远不会看到中间状态。即使渲染被多次中断和重启,屏幕上始终呈现完整且一致的 current 树。


八、React Scheduler:统一调度层

React 自己实现了一个独立于框架的 Scheduler 包,用于管理所有异步任务的优先级和执行时机。它既可以作为 requestIdleCallback 的 polyfill,也提供了 MessageChannel 为基础的高精度调度能力。

8.1 调度队列

Scheduler 维护一个基于最小堆实现的优先级队列,任务按过期时间排序,最早过期的任务最先执行:

// Scheduler 任务优先级定义
export const NoPriority = 0;
export const ImmediatePriority = 1;     // 同步,立即执行
export const UserBlockingPriority = 2;  // 用户交互(点击、输入)
export const NormalPriority = 3;        // 普通异步更新
export const LowPriority = 4;           // 低优先级
export const IdlePriority = 5;          // 空闲时才执行

function unstable_scheduleCallback(priorityLevel, callback, options) {
  const currentTime = getCurrentTime();
  const startTime = options?.delay ? currentTime + options.delay : currentTime;

  // 根据优先级计算过期时间
  let timeout;
  switch (priorityLevel) {
    case ImmediatePriority: timeout = -1; break;
    case UserBlockingPriority: timeout = 250; break;
    case NormalPriority: timeout = 5000; break;
    case LowPriority: timeout = 10000; break;
    case IdlePriority: timeout = -1; break;
  }

  const expirationTime = startTime + timeout;

  const newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  };

  push(timerQueue, newTask);  // 先进入定时队列
  requestHostCallback(flushWork); // 触发调度

  return newTask;
}

8.2 时间切片实现

Scheduler 不直接依赖 requestIdleCallback,因为该 API 存在调用频率低和浏览器兼容性不足的问题。React 使用 MessageChannel 实现了一个微调的循环:

const channel = new MessageChannel();
const port = channel.port2;

// 注册回调
channel.port1.onmessage = performWorkUntilDeadline;

function requestHostCallback(callback) {
  scheduledHostCallback = callback;
  port.postMessage(null); // 触发宏任务
}

function performWorkUntilDeadline() {
  const currentTime = getCurrentTime();
  startTime = currentTime;

  // 持续执行任务直到时间片用完
  const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);

  if (hasMoreWork) {
    // 还有任务,立即安排下一次回调
    port.postMessage(null);
  }
}

MessageChannel 的回调在宏任务队列中执行,不会阻塞微任务和用户事件,同时相比 setTimeout 有更高的精度和更低的延迟。


结语

React 并发模式的每一项功能——Suspense、useTransition、DeferredValue——都不是孤立的 API,而是建立在 Fiber 架构、Lane 优先级模型和 Scheduler 调度层之上的统一体系。Fiber 的可中断链表遍历让时间切片成为可能,Lane 的位运算让优先级比较和合并变得高效,双缓冲保证了原子化的 UI 切换,而 Suspense 的 Promise 抛出机制则将异步数据加载无缝集成到渲染循环中。

理解这些底层原理,不仅有助于写出更高效的 React 应用,也能在遇到性能瓶颈时,准确判断问题究竟是出在渲染层、调度层还是数据层,从而做出有针对性的优化。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. 前端 CI/CD 最佳实践:从代码提交到自动发布
  2. 前端 Bundle 分析与优化:从体积到执行时长的全链路
  3. 从 Webpack 到 Vite:迁移策略与原理对比