每次面试被问到"React Fiber 是什么",我都能说出"可中断渲染"这几个字,但一旦被追问"那它怎么中断?中断之后做什么?"就开始语塞。
最近一次深挖让我意识到,Fiber 不只是一个优化方案,它几乎重构了 React 的整个运行模型。而 Hooks 的很多"奇怪规则",其实也直接源于 Fiber 的数据结构设计。这篇文章是我梳理这部分知识的过程记录,希望对你也有帮助。
问题的起源:React 15 出了什么问题?
在 React 16 之前,组件树的更新是同步递归完成的。浏览器是单线程的,JS 执行和页面渲染共用一个线程,一旦组件树足够大,一次 setState 可能会让 JS 独占线程几百毫秒,用户的点击、滚动、输入全部无响应。
这就是所谓的长任务卡顿。问题的本质不是"计算量太大",而是"计算无法被打断"——哪怕只差一帧(16ms),用户也能感知到掉帧。
React 团队的解法是:把同步不可中断的递归,改成可中断的增量渲染。这就是 Fiber 要解决的核心问题。
Fiber 是什么:一个重新定义的数据结构
Fiber 这个词本身来自操作系统里的"纤程"概念,是比线程更轻量的执行单元。在 React 里,每一个组件实例对应一个 Fiber 节点,整棵组件树被构建成一个 Fiber 链表树。
每个 Fiber 节点大致长这样:
// 环境:React 内部(简化版,非真实源码)
// 场景:理解 Fiber 节点的数据结构
const fiber = {
// 组件类型(函数组件 / 类组件 / DOM 元素等)
type: MyComponent,
// 指向父节点
return: parentFiber,
// 指向第一个子节点
child: childFiber,
// 指向兄弟节点(关键:用链表代替递归调用栈)
sibling: siblingFiber,
// 该节点需要做的操作(新增 / 更新 / 删除)
flags: Placement | Update,
// 对应的真实 DOM 节点
stateNode: domNode,
// Hooks 链表的头节点(函数组件)
memoizedState: hookLinkedList,
// 本次更新的 props
pendingProps: newProps,
// 上次渲染的 props
memoizedProps: oldProps,
// 任务优先级
lanes: SomeLane,
};
注意 child 和 sibling 这两个指针——这是 Fiber 能"可中断"的关键。传统递归是靠调用栈来记录"我走到哪了",一旦中断,调用栈就丢失了。而 Fiber 用链表指针代替了调用栈,中断后只需要记住"当前处理到哪个 Fiber 节点",下次可以从这里继续。
Fiber 的两个阶段:Render 和 Commit
理解 Fiber 的工作流程,需要先知道它分为两个完全不同性质的阶段。
Render 阶段(可中断)
这个阶段 React 会遍历 Fiber 树,对每个节点执行 diff,计算出"哪些节点需要新增、更新、删除",最终生成一棵带有变更标记的新 Fiber 树(称为 workInProgress 树)。
这个阶段是纯计算,不接触真实 DOM,所以可以被随时中断和恢复。React 通过 requestIdleCallback(实际上是自己实现的 scheduler 调度器)来控制每次计算的时间片,大约 5ms 一个时间片,到期就暂停,把控制权还给浏览器。
Commit 阶段(不可中断)
当 Render 阶段完成后,React 拿到变更列表,一次性同步地把所有 DOM 操作执行完。这个阶段不能被打断——想象一下如果 DOM 更新到一半被中断,用户会看到一个撕裂的界面。
这也解释了为什么 useLayoutEffect 的执行时机在 Commit 阶段的 DOM 更新之后、浏览器绘制之前——它是同步阻塞的,适合需要读取 DOM 布局信息的场景。
双缓冲树:current 和 workInProgress
React 在内存中同时维护两棵 Fiber 树:
- current 树:当前屏幕上正在显示的状态
- workInProgress 树:正在后台构建的下一帧状态
// 环境:React 内部(概念示意)
// 场景:双缓冲机制
// 每个 Fiber 节点都有一个 alternate 指针,指向另一棵树的对应节点
currentFiber.alternate === workInProgressFiber; // true
workInProgressFiber.alternate === currentFiber; // true
// Commit 完成后,两棵树互换身份
// workInProgress 变成新的 current
// 原来的 current 成为下次更新的 workInProgress(复用节点,减少内存分配)
这个设计的好处是:即使 workInProgress 树在构建过程中被中断或出错,current 树依然完整,用户看到的界面不受影响。
Hooks 为什么必须按顺序调用?
这应该是面试里最高频的 Hooks 原理问题了。
每个函数组件对应的 Fiber 节点上,有一个 memoizedState 字段,它指向一条单向链表,链表的每个节点对应一个 Hook 的状态。
// 环境:React 内部(简化)
// 场景:Hooks 链表的结构
// 假设组件有这些 Hooks:
function MyComponent() {
const [count, setCount] = useState(0); // Hook 1
const [name, setName] = useState(''); // Hook 2
useEffect(() => {}, [count]); // Hook 3
}
// 对应的 memoizedState 链表:
// Hook1 { memoizedState: 0, next: Hook2 }
// --> Hook2 { memoizedState: '', next: Hook3 }
// --> Hook3 { memoizedState: { deps: [0] }, next: null }
React 没有给每个 Hook 起名字,它依赖调用顺序来找到对应的 Hook 节点。第一次渲染时按顺序创建链表,后续渲染时按顺序遍历链表取值。
如果在条件语句里调用 Hook,某次渲染可能跳过了 Hook 2,那么 React 取到的"第 2 个节点"实际上是原来的 Hook 3,状态就完全乱掉了。
// 环境:浏览器(React)
// 场景:错误示范——条件 Hook 导致状态错乱
function BadComponent({ show }) {
const [count, setCount] = useState(0);
// ❌ 不能这样做:条件渲染导致 Hook 顺序不稳定
if (show) {
const [name, setName] = useState('');
}
useEffect(() => {
// 当 show 从 true 变为 false,
// React 以为这是 Hook 2,但实际是 useEffect
// 链表对应关系错误,行为不可预测
}, []);
}
这不是 React 的限制,而是这种数据结构设计的必然结果。我的理解是:React 用了最简单的数据结构(链表)换取了最小的内存开销和最快的遍历速度,代价是开发者必须遵守调用顺序的规则。
useEffect vs useLayoutEffect:执行时机的差异
这两个 Hook 的区别常被简化成"同步 vs 异步",但理解执行时机更有价值。
// 环境:浏览器(React)
// 场景:理解两者执行时序
function TimingDemo() {
useLayoutEffect(() => {
// Commit 阶段:DOM 已更新,浏览器还没绘制
// 在这里读取 DOM 尺寸是安全的(不会触发额外的重排)
const height = ref.current.getBoundingClientRect().height;
});
useEffect(() => {
// 浏览器绘制完成后异步执行
// 适合:数据请求、事件订阅、埋点上报等副作用
fetch('/api/data').then(setData);
return () => {
// 清理函数:组件卸载或依赖变化时执行
subscription.unsubscribe();
};
}, [dependency]);
}
一个经验性的判断:如果副作用需要读取或修改 DOM 尺寸和位置,用 useLayoutEffect;其他情况默认用 useEffect。
useMemo 和 useCallback:它们在缓存什么?
这两个 Hook 的误区挺多的,常见的误解是"只要用了就能提升性能"。
// 环境:浏览器(React)
// 场景:理解 useMemo 和 useCallback 的本质
function ParentComponent({ list }) {
// useMemo:缓存计算结果
// 只有 list 变化时才重新计算,否则返回上次的结果
const sortedList = useMemo(() => {
return [...list].sort((a, b) => a.value - b.value);
}, [list]);
// useCallback:缓存函数引用
// 本质是 useMemo 的语法糖:useMemo(() => fn, deps)
// 如果不缓存,每次渲染都会创建新的函数引用
// 传给子组件时会导致子组件不必要的重渲染
const handleClick = useCallback((id) => {
onSelect(id);
}, [onSelect]);
return <ChildComponent list={sortedList} onClick={handleClick} />;
}
但这里有个容易忽视的前提:useCallback 配合 React.memo 才能发挥作用。如果子组件没有用 memo 包裹,父组件重渲染时子组件必然重渲染,useCallback 缓存函数引用就没有意义了。
我对这两个 Hook 的使用原则是:不要无脑加,先确认是否真的有性能问题,再考虑是否需要缓存。过度使用反而会增加内存压力和代码复杂度。
延伸与发散
研究这些内容时,产生了一些新的疑问,还没完全想清楚:
关于优先级调度:Fiber 引入了 Lane 模型来区分任务优先级(用户交互 > 普通更新 > 后台任务)。这意味着一个低优先级的更新可能被高优先级的更新"插队",也就是 Concurrent Mode 的核心能力。但这在某些场景下会导致"状态撕裂"问题,useSyncExternalStore 就是为了解决这个问题而引入的——这块还需要深入理解。
关于 React Server Components:RSC 在服务端生成 Fiber 树的序列化描述,发送给客户端"注水"。这和传统 SSR 的差异在哪里?Fiber 的序列化边界怎么处理?这是我下一步想弄清楚的问题。
关于 Hooks 的依赖数组:useEffect 的依赖数组是浅比较,对于对象和数组,很容易出现"每次渲染都是新引用"的问题,导致 effect 无限触发。这让我想到:在大型业务系统里,依赖管理的复杂度可能不亚于状态管理本身。
小结
回头看,Fiber 解决的核心问题其实只有一个:让浏览器有机会在 JS 计算的间隙处理高优先级任务。但为了实现这一点,React 重新设计了组件树的数据结构(链表代替调用栈)、引入了双缓冲机制、建立了任务优先级调度系统。
Hooks 建立在这套数据结构之上,它的每一条规则都不是任意规定的,而是链表结构带来的直接约束。
这篇文章是我的理解整理,不保证每个细节都准确。如果你发现有出入或有更好的解释角度,欢迎交流。
参考资料
- React Fiber Architecture(acdlite) - React 核心团队成员对 Fiber 的早期设计说明
- React 官方文档 - Hooks Rules - Hooks 规则的官方解释
- React 源码 - ReactFiber.js - Fiber 节点的真实数据结构定义
- Build your own React(pomb.us) - 从零实现一个简版 React,帮助理解 Fiber 工作流程