前言
今年 3 月底 React 18 正式发布,官方口号喊得很响:”Concurrent Rendering(并发渲染)是 React 未来十年愿景的基础”。我一开始是抱着怀疑态度去读文档的——React 16 的 Fiber 架构铺垫了四年,总不能又只是”底层重构、用户无感”吧?后来在一个大列表搜索的场景里实际用上了 useTransition,卡顿肉眼可见地消失,才真正体会到这次升级的价值。这篇文章就把我对并发渲染的理解和实战代码整理出来。
1. React 18 发布背景
要理解 React 18,得先简单回顾一下历史脉络。React 16(2017)引入 Fiber 架构,把同步不可打断的递归渲染改造成链表结构,为”可中断渲染”打下了地基,但当时所有更新仍然是同步执行的。React 17(2020)基本是个”垫脚石”版本,主要工作是事件委托移到根节点、JSX 转换升级等铺垫。
React 18(2022)才是 Fiber 架构真正的”交卷”:渲染过程可以被打断、暂停、重新开始,React 可以根据优先级决定先渲染什么、后渲染什么。官方称之为 Concurrent Rendering。配套的新特性(useTransition、useDeferredValue、Suspense 数据获取、流式 SSR)全部建立在这个能力之上。
先把一件事说清楚:并发渲染默认开启,但只有使用新 API 声明”这是可并发的更新”时才会生效。旧的代码路径行为完全不变,老项目升 createRoot 不用担心行为变化。
2. 什么是 Concurrent Rendering
2.1 可中断渲染:从”独占”到”协作”
React 17 及之前,一次状态更新触发的渲染是同步的、不可打断的:JS 引擎从 render 到 commit 一口气跑完,期间用户的输入、动画帧全部排队等待。列表大一点,输入框每敲一个字母都要卡一下,这就是经典的”输入掉帧”问题。
并发渲染改变了这个模型:React 把渲染工作切成一个个小单元(对应 Fiber 节点),每处理完一个单元就去问一句”有没有更重要的事要做?”。如果用户此刻正在打字,React 会把手头的低优先级渲染暂停,先让浏览器处理输入事件;输入处理完,再从暂停的位置恢复渲染。整个过程类似时间切片(Time Slicing)。
这里有个最容易误解的点要划重点:并发渲染并没有让单次渲染变快,渲染总量甚至可能因中断恢复而增加。它改变的是调度方式——让高优先级的交互永远插队,把耗时的低优先级渲染延后,用户感知到的”卡顿”消失了。
2.2 优先级调度:Lane 模型的通俗理解
React 18 内部用 Lane(车道)模型给更新划分优先级,可以类比高速公路车道:不同紧急程度的更新驶入不同车道,调度器每轮先处理最高优先级车道上的更新。
用文字描述一次”输入 + 大列表过滤”的调度过程:
1 | 用户敲入字符 "react" |
注意 t3 这个细节:被中断的渲染如果还没 commit,React 会直接丢弃重算,而不是续着渲染一个已经过时的结果。因为这类过渡渲染不 commit 就不会影响屏幕内容,丢弃的成本是零——这就是并发渲染敢于”半途而废”的底气。
3. createRoot 与 Automatic Batching
3.1 createRoot:开启并发的开关
升级 React 18 后,业务代码要改的第一行就是入口文件:
1 | // React 17 及之前 |
createRoot 就是并发功能的总开关。使用旧的 ReactDOM.render 时 React 会打警告,并且所有新特性(包括自动批处理)都不生效。过渡期间也可以用 ReactDOM.createRoot + flushSync 组合,后者可以强制某次更新走同步路径。
3.2 Automatic Batching:批处理范围扩大
“批处理”指多次 setState 合并为一次渲染。React 17 只在事件回调(如 onClick)里批处理,在 Promise、setTimeout、原生事件里每次 setState 都会触发独立渲染。React 18 的自动批处理把范围扩大到了所有更新场景:
1 | // React 17:setTimeout 里两次 setState 各触发一次渲染,一共渲染两次 |
绝大多数场景这是免费的性能提升。极少数依赖”setState 后立即同步重渲染”的代码(比如打印中间状态)会踩到行为差异,需要用 flushSync 显式同步:
1 | import { flushSync } from 'react-dom'; |
4. useTransition 与 useDeferredValue 实战

