React 渲染机制与调度:Fiber、优先级与并发特性
要理解 React 为什么有时「感觉很卡」,为什么某些更新可以被打断,就得从它的渲染与调度机制说起,尤其是 Fiber 出现之后的那一套。
从「一口气渲染到底」到「可中断的渲染」
早期(Stack Reconciler 时代),React 渲染是这样的:
- 更新一触发,从根组件开始深度遍历;
- 一路递归到底,中间不能停;
- 这段时间里浏览器没法处理别的事情(如输入、滚动),就会感觉掉帧。
Fiber 的出现,核心目的只有一个:把这段递归拆碎,做成可以被打断、可以分片执行的工作单元。
可以粗略理解为:
- 把整个组件树拆成一棵 Fiber 树,每个节点代表一个「工作单元」;
- 遍历时可以先做一部分,发现「时间片快用完了」,就先停下来,让浏览器先去干别的事(如绘制、处理输入);
- 下次有空再从上次停下来的地方继续。
这就让 React 有机会在「不阻塞主线程太长时间」和「保持 UI 一致性」之间折中。
两个阶段:render 与 commit
React 的一次更新,大致可以分成两个阶段:
- render 阶段:计算「应该怎么变」,构建新的 Fiber 树,得出一组变更描述(effect list);
- commit 阶段:把这些变更真正提交到 DOM / 原生视图。
重要区别在于:
- render 阶段是可以被打断、重做的 —— 这是 Fiber 调度的主要战场;
- commit 阶段必须一次性执行完 —— DOM 更新需要保持一致性,不能半途而废。
可以类比为:
- render 阶段在做「草稿」:可以多次重画,直到满意;
- commit 阶段在「正式盖章」:一旦开始,就要一次做完。
优先级与任务调度
既然渲染工作可以被拆分,接下来要解决的问题就是:哪些工作应该先做,哪些可以稍后再说。
React 内部有一套优先级与调度系统,粗略来说会考虑:
- 用户输入相关的更新(例如输入框、点击)优先级更高;
- 不那么着急的更新(例如列表统计、日志)可以晚一点;
- 同一批更新尽量合并,减少重复工作。
在开发者可见的层面,能看到的一个入口是 startTransition:
1import { startTransition, useState } from 'react';
2
3function SearchPage() {
4 const [keyword, setKeyword] = useState('');
5 const [results, setResults] = useState<string[]>([]);
6
7 function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
8 const value = e.target.value;
9 setKeyword(value); // 高优先级:保证输入框跟手
10
11 startTransition(() => {
12 // 低优先级:允许稍微延迟渲染结果
13 setResults(expensiveSearch(value));
14 });
15 }
16
17 // ...
18}
可以把 startTransition 看成是对 React 说的一句话:「这块不需要立刻完成,先保证交互顺滑。」
并发特性:useTransition 与 useDeferredValue
useTransition:延后那部分「重的渲染」
useTransition 是在组件内使用「低优先级更新」的一种方式:
1const [isPending, startTransition] = useTransition();
2
3startTransition(() => {
4 // 这里的 setState 是低优先级的
5});
典型场景:
- 输入框要保证跟手;
- 输入同时触发一个较重的列表过滤/渲染;
- 用 transition 把「列表那一块更新」标记成低优先级,允许略微延迟。
在 UI 层面通常会配合 isPending 给出一点反馈(例如 loading skeleton,而不是整页卡住)。
useDeferredValue:延后那份「值的变化」
useDeferredValue 则是把某个值的变化「延后传播」:
1const [keyword, setKeyword] = useState('');
2const deferredKeyword = useDeferredValue(keyword);
用法上可以理解成:
keyword立刻变,用在轻量部分(例如输入框显示);deferredKeyword慢一点变,用在重渲染部分(例如列表过滤)。
在内部,useDeferredValue 也是用低优先级的更新来实现,只不过它更像是「对值做延迟抖动」。
重渲染与调度:几个容易混淆的点
「渲染频率高」不一定等于「卡顿」
在 Fiber 模型下,只要每一帧内的工作量可控,即便渲染很频繁,也可以保持流畅。
真正导致卡顿的,往往是:
- 某一批更新触发了大量组件重新渲染;
- 每个渲染里又做了比较重的计算或 DOM 操作;
- 加起来超过了一帧的预算时间(大致 16ms)。
所以优化的时候,一般会从这几件事入手:
- 减少不必要的重渲染(
React.memo、useMemo、useCallback、拆分 Context); - 把重计算移出渲染路径(例如交给 worker、延后到 effect);
- 用并发特性标记「不着急的那部分更新」。
「打断」不等于「丢掉」
当一个低优先级更新在 render 阶段被打断时,它并不是被丢弃了,而是会:
- 停在当前已经计算好的 Fiber 节点上;
- 之后有空时继续往下算;
- 或者被更新的更高优先级任务「覆盖」,重新从更高优先级路径开始。
最终提交到 DOM 的,仍然是某个时间点上一致的整棵视图树,不会出现「一半旧 UI 一半新 UI」的状态。
和日常开发的连接点
这套渲染与调度机制,在日常编码时不需要时刻挂在嘴边,但几个具体影响是可以直接感知到的:
- 不要在渲染函数里做重计算和副作用;
- 真正有性能压力的地方,再考虑用 memo、拆组件和并发特性;
- 需要「交互优先」的场景(搜索、过滤、复杂页面切换),可以考虑
startTransition/useTransition/useDeferredValue。
理解 Fiber 和优先级的基本思路之后,看 React DevTools 里的渲染时间线,会更容易判断:当前卡顿是「单次渲染太重」,还是「更新策略不合适」。