Fiber 架构与可中断渲染

第五编 · 第七章:Fiber 架构与可中断渲染的深入分析


从一个问题开始

你有没有遇到过这种情况:一个页面内容很多,React 渲染的时候整个页面会卡一下,而不是流畅地更新?

这种"卡"通常是因为 JavaScript 执行占用了主线程太久,导致浏览器无法及时响应用户的点击或滚动。React 16 之前的版本就有这个问题:当组件树很复杂时,一次状态更新可能触发一个很长的同步渲染任务,这个任务在完成之前会阻塞主线程,用户感觉到的就是页面卡死了。

React Fiber 解决的就是这个问题:把长的渲染任务拆成小块,让浏览器能在执行过程中插入其他高优先级任务(比如用户点击),然后再继续执行。

理解 Fiber,是理解 React 现代版本(React 16 及之后)所有特性的基础——没有 Fiber,Concurrent Mode、Suspense、useTransitionuseDeferredValue 这些都不存在。

Andrew Clark 的 React Fiber Architecture 是 React Fiber 设计的原始文档,React 团队成员参与审核。Dan Abramov 在 What's Next for React (ReactNext 2016) 中首次公开展示了 Fiber 的核心思路。


React 16 之前的渲染机制

在 React 15 及之前,组件树的渲染是这样的:一次 setState 触发整个组件树的递归调和(reconciliation),这个过程是同步的,不能中断。用 Andrew Clark 的话说:"In its current implementation React walks the tree recursively and calls render functions of the whole updated tree during a single tick."1

function App() {
  const [items, setItems] = useState([/* 1000 个复杂组件 */]);

  function handleAdd() {
    // 触发一次全量调和——整个组件树必须在这一帧内完成
    setItems([...items, newItem]);
  }
}

items 有 1000 个元素时,这次 setItems 会触发 1000 个组件的创建和比较。这个过程可能需要几十甚至上百毫秒,在这期间主线程被占用,用户点击页面上的按钮不会有响应。

这在移动端尤其明显,因为移动设备的 JavaScript 执行速度更慢,一次复杂的渲染可能让页面卡顿几百毫秒。


Fiber 的核心思想

Fiber 的核心思想是:把一个大的渲染任务拆成多个小的工作单元,每个工作单元执行完后,浏览器都可以检查是否有更高优先级的任务(比如用户输入)需要处理。

在 Fiber 架构里,每个 React Element 都会对应一个 Fiber 节点。Fiber 节点是一个 JavaScript 对象,包含比 React Element 更多的信息:

{
  // 基本属性
  type: 'div', // 或组件函数
  key: null,
  props: { children: [...] },
  stateNode: null, // 对应的 DOM 节点

  // Fiber 树结构
  child: fiberNode, // 第一个子节点
  sibling: fiberNode, // 下一个兄弟节点
  return: fiberNode, // 父节点

  // 调度信息
  lanes: 0b0001, // 优先级标记
  memoizedState: null, // 缓存的状态
  memoizedProps: null, // 缓存的 props

  // 副作用
  effectTag: 'UPDATE', // 标记需要执行的副作用类型
  nextEffect: null, // 下一个有副作用的节点
}

这个数据结构让 React 能以链表的形式遍历 Fiber 树,而不是像递归调和那样只能用调用栈。


工作循环

Fiber 的调度机制是通过一个"工作循环"来实现的:

function workLoop() {
  while (nextUnitOfWork !== null) {
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }
}

nextUnitOfWork 是下一个待处理的 Fiber 节点。performUnitOfWork 处理一个 Fiber 节点,可能返回下一个待处理的节点,也可能返回 null(表示这个分支处理完毕)。

关键在于:每个 performUnitOfWork 的调用都是一个小单元,调用完成后浏览器可以检查是否有更高优先级的任务需要处理

如果此时用户点击了页面,浏览器的渲染引擎会暂停当前的 JavaScript 执行,处理用户交互事件。高优先级任务完成后,workLoop 再继续从上次中断的地方恢复。

这就是"可中断渲染"的本质:不是因为 React 主动让出了线程,而是因为 React 把工作拆成了足够小的单元,浏览器能在这些单元之间插入其他任务。


Render Phase 和 Commit Phase

Fiber 把渲染过程分成了两个阶段:

Render Phase(可中断):React 会创建新的 Fiber 树,计算出哪些节点需要更新。这个阶段可以被打断和恢复,因为它只是"计算",还没有真正改变 DOM。