4.1 一个真实卡顿的场景
设想一个常见需求:搜索框 + 一万条数据的列表实时过滤。React 17 的写法:
1 | function SearchList() { |
handleChange 里两个 setState 同步批处理成一次渲染,而这次渲染里包含了对一万条数据的过滤计算和一万个列表项的 diff。结果就是:每敲一个字母,主线程被阻塞几百毫秒,输入框跟着卡。输入和过滤被绑在了同一个”同步更新”里,输入被迫陪过滤一起慢。
4.2 useTransition:把慢更新标记为”过渡”
React 18 的解法是给更新分类:输入框是”紧急更新”(UI 响应必须即时),列表过滤是”过渡更新”(晚几百毫秒无所谓)。useTransition 就是这个分类器:
1 | import { useState, useTransition } from 'react'; |
跑起来的实际效果:连续快速输入 “reactjs” 六个字母,输入框每帧都跟手;列表渲染被中断了五次,只有最后一次结果被 commit 到屏幕。isPending 为 true 表示还有过渡渲染在后台进行,可以用来展示轻量提示。
几个使用要点:
startTransition包住的回调里做 setState,不要把网络请求等副作用放进去(请求该发就发,把”用结果更新状态”包起来);- 过渡更新期间,React 会展示旧状态的界面,所以页面不会白屏或闪烁,只是”旧列表多停留了一会儿”;
- 如果过渡更新自身还要展示 loading(比如切换 tab 拉数据),可以配合
useTransition的isPending,或者用Suspense的 fallback。
4.3 useDeferredValue:值的”防抖”版
如果不想改 setState 的调用位置,React 18 还提供了一个更”声明式”的 API:useDeferredValue。它返回一个滞后于真实值的副本,紧急部分用真值渲染,耗时的计算用滞后值:
1 | function SearchList() { |
工作方式类似 debounce,但有两个本质区别:一是它没有固定延迟时间,后一次渲染紧跟在前一次之后开始,机器越快延迟越小;二是输入框的渲染不会被延迟,慢的是吃计算的大列表。两种 API 的选择标准:能控制 setState 调用处就用 useTransition,值来自外部(props、自定义 Hook)就用 useDeferredValue。
另外提醒一点:不要把 useDeferredValue 当成万能的防抖工具。它优化的只是”渲染”这一段,如果你的卡顿来自阻塞主线程的非渲染计算(比如在 onChange 里同步跑了几百毫秒的字符串处理),这个 Hook 帮不了你,得先把慢计算挪进 Web Worker,或者用真正的 debounce 控制请求频率,再配合这两个 API 使用。
5. Suspense:数据获取与代码分割
5.1 代码分割里的 Suspense(现在就能用)
Suspense 在 React 18 里最成熟的应用是配合 React.lazy 做组件级代码分割,路由页面按需加载:
1 | import { lazy, Suspense } from 'react'; |
注意嵌套 Suspense 的用法:外层 Report 加载完成即可渲染,内层 Chart 继续用自己的骨架屏兜底,两个模块的加载互不阻塞——如果是一个 Suspense 包到底,一个慢模块会拖住整页。
5.2 与数据获取结合( Relay / 新一代框架)
并发渲染真正的杀手锏是 Suspense 数据获取:组件在”读取数据未就绪”时抛出 Promise,React 捕获后展示 fallback,同时不阻塞页面其他部分的渲染,数据到达后从中断处继续。官方目前推荐通过框架接入(如 Relay):
1 | // Relay 风格的伪代码,展示 Suspense 数据获取的模型 |
配合 React 18 的流式 SSR(renderToPipeableStream + selective hydration),服务端可以按 Suspense 边界分块吐 HTML,浏览器先看到的区块先交互、先水合,整体 TTI 大幅提前。这块目前需要框架支持,暂时还不建议裸用。
6. 升级注意事项
最后把升级清单列一下,我们项目升级的过程基本就是照着这个排查的:
- 依赖对齐:
react、react-dom升到 18,类型包升到@types/react@18,React Testing Library 升到 13+(内部已改用 createRoot,业务测试代码基本不用动); - 入口改造:
ReactDOM.render换createRoot,SSR 的hydrate换hydrateRoot; - 类型变化:
children从 FC 的隐式 props 中移除,函数组件需要显式声明children?: React.ReactNode; - 批处理行为审查:排查 Promise/setTimeout 里依赖 setState 后立即重渲染的逻辑,必要时
flushSync; - 第三方库兼容:个别库依赖旧渲染调度(如部分动画库、react-beautiful-dnd 老版本),升级前先确认版本兼容列表;
- 渐进采用:不急着把所有状态更新包进 transition,先在列表过滤、tab 切换这类”大计算 + 可容忍延迟”的场景小范围验证。
写在最后
并发渲染的本质,是把”用户交互优先”从口号变成了调度器里真实的优先级队列。React 没有让渲染变快,而是让”慢”变得不再可感。
从 useTransition 开始,前端开发者能以”声明优先级”的方式参与渲染调度,这在我看来是 Hooks 之后 React 给出的第二个范式级 API。Suspense 数据获取的生态眼下还在起步,server components 也仍是实验状态,我的打算是生产环境里先用 useTransition 和 useDeferredValue,等框架层的数据获取方案稳定了再上 Suspense。倒是 createRoot 和自动批处理这两块零成本的部分,这周就可以升。





