有个后台项目的表格页,一屏能渲染出七八百个单元格,每个单元格里还挂着状态标签和操作按钮。点一下筛选条件,页面会明显卡一下,卡住的那两三百毫秒里鼠标点什么都没反应,连输入框的光标都不闪。用 Chrome 的 Performance 面板录下来看,就是一根几百毫秒的黄条,一个 Task 里全是 React 的递归调用。
这类卡顿不是「渲染了太多次」,而是「这一次渲染太长,中间谁也打断不了」。React 16 引入的 Fiber 架构,改的正是这件事。这篇不铺 API,只把 Fiber 的数据结构、遍历方式和两阶段拆分讲清楚,读完你应该能自己回答一个问题:为什么换了个数据结构,渲染就能被打断了。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- React Fiber 是什么,它解决的到底是哪一类卡顿
- React 15 的组件渲染顺序长什么样,为什么这个顺序停不下来
- Fiber 节点上的三根指针,以及递归改循环之后多出来的能力
- render 阶段和 commit 阶段的分界线,为什么一个能断一个不能断
- 「render 之前的生命周期可能执行多次」这句话的完整推导
- Fiber 没有解决的问题,以及 2018 年之后 React 又补了什么
一、React Fiber 是什么
React Fiber 是对协调(Reconciliation)核心算法的一次重新实现。它把更新过程碎片化,把一个耗时长的任务分成很多小片,每一个小片的运行时间很短,虽然总时间依然很长,但是在每个小片执行完之后,都给其他任务一个执行的机会,这样唯一的线程就不会被独占,其他任务依然有运行的机会。
围绕这句话,有四条推论值得先摆出来。
在 React Fiber 中,一次更新过程会分成多个分片完成,所以完全有可能一个更新任务还没有完成,就被另一个更高优先级的更新过程打断,这时候优先级高的更新任务会优先处理完,而低优先级更新任务所做的工作则会完全作废,然后等待机会重头再来。
因为一个更新过程可能被打断,所以 React Fiber 一个更新过程被分为两个阶段(Phase):第一个阶段 Reconciliation Phase 和第二阶段 Commit Phase。
在第一阶段 Reconciliation Phase,React Fiber 会找出需要更新哪些 DOM,这个阶段是可以被打断的;但是到了第二阶段 Commit Phase,那就一鼓作气把 DOM 更新完,绝不会被打断。
这两个阶段大部分工作都是 React Fiber 自己做,和我们相关的也就是生命周期函数。
Fiber 改变了之前 React 的组件渲染机制,新的架构使原来同步渲染的组件现在可以异步化,可中途中断渲染,执行更高优先级的任务,释放浏览器主线程。
关键特性有这么几条:
- 增量渲染(把渲染任务拆分成块,匀到多帧)
- 更新时能够暂停、终止、复用渲染任务
- 给不同类型的更新赋予优先级
- 并发方面新的基础能力
增量渲染用来解决掉帧的问题,渲染任务拆分之后,每次只做一小段,做完一段就把时间控制权交还给主线程,而不像之前那样长时间占用。
这里得先把一个常见误解摆平。Fiber 不会让你的组件渲染得更快。
七百个单元格该花的 CPU 时间一点都没少,Fiber 做的只是把这段时间切成一小截一小截,让浏览器在缝隙里有机会响应点击、重绘画面。用户感知到的是「不卡了」,实际总耗时甚至因为调度开销还会略微变长。这条边界搞清楚了,后面很多设计动机才讲得通。
二、React 15 的组件渲染顺序
假如有 A、B、C、D 四个组件,层级结构为:

我们知道组件的生命周期为:
挂载阶段:
constructor()componentWillMount()render()componentDidMount()
更新阶段为:
componentWillReceiveProps()shouldComponentUpdate()componentWillUpdate()render()componentDidUpdate()
那么在挂载阶段,A、B、C、D 的生命周期渲染顺序是如何的呢?

