React的Fiber架构从React16之后开始了,都知道它的作用是什么。把虚拟DOM树结构改成了链表结构,更灵活、可中断、随时恢复或重新开始。直到React18的时候又有了一个并发渲染,那么你是否有疑惑什么是并发渲染?它和Fiber之间又有什么关系呢?
了解并发渲染
其实所谓并发渲染并不是真的并发,而是一种更智能的、可中断的协作式调度机制,并非并发执行。它主要解决的是单线程 JavaScript 中长时间任务阻塞主线程导致界面卡顿的问题,核心在于 “让高优先级任务能打断低优先级任务”。这带来了更流畅的用户体验,尤其是在处理复杂更新或低性能设备时。
React18之后与18之前的对比
传统渲染(React 18 之前 - 同步渲染)
- 不可中断: 一旦开始渲染一个更新(例如由 setState 或 props 变化触发),React 会同步地、不间断地完成整个组件树的渲染过程(包括渲染组件函数、进行虚拟 DOM 比较 - reconciliation)直到最终将更改提交 (commit) 到真实 DOM。
- 阻塞主线程: 这个同步渲染过程会独占 JavaScript 主线程。如果更新涉及大量计算或渲染深度很大的组件树,用户界面会完全卡住,无法响应用户的输入(点击、输入、滚动等),直到渲染完成。这就是常说的“卡顿”(jank)。
- “全有或全无”: 更新要么完全完成,要么根本没开始。没有中间状态。
并发渲染(React 18 及之后)
- 可中断: 这是核心!React 可以将渲染工作分解成小的、独立的单元(得益于 Fiber 架构)。在渲染过程中,React 可以暂停当前工作,去处理更紧急的任务(如响应用户输入或动画),然后在稍后恢复或重新开始渲染工作。
- 协作式多任务: React 使用浏览器的 requestIdleCallback 或更现代的调度器(Scheduler),在主线程空闲时进行渲染工作。更重要的是,当高优先级事件(如用户点击)发生时,React 可以中断正在进行的低优先级渲染工作,优先处理用户交互并更新 UI,之后再回来继续或重新开始那个低优先级的渲染。
- 优先级调度: React 能够为不同的更新分配不同的优先级:
- 高优先级更新: 用户交互(输入、点击、拖动)、需要即时反馈的动画。这些更新会抢占低优先级更新。
- 低优先级更新: 数据获取、离屏内容渲染、复杂计算。这些更新可以被中断,让位给高优先级任务。
- 并发特性: 并发渲染本身是一个底层能力。React 18 提供了一系列基于并发能力的新 API(并发特性) 让开发者利用它:
- useTransition: 允许你将一个更新标记为“低优先级”(过渡更新)。React 会立即开始处理它,但不会阻塞高优先级更新(如用户输入)。它还返回一个 isPending 标志,让你在等待低优先级更新完成时显示加载指示(如骨架屏)。这是避免用户输入卡顿的关键 API。
- useDeferredValue: 允许你“推迟”一个值(通常是基于用户输入计算出来的复杂值或来自慢速数据源的值)。React 会先使用旧的、可能过时的值快速渲染 UI(保持响应),然后在后台计算新值并更新。这常用于延迟渲染开销大的列表或图表。
- <Suspense>: 虽然本身在 React 16.3 引入,但在并发渲染下功能更强大。它允许组件在等待异步操作(数据加载、懒加载组件)时“暂停”渲染并显示一个备用 UI(fallback)。在并发模式下,Suspense 与 useTransition 结合,可以避免在数据加载时整个页面卡住,同时允许高优先级交互打断等待状态。
- 自动批处理: React 18 将更多状态更新自动批处理(即使在 Promise, setTimeout, 原生事件处理函数中),减少不必要的渲染次数,提升性能。这是并发渲染能力的自然结果。
如何使用并发渲染
注意: 要使用并发能力必须先用 createRoot 替代 ReactDOM.render 初始化应用根节点。旧方法不会触发并发渲染机制。
// React 18 启用并发渲染的写法
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
并且并发能力是按需启动的,可以通过在新代码中使用 useTransition, useDeferredValue, <Suspense> 来逐步采用。React 默认会尽量安全地使用并发能力。
import { useTransition } from 'react';
function handleInput(e) {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
setInput(e.target.value); // 紧急更新(同步执行)
startTransition(() => {
setResults(filterData(e.target.value)); // 非紧急更新(可中断)
});
}
并发渲染和Fiber的区别
React 的 并发渲染(Concurrent Rendering) 和 Fiber 架构(Fiber Architecture) 确实紧密相关。可以这样理解:
Fiber 架构是底层引擎和数据结构(基础):
React 16 引入 Fiber 架构的主要目的之一,就是为了实现并发渲染铺平道路。
-
核心改变: Fiber 架构将传统的虚拟 DOM 树中的每个组件节点,表示为一个更小、更灵活的数据结构单元——Fiber 节点。
-
关键能力:
- 可中断性: 每个 Fiber 节点代表一个独立的工作单元。React 可以完成一个 Fiber 节点的工作后,暂停渲染,将控制权交还给浏览器处理用户输入、动画等更高优先级的任务,然后在空闲时恢复或重新开始下一个 Fiber 节点的工作。没有这种细粒度的单元划分,中断整个渲染树是不可能的。
- 跟踪状态: 每个 Fiber 节点保存了关于组件类型、props、state、副作用(effects)、子节点、兄弟节点、父节点等信息,以及它当前在渲染过程中的状态(如是否已完成渲染,是否有待处理的更新)。这使得暂停后恢复时,React 能精确知道从哪里继续。
- 链表结构: Fiber 节点之间通过链表(child, sibling, return/parent)连接,代替了传统的树结构。这种结构使得遍历(深度优先遍历)可以更灵活地暂停和恢复,并且可以跳过已处理或无需处理的子树,提高效率。
- 双缓冲: Fiber 架构维护两棵树:当前在屏幕上显示的“Current Tree”和在后台构建的“Work-In-Progress Tree”。这允许 React 在后台准备完整的更新(包括可中断的计算和协调),然后一次性高效地提交(Commit)到屏幕上,减少视觉不一致。
并发渲染是上层调度策略和能力(目标):
并发渲染是 基于 Fiber 架构提供的基础能力(可中断、状态跟踪、链表遍历) 而实现的一套调度策略和渲染机制。
-
核心思想: React 现在可以将渲染工作拆分成多个小的时间片(基于 Fiber 单元),并根据优先级来调度这些工作片段的执行。
-
关键能力(依赖 Fiber 实现):
- 时间切片: 将渲染工作分成小块(Fiber 节点),在浏览器的主线程空闲期(通过 scheduler 包模拟 requestIdleCallback)执行,避免长时间占用主线程导致页面卡顿。
- 优先级调度: React 可以为不同的更新(如用户输入 vs 数据加载)分配不同的优先级。当高优先级任务(如点击事件)出现时,可以中断正在进行的低优先级渲染工作(依赖于 Fiber 节点保存的状态,以便之后恢复),先处理高优先级任务并更新 UI,之后再回来继续或重新开始低优先级工作。
- 可选的并发特性: 开发者使用的 useTransition, useDeferredValue, <Suspense> 等 API,内部都是利用了 Fiber 架构提供的可中断性和优先级调度机制来实现非阻塞渲染和延迟更新。
不是那么恰当的比喻
Fiber 架构: 就像把一个大工程(渲染整个应用)分解成无数个小任务(砌一块砖、装一扇窗 = 处理一个 Fiber 节点)。每个小任务都是独立的,有明确的说明(Fiber 节点保存的状态)。工头(React)知道每个任务的状态(完成、进行中、待做)和它们之间的依赖关系(链表结构)。
并发渲染: 就像这个工头(React)拥有一个智能的调度系统。它知道:
- 有些任务很紧急(用户点击响应 - 高优先级),必须立刻做。
- 有些任务不紧急(加载后台数据 - 低优先级),可以在有空时做。
- 当紧急任务出现时,工头可以立刻让工人停下手中的非紧急任务(中断低优先级 Fiber 工作),先去处理紧急任务。处理完后,工人再回到之前停下的地方继续工作(恢复),或者根据情况重新安排(放弃/重新开始)。
- 工头会尽量安排工人在不干扰住户正常生活的时间段(浏览器空闲时间) 进行噪音大的作业(耗时渲染)。
什么是useTransition
讲了这么多,提到了useTransition这个api。没错,它就是启动并发的关键API,最核心的最直接的体现。接下里讲讲它:
useTransition 的核心能力
-
创建“过渡”更新(非阻塞更新):
- 当你调用 startTransition (从 useTransition 返回的函数) 包裹一个状态更新时(例如 setState),你就是在告诉 React:“这个更新引起的渲染工作优先级较低!不要因为它阻塞用户界面。如果更高优先级的工作(如用户输入、动画)出现,请随时中断我!”
- React 会将包裹在 startTransition 内的状态更新标记为 “过渡更新”,并将其放入 低优先级队列 中处理。
-
提供加载状态 (isPending):
- useTransition 返回一个布尔值 isPending。
- isPending === true:表示有一个或多个由 startTransition 触发的低优先级更新正在后台进行中(可能被中断过多次,但尚未完成提交)。
- isPending === false:表示所有由 startTransition 触发的低优先级更新都已稳定完成(或没有进行中的过渡更新)。
- 开发者可以利用 isPending 在界面上提供反馈,例如显示加载指示器(骨架屏、旋转图标)或禁用某些按钮,告知用户后台有操作在进行,但主界面依然可交互。
-
避免不必要的加载状态闪烁:
- React 会尽量合并快速发生的过渡更新。如果一次过渡更新很快完成(例如数据在缓存中),isPending 可能根本来不及变成 true,从而避免了加载状态出现又立即消失的闪烁问题。
思考:是不是使用了useTransition的组件就是一个需要优化的并发渲染节点呢?
要回答这个问题,我们再说说useTransition和并发之间的关系:
-
useTransition 是并发能力的“开关”和“指示器”:
- 开关: startTransition 是你主动触发并发调度的入口。没有它,React 默认将所有 setState 视为高优先级(紧急)更新,会阻塞主线程直到完成(同步行为)。
- 指示器: isPending 直接反映了 React 并发调度系统的工作状态。它告诉你是否有低优先级任务正在利用并发机制(可中断、后台执行)进行中。
-
底层依赖: useTransition 的实现完全依赖于 Fiber 架构提供的可中断性和 React 调度器的优先级队列机制。没有并发渲染底层支持,useTransition 就无法实现其非阻塞的核心承诺。
并不是使用了useTransition的组件就会被标记,它实际标记的是“更新”和“更新触发的渲染工作链”!
- 当你调用 startTransition(callback) 时,你标记的是 callback 内部执行的所有状态更新(setState 等) 以及由这些更新触发的整个渲染工作链(Reconciliation + Commit) 为低优先级。
- React 调度器会跟踪这个“更新源头”及其引发的后续组件渲染工作,并给它们分配低优先级。
影响的是“渲染工作链”波及的所有组件:
- 假设 startTransition 里的 setState 更新了组件 A 的状态。
- 组件 A 需要重新渲染。
- 组件 A 渲染时,可能会导致其子组件 B、C 等也因 props 变化或 context 变化而重新渲染。
- 整个由这个 setState 触发的、从 A 开始的组件树渲染工作链(A -> B -> C -> ...),都会被当作低优先级任务来处理! 无论组件 B、C 是否直接使用了 useTransition。
- 在这个渲染工作链执行过程中,如果出现更高优先级的更新(如用户在输入框打字),React 可以中断这个低优先级的渲染链,先去处理高优先级更新。
useTransition Hook 本身只在声明它的组件中有效:
- const [isPending, startTransition] = useTransition(); 这行代码写在哪个函数组件里,isPending 就反映该组件内触发的过渡更新的状态,startTransition 也通常用于该组件内部(或传递给其子组件回调)来标记更新。
- 但是,startTransition 标记的低优先级更新所影响的范围绝不限于声明它的那个组件,而是会扩散到因该更新而需要重新渲染的整个子树。
喜欢就点个关注,不定期分享一些技术细节、感悟、新鲜事等。感谢!