
一句话:并发渲染(Concurrent Rendering)不是多线程,而是「渲染过程可以被中断、可以被丢弃、可以按优先级调度」——它让紧急更新(如输入)不被长渲染阻塞,也是
useTransition、useDeferredValue、Suspense的基础。
一、并发渲染是什么
❌ 常见误解:
「并发 = 多线程/并行渲染」→ 错
React 仍然是【单线程】的,组件函数仍在主线程执行
✅ 准确理解:
并发 = 「可中断的渲染 + 优先级调度」
一次渲染可以「开始 → 暂停 → 让浏览器处理别的事 → 继续」,
也可以「丢弃」已经做了一部分的工作。
类比:
同步渲染 = 一口气把一本厚书读完(期间不能接电话)
并发渲染 = 可以随时放下书去接电话,之后再从书签处继续
甚至「发现有更要紧的事」时撕掉已读的部分重新读
为什么需要它?
① 浏览器的帧预算是 16.7ms(60fps),留给 JS 的更少
② 一次渲染(尤其大列表)可能耗时几百毫秒
③ 同步渲染会在这段时间里【完全阻塞】:
· 用户输入无响应(输入框卡顿)
· 动画掉帧
· 点击延迟
并发渲染的目标:让「长渲染」不再阻塞「紧急交互」。
二、可中断渲染
机制(结合 4.2 的 Fiber):
① 渲染工作被拆成「一个个 Fiber 单元」
② 每处理完一个单元,检查:
· 是否用完了时间片?
· 是否有更高优先级的更新?
③ 如果「是」,就让出主线程(保存当前 workInProgress 指针)
④ 浏览器处理输入/动画/绘制
⑤ 下一帧(或稍后)从这个指针继续
⚠️ 关键点:
· 可中断的是【render 阶段】
· commit 阶段【不可中断】(否则用户看到改了一半的界面)
「丢弃」也是很重要的能力:
场景:用户在输入框里快速输入 "abc"
① 输入 "a" → 开始渲染「过滤结果」(低优先级,耗时 200ms)
② 用户输入 "b" → 有新的更新
③ React 中断当前的 render,【丢弃】已经算了一半的 "a" 的结果
④ 直接用最新状态("ab")重新渲染
→ 避免了「用落后的输入渲染出落后结果」的浪费
这也是「为什么闭包/中间态不会显示在界面上」的原因之一。
三、优先级与车道(Lane)
// Lane 是位掩码(见 4.2)
const SyncLane = 0b001; // 最高(离散事件)
const InputContinuousLane = 0b100; // 连续事件(mousemove/scroll)
const DefaultLane = 0b10000; // 默认(普通 setState)
const TransitionLanes = 0b1110000000; // 过渡(startTransition)
const IdleLane = 0b10000000000; // 空闲
| 优先级 | 典型来源 | 是否可中断 |
|---|---|---|
| Sync | 点击、键盘输入、flushSync |
❌(同步执行) |
| InputContinuous | mousemove、scroll、拖拽 |
✅ |
| Default | 普通 setState、网络回调、定时器 |
✅ |
| Transition | startTransition、useTransition、useDeferredValue |
✅(最容易被让路) |
| Idle | 完全不紧急的工作 | ✅ |
调度规则:
① 高优先级车道可以「插队」——先渲染
② 低优先级的渲染被打断后,会「稍后重试」
③ 如果低优先级渲染期间状态又变了,会「丢弃已作废的工作」
④ Commit 会「按优先级」把已完成的高优先级结果先提交
⚠️ 但要注意:默认的 setState 仍然是 DefaultLane(不低也不高)。
「让某个更新变成低优先级」必须显式用 startTransition / useDeferredValue。
四、时间切片
React 用 Scheduler 包调度:
① 每个时间片约 5ms
② 时间片用完(或出现更高优先级任务)→ 让出主线程
③ 用 MessageChannel 安排下一次继续(比 setTimeout 更及时,比 requestIdleCallback 更可控)
⚠️ 时间切片只在「Concurrent 模式 + 有可中断的更新」时生效。
如果整棵树的更新都是 SyncLane(如点击触发),仍然会同步跑完。
// 让「耗时更新」变成可中断的(这才是时间切片真正发挥作用的地方)
function Search() {
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<Item[]>([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setKeyword(e.target.value); // 紧急:SyncLane
startTransition(() => {
setResults(filterHugeList(e.target.value)); // ⭐ 非紧急:TransitionLane,可被打断
});
};
return (
<>
<input value={keyword} onChange={handleChange} />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{results.map((r) => <Row key={r.id} item={r} />)}
</div>
</>
);
}
五、并发相关的 API
| API | 作用 | 见 |
|---|---|---|
useTransition |
把更新标记为「过渡(低优先级)」 | 2.10 |
useDeferredValue |
返回一个「滞后版本」的值 | 2.10 |
startTransition |
非 Hook 版本(可在组件外调用) | react 直接导出 |
Suspense |
等待某件事完成时显示 fallback | 8.4 |
useSyncExternalStore |
订阅外部数据,避免并发下的撕裂 | 2.11 |
useInsertionEffect |
为 CSS-in-JS 库设计的、在布局 Effect 前执行 | 库作者使用 |
createRoot |
启用并发能力的入口 | React 18+ |
// startTransition 也可以从 react 导入(不依赖 Hook,可在任意地方调用)
import { startTransition } from 'react';
// 场景:路由跳转(紧急) + 内容渲染(非紧急)
function navigate(to: string) {
router.push(to); // 紧急:导航立即响应
startTransition(() => {
setPageContent(computeContent(to)); // 非紧急:内容可以稍后
});
}
六、并发与 StrictMode 的双调用
为什么 StrictMode 要「双调用」?
因为并发渲染带来一个前提假设:「**render 必须可以被执行多次**」
① 渲染可被中断 → 之后「重放」(重新执行未完成的部分)
② 渲染可能被执行两次(React 需要在某些情况下「预先」执行)
③ 未来 React 可能支持「在保留状态的情况下卸载/重挂载组件」
→ 所以「render 必须是纯函数」不再只是「好习惯」,而是【正确性的要求】。
StrictMode 的双调用就是在开发环境【故意】制造这个场景,
把「不纯的 render」和「缺失清理的 Effect」暴露出来。
七、并发带来的新问题
1) 撕裂(Tearing)
场景:一个「外部数据源」(不是 React state):
① 组件 A 渲染时读到 store.value = 1
② 渲染被打断,外部数据被改成 2
③ 组件 B 在同一轮渲染里读到 store.value = 2
→ 同一个界面上 A 显示 1、B 显示 2 → 撕裂
解法:useSyncExternalStore(它保证在读取外部数据时的一致性)
↑ 这也是为什么 Redux/Zustand 内部都改用了它。
2) 「渲染期间修改状态」
并发下,「渲染期间修改外部状态」的行为变得不可预测:
· 可能被重放 → 修改执行多次
· 可能被丢弃 → 修改「看起来没生效」
所以规则更严格了:
render 里只能【读】props/state,不能【写】任何外部东西。
3) 副作用不能「预期执行顺序」
// ⚠️ 不要依赖「渲染执行了几次」
let renderCount = 0;
function Comp() {
renderCount++; // ❌ 并发下次数不可预测
return <div>{renderCount}</div>;
}
八、常见坑
① 认为「并发 = 多线程」
→ 仍是单线程,只是「可中断 + 优先级」
② 认为「升级 React 18 就自动享受并发的好处」
→ 需要 createRoot;且默认 setState 仍是 sync/default 优先级,
真正要用并发能力得显式用 transition/Suspense
③ 认为「所有更新都会被打断」
→ SyncLane(点击/输入)是同步不可中断的;
只有被标记为过渡的更新才容易被让路
④ 在 render 里做副作用
→ 并发下可能重复执行或看起来「没生效」
⑤ 用「执行次数」判断逻辑正确性
→ 次数是优化细节,不可依赖
⑥ 在 transition 里放「紧急反馈」
→ 输入回显、hover、loading 提示不能是过渡优先级
⑦ 自己管理「外部 store」而不使用 useSyncExternalStore
→ 可能出现撕裂
⑧ 认为「时间切片会让长列表不卡」
→ 它降低「阻塞」,但总工作仍需完成;大列表的真正解法是虚拟滚动
⑨ 在 useLayoutEffect 里做重计算
→ commit 不可中断,会直接阻塞绘制
⑩ 认为「Suspense 只是 lazy 的配套」
→ 它是并发体系的一部分(数据获取、流式 SSR 都依赖它)
九、面试延伸
- 「什么是并发渲染?」
并发渲染是 React 18 的核心能力,它的本质是「渲染过程可中断、可丢弃、可按优先级调度」——不是多线程(React 仍在主线程执行组件函数)。它让 React 可以在「渲染一个大列表」的中途暂停,让浏览器先处理用户的输入与动画,然后再继续。这样「长渲染」就不会阻塞「紧急交互」。它是useTransition、useDeferredValue、Suspense等特性的底层基础。 -
「并发渲染和 Fiber 是什么关系?」
Fiber 是「让可中断渲染成为可能的数据结构」——它把递归改成「链表遍历 + 工作单元」,因此能在任意一步暂停并保存进度。并发渲染是「建立在这个结构之上的调度能力」——按优先级安排工作、在必要时中断与丢弃。一句话:Fiber 是基础设施,并发渲染是上层能力。 -
「并发渲染解决了什么问题?」
解决「长渲染阻塞主线程」的问题。在同步渲染下,一次耗时 300ms 的渲染会让:① 输入框卡顿(用户打字延迟);② 动画掉帧;③ 点击无响应。并发渲染通过「时间切片 + 优先级」让紧急更新先执行、低优先级更新可被打断,从而保持界面「可交互」。注意:它不减少总工作量,只是改变「谁先做、能不能暂停」。 -
「时间切片是怎么工作的?」
React 用独立的 Scheduler 调度:给一次渲染分配约 5ms 的时间片,每处理完一个 Fiber 单元检查「时间是否用完 / 是否有更高优先级任务」;如果用完就让出主线程(用MessageChannel安排下一帧继续),让浏览器处理输入与绘制。注意两点:① 只有「可中断的更新」(transition 优先级)才真正受益;② commit 阶段不参与时间切片。 -
「优先级是怎么划分的?」
React 用 Lane(车道)位掩码表示优先级,从高到低大致是:Sync(点击、键盘等离散事件、flushSync)→ InputContinuous(mousemove、scroll等连续事件)→ Default(普通setState、网络回调)→ Transition(startTransition/useTransition/useDeferredValue)→ Idle。高优先级可以「插队」,低优先级的渲染被打断后会稍后重试,过期的工作会被丢弃。 -
「并发渲染与 StrictMode 的双调用有什么关系?」
并发渲染建立在「render 可以被执行多次」的前提上——因为渲染可能被重放(中断后重新执行未完成部分)、可能被丢弃重来,未来 React 还可能「保留状态地卸载/重挂载组件」。所以「render 必须是纯函数」从「好习惯」升级为「正确性要求」。StrictMode 的双调用就是故意制造这个场景,在开发环境暴露「不纯的 render」与「缺失清理的 Effect」。 -
「什么是撕裂(tearing)?怎么解决?」
撕裂指「同一次渲染中,不同组件读到了外部数据的不同版本」——在并发渲染下(渲染可被打断),外部数据源可能在渲染过程中变化,导致组件 A 读到旧值、组件 B 读到新值,界面出现不一致。React 的官方解法是useSyncExternalStore:它在读取外部数据时保证一致性(必要时强制同步渲染)。这也是 Redux、Zustand 等库内部都改用它订阅的原因。 -
「
useTransition是必需的吗?不加会怎样?」
不加也能工作——但「昂贵更新」会以 Default 优先级同步执行,阻塞输入回显等紧急交互,表现为「输入卡顿」。加上useTransition后,这些更新被标记为 Transition 优先级,可以被中断,从而让输入保持流畅。所以它的作用是「在需要区分优先级的地方,显式告诉 React 哪些更新不紧急」——这也是「并发能力默认不生效」的原因(React 不会自动猜测你的意图)。
一句话速记
并发渲染 = 「可中断 + 可丢弃 + 按优先级调度」,不是多线程(单线程执行组件函数);它建立在 Fiber(可暂停的链表遍历)之上,用 Lane 位掩码表达优先级(Sync → InputContinuous → Default → Transition → Idle),用 约 5ms 时间切片让出主线程;commit 阶段不可中断;默认
setState是 Default 优先级(并发好处不会自动生效,要显式用useTransition/useDeferredValue/Suspense);它带来的新约束是「render 必须纯」(StrictMode 双调用就是检验),以及需要useSyncExternalStore避免撕裂。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!