以 render() 函数为分界线,从顶层组件开始一直往下,直至最底层子组件,然后再往上。组件 update 阶段同理。
这张图值得多看两眼。render 之前的那些钩子是「进」的方向,从父到子一路下钻;render 之后的那些钩子是「出」的方向,从最深的子组件一路往上冒。这个形状和递归函数的调用栈是完全对应的,因为 React 15 的协调就是靠递归实现的:父组件调子组件,子组件再调孙组件,整个进度压在 JS 的调用栈上。
问题就出在这里。
调用栈这个东西有个硬性特点,你没法在中间「存档退出」。函数一旦 return,局部变量、循环下标、当前处理到哪个子节点,全都跟着栈帧一起销毁了。想要中断再恢复,就得把这些进度全部搬到栈外面去存着,而递归写法做不到这一点。
前面是 React 16 以前的组件渲染方式,这就存在一个很实际的后果:如果这是一个很大、层级很深的组件,React 渲染它需要几十甚至几百毫秒,在这期间 React 会一直占用浏览器主线程,任何其他的操作(包括用户的点击、鼠标移动等操作)都无法执行。
Fiber 架构就是为了解决这个问题。
三、Fiber 的数据结构改了什么
先说结论:Fiber 做的核心改动,是把「树的递归遍历」改写成「链表的循环遍历」,把进度从调用栈里挪到了堆上的一个变量里。
每个 Fiber 节点上挂着三根指针:
return指向父节点(叫 return 是因为它对应递归里「返回到哪」)child指向第一个子节点sibling指向下一个兄弟节点
有了这三根指针,原来需要递归才能表达的树形结构,现在可以用一个 while 循环走完。当前走到哪了,用一个全局变量记着,React 源码里叫 workInProgress。
// 简化后的工作循环 |
看这个循环条件就明白了。shouldYield() 返回 true 时跳出 while,此刻 workInProgress 还老老实实指着下一个要处理的节点,下次调度进来接着从这里跑就行。同步版本的循环条件里没有 shouldYield(),只有 workInProgress !== null,那就是一路跑到底。
差别就这么一个判断,但它成立的前提是进度存在变量里,而不是存在调用栈里。这就是「可中断」的物理基础。
顺着上面聊,还有一个配套设计叫双缓存。React 同时维护两棵 Fiber 树,一棵是 current 树(当前屏幕上正在显示的),另一棵是 workInProgress 树(正在构建的)。中途放弃 workInProgress 树完全不影响屏幕,因为屏幕上挂的一直是 current。只有新树构建完整、进入 commit 的那一刻,才会把 current 指针切过去。
原文里提到的说法是,React 在 workingProgressTree(并不是真实的 virtualDomTree)上复用 current 上的 Fiber 数据结构来一步步构建新的 tree,标记出需要更新的节点,放入队列中。这个描述是对的,只是名字在源码里写作 workInProgress。
这里有个坑要注意,也是当年这批文章里普遍写错的一处。很多资料(包括这篇的初版)都说 Fiber 是通过 requestIdleCallback 来分片的。React 团队确实评估过这个 API,但最后没有采用,实际实现是在 scheduler 包里基于 MessageChannel 自己造了一个调度器。原因主要两条:requestIdleCallback 兼容性不够(Safari 长期缺席),而且触发得不够积极,浏览器可能连续几帧都不给回调。这块的完整推导我在 React 18 并发机制深度解析 里展开写过,包括 5ms 时间片是怎么定出来的。
四、两个阶段与它们的分界线
加入 Fiber 的 React 把组件更新分为两个时期,这两个时期以 render 为分界:
render前的生命周期为 phase1render后的生命周期为 phase2
phase1 的生命周期是可以被打断的,每隔一段时间它会跳出当前渲染进程,去确定是否有其他更重要的任务。此过程中 React 在 workInProgress 树上复用 current 上的 Fiber 数据结构,一步步构建新的树,标记出需要更新的节点,放入队列中。
phase2 的生命周期是不可被打断的,React 将其所有的变更一次性更新到 DOM 上。
为什么 phase2 不能断?你想想看,commit 阶段做的是真实 DOM 操作,插入、删除、改属性。这些操作一旦做了一半就让出主线程,浏览器立刻就会把这个中间态画出来,用户看到的是一个画到一半的界面,可能是列表少了半截,也可能是新旧数据混在一起。所以 commit 必须一次性同步跑完,哪怕它会造成一次短暂的卡顿。
记住这条分界线,后面很多行为都能从这里推出来:render 可中断,commit 不可中断。
4.1 phase1 的机制,为什么它是重点
这里最重要的是 phase1 这个时期所做的事,所以我们需要具体了解 phase1 的机制。
如果不被打断,那么 phase1 执行完会直接进入 render 函数,构建真实的 virtualDomTree。
如果组件在 phase1 过程中被打断,即当前组件只渲染到一半(也许是在 componentWillMount,也许是 componentWillUpdate,反正是在 render 之前的生命周期),那么 React 会怎么干呢?React 会放弃当前组件所有干到一半的事情,去做更高优先级更重要的任务(当然,也可能是用户鼠标移动,或者其他 React 监听之外的任务),当所有高优先级任务执行完之后,React 通过回调回到之前渲染到一半的组件,从头开始渲染。
看起来放弃已经渲染完的生命周期会有点不合理,反而会增加渲染时长,但 React 确实是这么干的。
我一开始也是这么想的,觉得应该做个断点续传,从被打断的那个钩子往后接着跑。后来想明白了:要「续」就得把每个钩子执行到一半的中间状态全存下来,这套记账的开销和复杂度远比重跑一遍高,而且 phase1 的钩子本来就该是廉价的纯计算。丢掉重来反而是更简单也更划算的选择。
所以就有了那句关键的结论。
所有 phase1 的生命周期函数都可能被执行多次,因为可能会被打断重来。
这样的话,就和 React 16 版本之前有很大区别了。因为可能会被执行多次,那么我们最好就得保证 phase1 的生命周期每一次执行的结果都是一样的,否则就会有问题,因此最好都是纯函数。
「最好都是纯函数」这句话落到实处是什么意思?如果你在 componentWillMount 里发了一个 AJAX 请求,这个请求可能被发好几次;如果你在 componentWillReceiveProps 里做了埋点上报,这个点可能被重复上报;如果你在里面改了一个模块级的计数器,这个数字就完全不可信了。这几类问题在 React 16、17 里其实很难被发现,因为那两个版本并没有真正开启可中断渲染,Fiber 只是把地基打好了。
React 16.3 那次生命周期大改动,起因就是这条。旧的 componentWillMount、componentWillReceiveProps、componentWillUpdate 被打上不安全的标记,取而代之的是一个静态的、拿不到 this 的 getDerivedStateFromProps,强制你在 render 之前只做无副作用的计算。新旧生命周期的完整对照和废弃理由,我在 React 16.3 新的生命周期详解 里单独写了一篇。
五、Fiber 没解决的问题
原文最后提了一句很实在的话:如果高优先级的任务一直存在,那么低优先级的任务则永远无法进行,组件永远无法继续渲染。这个问题在 2018 年确实还没有一个完整的解法。
这就是调度里的饥饿(starvation)问题。React 后来的处理思路是给更新加一个「过期时间」,一个低优先级任务如果被插队太多次、等得太久,它就会被强行提升成同步任务一次性做完,宁可卡一下也不能永远不做。早期版本里这套机制叫 expirationTime,React 17 之后换成了 Lane 模型,用二进制位表示优先级,饥饿判断也跟着重做了一遍。具体实现细节我没有逐行读过新版源码,只能说思路是这个思路,准确的行为以官方文档和源码为准。
还有一点值得提。Facebook 在 React 16 增加 Fiber 结构,其实并不是为了减少组件的渲染时间,事实上也并不会减少。最重要的是现在可以使得一些更高优先级的任务(比如用户的操作)能够优先执行,提高用户的体验,至少用户不会感觉到卡顿。
这句话在今天依然成立,而且是判断「要不要上并发」的第一条标准。如果你的页面是总 CPU 时间超标(比如一次渲染要跑三秒),Fiber 和后来的并发特性救不了你,那种情况该上的是虚拟列表、按需渲染、把重计算挪到 Worker。并发治的是响应性,不是吞吐量。
六、2018 之后又补了什么
这篇写于 2018 年,那时候 React 是 16.x。Fiber 的地基虽然打好了,但可中断渲染在 16、17 里一直没有对外开放,你写的代码走的还是同步渲染路径。这也是当年很多人升级到 16 之后感觉「没什么变化」的原因。
真正把这套能力放出来的是 React 18。它引入了 createRoot,只有走这个入口创建的应用才会启用并发渲染,同时给了开发者 startTransition 和 useDeferredValue 两个手动标记低优先级更新的入口。Fiber 提供的是「能不能断」的能力,Lane 模型回答「先做谁」,调度器回答「什么时候停」,而 startTransition 回答「谁说了算」。这四层叠起来才是完整的并发渲染。
到 React 19,这套底层调度没有推倒重来,仍然是 Fiber 加 Lane,上层变化更多集中在 Actions、use、Server Components 这些方向。想看新版本带来的具体变化,可以顺着 React 19 新特性 这篇往下读。
所以今天再看 Fiber,它的定位是「后面所有并发能力的地基」,而不是一个你需要直接调用的东西。面试里问 Fiber,问的其实也是你懂不懂这层地基。
总结
Fiber 这件事拆开看,链条是很短的。
React 15 的协调是递归,进度存在调用栈里,栈帧一销毁进度就没了,所以中断不了。Fiber 给每个节点加上 return、child、sibling 三根指针,把树的遍历改写成 while 循环,进度存进 workInProgress 这个变量,中断之后能原地接着跑。双缓存保证了被丢弃的半成品树不会污染屏幕。这三件事凑齐,可中断渲染的物理条件就具备了。
然后是那条分界线。render 阶段可以断、可以重来、可以整棵丢掉,所以 render 之前的生命周期必须是纯的;commit 阶段操作真实 DOM,断了用户就会看到画到一半的界面,所以它必须同步跑完。React 16.3 废掉三个 componentWill* 钩子,理由完全来自这条分界线。
最后记住 Fiber 不减少总渲染时间。它把 300ms 的一整块,变成了 60 段 5ms 的小块,用户在缝隙里能点得动、看得到动画,仅此而已。搞清楚这条边界,你才不会在一个纯 CPU 超标的页面上盲目加 startTransition。