深入理解 React 并发渲染
useTransition、Suspense、Selective Hydration、流式 SSR 与并发模式内部机制
什么是并发渲染?
定义:并发渲染(Concurrent Rendering)是 React 18 引入的渲染模式,允许 React 同时准备多个版本的 UI,根据优先级决定哪个版本先展示给用户。它不是多线程——JavaScript 仍然是单线程的——而是通过时间切片和优先级调度实现"看起来同时进行"的效果。
涉及场景:
- 搜索输入:用户输入时立即响应键盘,搜索结果延迟渲染
- 页面导航:切换标签时保持旧页面显示,新页面准备好后一次性切换
- 大列表过滤:过滤操作不阻塞输入框交互
- Suspense 数据加载:组件挂起时展示 fallback,数据就绪后无缝切换
- 流式 SSR:服务端逐步发送 HTML,客户端逐步水合
作用:
- UI 始终响应:紧急更新(输入、点击)不被耗时渲染阻塞
- 更好的加载体验:Suspense + 流式渲染让页面逐步呈现
- 减少不必要的中间状态:Transition 保持旧 UI 直到新 UI 就绪
- 面试重点:理解并发渲染是区分初级和高级 React 开发者的关键
一、并发模式 vs 同步模式
同步模式(React 17 及之前):
setState() → 开始渲染 → 渲染完成 → 更新 DOM
↑
不可中断,必须一次性完成
如果渲染耗时 100ms,用户输入延迟 100ms
并发模式(React 18+):
setState() → 开始渲染 → [用户输入!] → 暂停渲染 → 处理输入 → 恢复渲染 → 更新 DOM
↑
高优先级更新中断低优先级渲染启用方式
jsx
// React 18+:使用 createRoot 自动启用并发特性
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
// React 17:同步模式(不支持并发特性)
// ReactDOM.render(<App />, document.getElementById('root'));二、useTransition 深入
基本原理
jsx
import { useTransition, useState } from 'react';
function FilterableList({ items }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
// 紧急更新:立即反映输入
setQuery(value);
// 非紧急更新:可被中断
startTransition(() => {
const result = items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFiltered(result);
});
}
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <div className="loading-bar" />}
<ul>
{filtered.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}内部机制
startTransition(() => setState(newValue))
1. React 将这次 setState 标记为 TransitionLane(低优先级)
2. 调度器发现有 TransitionLane 的更新
3. 开始在 WIP 树上渲染这个更新
4. 如果渲染过程中收到 SyncLane 的更新(如用户输入):
a. 暂停 Transition 的渲染
b. 优先处理 SyncLane 更新
c. SyncLane 处理完后,恢复 Transition 渲染
5. Transition 渲染完成 → Commit
6. isPending: true → falsestartTransition(非 Hook 版本)
jsx
import { startTransition } from 'react';
// 在非组件代码中使用(如路由库)
function navigate(url) {
startTransition(() => {
setCurrentPage(url);
});
}
// 注意:非 Hook 版本没有 isPending三、useDeferredValue 深入
基本原理
jsx
function SearchResults({ query }) {
// query 立即更新,deferredQuery 延迟到空闲时更新
const deferredQuery = useDeferredValue(query);
// 检测是否正在显示旧值
const isStale = query !== deferredQuery;
// deferredQuery 变化时才重新计算
const results = useMemo(() => {
return expensiveSearch(deferredQuery);
}, [deferredQuery]);
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
{results.map(r => <ResultCard key={r.id} data={r} />)}
</div>
);
}内部机制
useDeferredValue(value):
首次渲染:
返回 value 本身(无延迟)
后续更新:
1. React 先用旧的 deferredValue 完成一次渲染(紧急)
2. 然后在后台用新的 value 重新渲染(Transition 优先级)
3. 后台渲染完成后,切换到新值
本质上等价于:
const [deferredValue, setDeferredValue] = useState(value);
useEffect(() => {
startTransition(() => setDeferredValue(value));
}, [value]);useTransition vs useDeferredValue
| 场景 | 选择 |
|---|---|
| 你能控制 setState | useTransition |
| 值来自 props,你无法控制 | useDeferredValue |
| 需要 isPending 状态 | useTransition |
| 包裹第三方组件 | useDeferredValue |
四、Suspense 深入
基本用法
jsx
import { Suspense, lazy, use } from 'react';
// 1. 代码分割
const LazyComponent = lazy(() => import('./HeavyComponent'));
// 2. 数据获取
function UserProfile({ userPromise }) {
const user = use(userPromise); // 自动 suspend
return <h1>{user.name}</h1>;
}
// 3. 嵌套 Suspense
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</Suspense>
);
}Suspense 的工作原理
组件树渲染过程中,某个组件 throw 了一个 Promise:
1. React 捕获这个 Promise(不是普通的 try-catch,是 Fiber 层面的捕获)
2. 向上查找最近的 <Suspense> 边界
3. 显示该 Suspense 的 fallback
4. 同时"订阅"这个 Promise
5. Promise resolve 后:
a. 重新渲染 Suspense 包裹的子树
b. 这次组件不再 throw(因为数据已就绪)
c. 替换 fallback 为正式内容
关键:组件不需要知道 Suspense 的存在
数据获取库负责 throw Promise
React 负责协调 fallback 显示SuspenseList(实验性)
jsx
// 控制多个 Suspense 子项的显示顺序
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<Skeleton />}>
<Section1 /> {/* 先显示 */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Section2 /> {/* 等 Section1 显示后再显示 */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<Section3 /> {/* 等 Section2 显示后再显示 */}
</Suspense>
</SuspenseList>
// revealOrder: 'forwards' | 'backwards' | 'together'
// tail: 'collapsed'(只显示下一个 fallback)| 'hidden'(不显示 fallback)五、流式 SSR(Streaming SSR)
传统 SSR 的问题
传统 SSR 流程(瀑布式):
服务器获取所有数据 → 渲染完整 HTML → 发送 → 加载所有 JS → 水合所有组件
↑ ↑
最慢的数据决定首屏时间 必须全部水合完才能交互流式 SSR + Selective Hydration
React 18 流式 SSR:
1. 服务器遇到 <Suspense> 时:
a. 先发送 fallback 的 HTML
b. 继续渲染其他部分
c. 当 Suspense 内的数据就绪后,发送补充 HTML(替换 fallback)
2. 客户端水合:
a. 不需要等所有 JS 加载完
b. 哪个组件的 JS 先到,就先水合哪个(Selective Hydration)
c. 用户点击了还未水合的区域 → React 提升该区域的水合优先级jsx
// server.js
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe, abort } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/main.js'],
onShellReady() {
// Shell(Suspense 边界外的内容)准备好后开始流式传输
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.send('<h1>服务器错误</h1>');
},
onError(error) {
console.error(error);
},
}
);
// 超时中止
setTimeout(abort, 10000);
});流式传输过程
html
<!-- 第一次传输:Shell + Suspense fallback -->
<html>
<body>
<header>导航栏</header>
<main>
<template id="B:0"></template> <!-- Suspense 占位 -->
<div>加载中...</div> <!-- fallback -->
</main>
<script src="/main.js" async></script>
</body>
</html>
<!-- 第二次传输:Suspense 内容就绪 -->
<div hidden id="S:0">
<article>实际内容...</article> <!-- 真正的内容 -->
</div>
<script>
// 替换 fallback 为真正内容
$RC('B:0', 'S:0');
</script>六、Selective Hydration
水合优先级
传统水合:
加载 JS → 从根节点开始水合 → 按顺序处理所有组件 → 全部完成后可交互
Selective Hydration(React 18):
加载 JS → 分块水合 → 用户交互的区域优先水合
场景:
页面有 Header、Sidebar、MainContent、Comments 四个区域
Comments 数据最慢,用 Suspense 包裹
1. 服务器先发送 Header + Sidebar + MainContent 的 HTML
2. 客户端开始水合 Header(JS 最先到达)
3. Comments 数据到达,服务器发送补充 HTML
4. 用户点击了 Sidebar 中的按钮
→ React 提升 Sidebar 的水合优先级
→ 优先水合 Sidebar,使其可交互
5. 继续水合其他区域代码示例
jsx
// 利用 Suspense 实现 Selective Hydration
function App() {
return (
<Layout>
{/* 这些立即水合 */}
<Header />
<SearchBar />
{/* 这些延迟水合,各自独立 */}
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations />
</Suspense>
</Layout>
);
}七、并发特性的组合使用
Transition + Suspense
jsx
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
function navigate(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<nav>
<button onClick={() => navigate('home')}>首页</button>
<button onClick={() => navigate('posts')}>文章</button>
</nav>
{/* Transition 期间:保持显示旧 tab 内容 */}
{/* 而不是立即显示 Suspense fallback */}
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<Suspense fallback={<TabSkeleton />}>
<TabContent tab={tab} />
</Suspense>
</div>
</div>
);
}
// 关键行为:
// 没有 Transition:切换 tab → 立即显示 fallback → 数据就绪后显示内容
// 有 Transition:切换 tab → 保持旧内容(isPending=true)→ 新内容就绪后一次性切换DeferredValue + Suspense
jsx
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<Suspense fallback={<SearchSkeleton />}>
<div style={{ opacity: isStale ? 0.5 : 1 }}>
<SearchResults query={deferredQuery} />
</div>
</Suspense>
</div>
);
}
// 行为:
// 1. 用户输入 → query 立即更新 → 输入框始终响应
// 2. deferredQuery 延迟更新 → SearchResults 延迟渲染
// 3. 如果 SearchResults suspend → 不会显示 fallback(因为有旧值可用)
// 4. 只有首次加载时才显示 fallback面试高频题
Q: React 并发模式是多线程吗?
不是。JavaScript 是单线程的。并发模式通过时间切片(每 5ms 让出主线程)和优先级调度(Lane 模型)实现"看起来同时进行"的效果。高优先级更新可以中断低优先级更新的渲染。
Q: useTransition 和 setTimeout 有什么区别?
setTimeout 是真正的延迟执行,回调在下一个事件循环执行,无法被取消或抢占。startTransition 标记更新为低优先级,React 会立即开始渲染但允许被高优先级更新中断。如果没有高优先级更新,Transition 可以立即完成,不会有延迟。
Q: Suspense 是怎么"暂停"组件渲染的?
组件在渲染过程中 throw 一个 Promise(不是 Error)。React 的 Fiber 层面捕获这个 Promise,找到最近的 Suspense 边界,显示 fallback。当 Promise resolve 后,React 重新渲染该子树。组件本身不需要感知 Suspense 的存在。
Q: 流式 SSR 相比传统 SSR 的优势?
- 更快的 TTFB:不需要等所有数据就绪,Shell 准备好就开始传输
- 渐进式内容:Suspense 内的内容就绪后自动替换 fallback
- Selective Hydration:不需要等所有 JS 加载完才开始水合
- 交互优先:用户点击的区域优先水合