Commit Phase(不可中断):React 把计算出的变更一次性应用到 DOM 上。这个阶段必须同步执行,因为它会直接操作 DOM,打断会导致 DOM 处于不一致状态。

这就是为什么 useEffect 的副作用在 Commit Phase 才执行——Commit Phase 之后 DOM 已经稳定了,在这时执行副作用是安全的。而 useLayoutEffect 则是在 DOM 变更后立即同步执行,适用于需要在用户看到新 UI 之前完成一些同步操作的场景。


Lane 模型

Fiber 用 lane(车道)来标记更新的优先级。每个更新都会被分配一个或多个 lane,代表这个更新的紧急程度。关于优先级的设计,React 官方 Design Principles 文档有明确的阐述:React 采用的是"pull"模式而非"push"模式——React 会智能地延迟非紧急更新,避免掉帧2

const SyncLane = 0b0000000000000001;    // 同步优先级:用户点击、输入
const InputContinuousLane = 0b0000000000000100; // 连续输入:拖拽、滚动
const DefaultLane = 0b0000000000100000;  // 默认优先级:数据请求返回
const TransitionLane = 0b0000000001000000; // Transition 优先级

优先级高的更新(如用户点击)会打断优先级低的更新(如数据请求返回后的状态设置)。startTransition 就是把一个更新的 lane 标记为 TransitionLane,告诉 React 这个更新可以被打断。

lane 模型让 React 能在多个同时发生的更新里做出正确的调度决策:高优先级更新先处理,低优先级更新可以等待。


优先级如何影响用户体验

举一个具体例子来理解优先级:

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

  useEffect(() => {
    // 模拟一个慢的搜索请求
    const timeout = setTimeout(() => {
      setResults(search(query));
    }, 500);
    return () => clearTimeout(timeout);
  }, [query]);

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} />
      {/* 渲染 1000 个结果 */}
      {results.map(r => <ResultItem key={r.id} result={r} />)}
    </div>
  );
}

没有 useTransition 时,用户在 input 里快速输入,每次输入都会立即触发 setText,这个更新是 urgent 的,会打断正在进行的 setResults 更新,导致输入响应变慢。

有了 startTransition

function handleChange(e) {
  const value = e.target.value;
  setText(value); // urgent:输入框要立刻响应

  startTransition(() => {
    setQuery(value); // transition:搜索结果可以延迟
  });
}

setText 是 urgent 更新,会立即反映到输入框。setQuery 是 transition 更新,如果用户在结果渲染完成之前又输入了新字符,React 可以中断当前的 transition,先处理新的 urgent 更新。

这让输入保持流畅的同时,结果列表的更新也能在后台平滑进行。


Fiber 带来的新心智模型

理解 Fiber 之前,很多 React 的行为看起来像是"怪癖"或者"设计缺陷":

  • "为什么 setState 是异步的"——因为内部有批处理和调度机制
  • "为什么有时候 useEffect 会晚一拍才执行"——因为可能是 transition 更新,被更高优先级打断了
  • "为什么 useLayoutEffect 会阻塞渲染"——因为它同步执行,在 DOM 变更后立即运行

理解 Fiber 之后,这些都变成了"符合预期的行为"。

React 渲染不是简单的"状态变了 UI 就变",而是:状态变化 → 生成更新任务 → 分配 lane 优先级 → 进入工作循环 → 按优先级调度执行 → Commit Phase 应用 DOM 变更。这条链路上的每一步都可能受调度影响。


这一章想说的

Fiber 是 React 16 引入的核心架构改变,解决的是"长任务阻塞主线程"这个问题。

Fiber 把组件树的渲染拆成了可中断的小工作单元,每个单元执行完后浏览器可以处理更高优先级任务。Render Phase 计算变更,Commit Phase 应用变更,两者分开处理让 React 能在中间插入其他任务。

lane 模型给每个更新分配优先级,让 React 能区分紧急更新(用户点击、输入)和非紧急更新(数据加载后的 UI 渲染),在并发场景下保持界面响应性。

理解 Fiber 是理解 React 现代所有特性的前提——Concurrent Mode、Suspense、useTransitionuseDeferredValue,都是建立在 Fiber 的可中断渲染和 lane 优先级调度之上的。

Footnotes

  1. https://github.com/acdlite/react-fiber-architecture

  2. https://react.dev/learn/preserving-and-resetting-state