React 基础体系 · 第 16/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 并发渲染:Transition、Deferred Value、Suspense 和一致性
React 的“并发渲染”不是指 JavaScript 同时在多个线程执行,也不是指组件可以在后台修改已经提交到 DOM 的内容。它描述的是一种渲染调度模型:当一次更新包含大量计算,或者依赖尚未准备好的异步数据时,React 可以先处理更紧急的更新,暂停、丢弃或重新开始较低优先级的渲染,最后仍以一次完整提交的方式更新界面。
本文围绕四个概念展开:
- Transition:把一组状态更新标记为非紧急更新;
- Deferred Value:让某个值在后台渲染中逐步追上最新值;
- Suspense:声明组件树在依赖未准备好时应显示的边界状态;
- 一致性:说明并发渲染如何避免用户看到由不同状态拼接出的半成品界面,以及哪些外部数据访问方式会破坏这一保证。
示例以 React 19、现代 TypeScript 和客户端组件为基础。涉及服务端渲染、Server Components 和服务端数据获取时,会单独说明边界。
一、先建立模型:渲染、提交与并发
1. 渲染不是直接修改 DOM
一次 React 更新大致可以分为两个阶段:
- Render 阶段:调用组件函数,计算新的 React 元素树;
- Commit 阶段:把计算结果应用到 DOM,并执行相应的布局或副作用处理。
简化表示为:
状态更新
│
▼
Render:计算新的 UI
│
├─ 被打断、重试或丢弃
│
▼
Commit:一次性提交到 DOM
并发渲染主要改变的是 Render 阶段。React 可以在计算低优先级树时暂停,让出主线程;也可以发现更新过时后放弃当前计算,重新基于更新后的状态渲染。
但一次 Commit 不应只提交“算到一半”的结果。用户通常看到的是某个完整的已提交 UI,而不是:
标题来自 query = "react"
列表来自 query = "re"
加载状态来自 query = "react 19"
这种不同时间点状态拼接出来的界面,就是并发场景中要避免的 tearing,中文常译为“撕裂”或“一致性破坏”。
2. “并发”不等于多线程
浏览器中的 React 通常仍运行在主线程。所谓并发,主要是:
- 可中断的渲染;
- 不同优先级的更新;
- 对过时渲染结果的丢弃;
- 对 Suspense 边界的协调;
- 在多个状态更新之间选择更合适的提交时机。
因此,并发渲染不能消除 JavaScript 计算本身的成本。如果一个组件在渲染期间执行了很长的同步循环,浏览器主线程仍然会被阻塞。并发调度只能让 React 有机会在可中断的工作之间让出控制权。
3. React 的一致性目标
可以把一个已提交界面抽象为状态快照:
其中:
- 是第 次提交所使用的状态快照;
- 是组件树的纯渲染函数;
- 是提交后的完整界面。
React 希望满足:
而不是让 DOM 中的一部分来自 ,另一部分来自 。
并发渲染允许存在未提交的中间计算:
但这个中间结果在完成并通过 React 的协调后,才会作为一个整体提交。若计算期间又出现了更高优先级更新,旧计算可以被丢弃。
这就是后面 Transition、Deferred Value 和 Suspense 共同依赖的基础。
二、更新优先级:什么是紧急更新,什么是 Transition
1. 紧急更新和非紧急更新
用户输入通常需要立即响应。例如:
- 文本框中的字符应立即显示;
- 按钮按下后的视觉状态应尽快反馈;
- 光标位置不能因为复杂列表渲染而长时间停滞。
而以下更新通常可以延后:
- 根据搜索关键字重新计算大列表;
- 切换复杂页面或选项卡;
- 加载并渲染大量内容;
- 重新渲染包含很多子组件的结果区域。
React 并不根据“操作名称”自动理解这些更新的业务优先级。开发者需要通过 Transition 明确告诉 React:
这组更新重要,但不应该阻塞当前的紧急交互。
2. startTransition
startTransition 用于把状态更新标记为 Transition:
import { startTransition, useState } from "react";
export function Tabs() {
const [tab, setTab] = useState<"overview" | "details">("overview");
function selectTab(nextTab: "overview" | "details") {
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<button onClick={() => selectTab("overview")}>概览</button>
<button onClick={() => selectTab("details")}>详情</button>
{tab === "overview" ? <Overview /> : <Details />}
</div>
);
}
这里的因果关系是:
- 点击事件发生;
setTab更新被标记为 Transition;- React 可以优先完成其他更紧急的更新;
Details的渲染可以被暂停或重新开始;- 新选项卡准备好后,React 再整体提交。
startTransition 不会自动显示加载状态,也不会自动取消网络请求,更不会让 Details 的计算成本消失。它只改变相关更新的调度优先级。
3. useTransition
如果组件需要知道 Transition 是否仍在进行,可以使用 useTransition:
import { useState, useTransition } from "react";
export function TabPanel() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState<"overview" | "details">("overview");
function selectTab(nextTab: "overview" | "details") {
startTransition(() => {
setTab(nextTab);
});
}
return (
<section>
<nav aria-busy={isPending}>
<button
disabled={isPending}
onClick={() => selectTab("overview")}
>
概览
</button>
<button
disabled={isPending}
onClick={() => selectTab("details")}
>
详情
</button>
{isPending && <span>正在准备内容…</span>}
</nav>
<div>
{tab === "overview" ? <Overview /> : <Details />}
</div>
</section>
);
}
isPending 表示相关 Transition 仍处于等待状态。它不是网络请求状态的通用替代品:
- 请求可能已经结束,但组件仍在渲染;
- Transition 可能在等待 Suspense 内容;
- 多个更新可能被 React 合并;
isPending的生命周期由 React 调度决定,不等于某个具体 Promise 的生命周期。
4. 文本输入不能直接作为 Transition 控制状态
文本输入有一个重要限制:控制输入框 value 的更新不能只放进 Transition。
下面的写法不正确:
function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
value={query}
onChange={(event) => {
startTransition(() => {
setQuery(event.target.value);
});
}}
/>
);
}
原因是输入框的受控值需要立即更新。若它被当成非紧急更新,输入可能出现延迟、光标跳动或丢失字符。
正确方式是拆分两个状态:
function SearchBox() {
const [inputValue, setInputValue] = useState("");
const [query, setQuery] = useState("");
const [, startTransition] = useTransition();
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextValue = event.target.value;
setInputValue(nextValue);
startTransition(() => {
setQuery(nextValue);
});
}
return (
<>
<input value={inputValue} onChange={handleChange} />
<p>用于搜索的关键字:{query}</p>
</>
);
}
这里:
inputValue是紧急状态,保证输入框立即响应;query是非紧急状态,可以驱动复杂结果区域;- 二者短暂不同步是有意设计,而不是数据撕裂;
- 只要结果区域基于同一个已提交的
query快照渲染,界面仍然是一致的。
不过,这种手动维护两个状态也容易产生同步逻辑。对于“一个值只是希望延后使用”的场景,useDeferredValue 通常更直接。
5. Transition 的异步边界
React 19 支持将异步函数交给 Transition:
const [isPending, startTransition] = useTransition();
function handleSubmit() {
startTransition(async () => {
await saveData();
setStatus("saved");
});
}
这里必须区分三件事:
await saveData()的网络请求本身不是由 React 调度的;- React 可以追踪这次 Transition 的等待状态;
- 异步函数中在
await之后发生的状态更新,具体是否仍被视为同一个 Transition,受 React 版本和当前能力限制影响。
在需要明确控制的代码中,尤其是跨越多个异步边界时,可以在 await 后再次显式标记更新:
startTransition(async () => {
const result = await saveData();
startTransition(() => {
setResult(result);
});
});
这不是为了“加速请求”,而是为了明确后续状态更新的优先级。实际项目应以所使用 React 版本和框架对 Actions 的支持方式为准。
三、useDeferredValue:延后使用一个值,而不是延后产生它
1. API 语义
useDeferredValue 接收一个值,返回一个可能暂时落后的值:
const deferredQuery = useDeferredValue(query);
它的核心语义不是:
等待固定的 300 毫秒再更新。
而是:
当最新值对应的渲染较昂贵时,允许 React 先继续使用旧值,并在后台尝试让结果追上最新值。
因此,useDeferredValue 没有防抖时间参数,也不等价于 setTimeout。
2. 一个完整的输入与结果例子
import { useDeferredValue, useState } from "react";
type Product = {
id: string;
name: string;
};
function filterProducts(products: Product[], query: string) {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return products;
}
return products.filter((product) =>
product.name.toLowerCase().includes(normalized),
);
}
export function ProductSearch({
products,
}: {
products: Product[];
}) {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const filteredProducts = filterProducts(products, deferredQuery);
const isStale = query !== deferredQuery;
return (
<section>
<label>
搜索:
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<div
style={{
opacity: isStale ? 0.6 : 1,
transition: "opacity 120ms ease",
}}
>
<p>结果关键字:{deferredQuery || "全部"}</p>
<ul>
{filteredProducts.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
</div>
</section>
);
}
逐步观察输入 "react" 的过程:
用户输入 r
query = "r"
deferredQuery 可能仍为 ""
用户继续输入 e
query = "re"
deferredQuery 可能仍为 "r"
后台渲染完成
query = "re"
deferredQuery = "re"
当 query !== deferredQuery 时,结果区域显示的是旧查询对应的内容。这个旧内容不是错误,而是一个明确的“正在追赶”状态。
3. useDeferredValue 与 startTransition 的差异
二者都可以降低某些更新对紧急交互的阻塞,但抽象层次不同。
| 机制 | 作用对象 | 常见场景 |
|---|---|---|
startTransition |
标记一组状态更新 | 切换页面、更新选项卡、提交后刷新区域 |
useDeferredValue |
延后某个已存在的值在下游的使用 | 输入框保持即时,列表使用较慢的关键字 |
可以用数据流表示:
startTransition:
事件 ──更新状态 A、B──> React 将这组更新作为非紧急工作
useDeferredValue:
状态 query ──> deferredQuery ──> 慢速结果组件
└──────────────────────> 输入框立即使用 query
如果多个状态必须一起更新,例如切换选项卡同时更新 URL、选中项和内容区域,startTransition 更符合意图。
如果只有一个值需要让下游组件延后消费,useDeferredValue 更自然。
4. 它不是防抖,也不是请求取消
以下代码不会限制网络请求频率:
const deferredQuery = useDeferredValue(query);
const results = useResults(deferredQuery);
如果 useResults 每次看到新关键字都会发请求,那么关键字变化仍可能触发多次请求。Deferred Value 解决的是 React 渲染优先级,不是请求调度。
网络请求仍需要单独考虑:
- 请求缓存;
- 相同关键字复用 Promise;
- 对不再需要的请求使用
AbortController; - 忽略过期响应;
- 处理错误和重试。
尤其是共享缓存 Promise 时,不能无条件取消某个请求,因为另一个组件可能仍然依赖同一个 Promise。
四、Suspense:组件如何声明“当前还不能完成渲染”
1. Suspense 不是通用的异步组件语法
Suspense 的基本形式是:
import { Suspense } from "react";
<Suspense fallback={<Loading />}>
<Content />
</Suspense>
当 Content 的渲染路径依赖的数据尚未准备好时,React 可以让该子树暂停,并显示 fallback。
但 Suspense 不是自动识别所有异步操作的机制。下面的代码不会被 Suspense 捕获:
function Content() {
const [data, setData] = useState(null);
useEffect(() => {
fetch("/api/data")
.then((response) => response.json())
.then(setData);
}, []);
if (!data) {
return <p>加载中</p>;
}
return <pre>{JSON.stringify(data)}</pre>;
}
这里组件第一次渲染时直接返回了 <p>加载中</p>;网络请求发生在 Effect 中,React 没有收到一个“当前渲染需要等待”的信号。
Suspense 数据读取通常依赖框架或库提供的机制,或者 React 19 的 use API 读取一个 Promise。
2. React 19 的 use
React 19 提供:
import { use } from "react";
const data = use(promise);
它的行为是:
- Promise 已完成:返回结果;
- Promise 仍在等待:当前组件树暂停,最近的 Suspense 边界显示 fallback;
- Promise 被拒绝:错误交给最近的错误边界。
use 与普通 Hook 有一个特殊区别:它可以在条件分支中使用,但仍必须遵守 React 对组件渲染的其他规则,不能在事件处理函数或 Effect 中直接调用。
3. 为什么必须缓存 Promise
下面的实现存在严重问题:
function Results({ query }: { query: string }) {
const data = use(
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then((response) => response.json()),
);
return <pre>{JSON.stringify(data)}</pre>;
}
每次 Results 渲染都会创建一个新的 Promise。若 Promise 尚未完成,组件暂停;重试时又创建新的 Promise;这可能形成持续请求、重复挂起甚至无法稳定完成的循环。
因此,Promise 应由稳定的缓存管理:
type SearchResult = {
items: Array<{
id: string;
name: string;
}>;
};
const resultCache = new Map<string, Promise<SearchResult>>();
function getSearchResult(query: string): Promise<SearchResult> {
const key = query.trim();
let promise = resultCache.get(key);
if (!promise) {
promise = fetch(`/api/search?q=${encodeURIComponent(key)}`)
.then((response) => {
if (!response.ok) {
throw new Error(`搜索请求失败:${response.status}`);
}
return response.json() as Promise<SearchResult>;
});
resultCache.set(key, promise);
}
return promise;
}
这个缓存的性质是:
同一个 query
└─> 同一个 Promise
不同 query
├─> 不同 Promise
└─> 可以并发请求
生产实现还需要限制缓存大小、设置失效策略,并根据业务决定是否缓存错误结果。
4. 一个可运行结构:输入、Deferred Value、Suspense 和错误边界
下面的例子展示四个机制如何组合。它假设应用已经有一个能够返回如下 JSON 的接口:
{
"items": [
{ "id": "1", "name": "React" }
]
}
完整组件如下:
import {
Component,
Suspense,
use,
useDeferredValue,
useState,
type ChangeEvent,
type ErrorInfo,
type ReactNode,
} from "react";
type SearchResult = {
items: Array<{
id: string;
name: string;
}>;
};
const resultCache = new Map<string, Promise<SearchResult>>();
function getSearchResult(query: string): Promise<SearchResult> {
const key = query.trim();
if (!key) {
return Promise.resolve({ items: [] });
}
const cached = resultCache.get(key);
if (cached) {
return cached;
}
const promise = fetch(`/api/search?q=${encodeURIComponent(key)}`)
.then((response) => {
if (!response.ok) {
throw new Error(`搜索请求失败:HTTP ${response.status}`);
}
return response.json() as Promise<SearchResult>;
});
resultCache.set(key, promise);
return promise;
}
function SearchResults({ query }: { query: string }) {
const result = use(getSearchResult(query));
if (result.items.length === 0) {
return <p>没有匹配结果。</p>;
}
return (
<ul>
{result.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
type ErrorBoundaryProps = {
children: ReactNode;
resetKey: string;
};
type ErrorBoundaryState = {
error: Error | null;
};
class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidUpdate(previousProps: ErrorBoundaryProps) {
if (previousProps.resetKey !== this.props.resetKey) {
this.setState({ error: null });
}
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("搜索区域渲染失败", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p>{this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>
重试
</button>
</div>
);
}
return this.props.children;
}
}
export function SearchPage() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setQuery(event.target.value);
}
return (
<main>
<label>
搜索:
<input value={query} onChange={handleChange} />
</label>
<section
aria-busy={isStale}
style={{
opacity: isStale ? 0.6 : 1,
transition: "opacity 120ms ease",
}}
>
<ErrorBoundary resetKey={deferredQuery}>
<Suspense fallback={<p>正在加载搜索结果…</p>}>
<SearchResults query={deferredQuery} />
</Suspense>
</ErrorBoundary>
</section>
</main>
);
}
5. 这个例子的完整生命周期
假设用户依次输入 r、re。
第一步:输入 r
query = "r"
deferredQuery = "" 或 "r"
输入框立即使用 query,因此字符不会延迟。
如果 deferredQuery 仍是空字符串,结果区域仍然显示空查询的结果;如果后台渲染已经完成,也可能直接开始请求 r。
第二步:输入 re
query = "re"
deferredQuery = "r"
这时:
- 输入框显示
re; - 结果区域继续显示
r的结果; - 结果区域可以通过透明度表示内容正在过渡;
- React 在后台尝试渲染
deferredQuery = "re"的树。
第三步:re 的 Promise 完成
query = "re"
deferredQuery = "re"
use(getSearchResult("re")) 获得数据,Suspense 边界可以提交新结果。
第四步:请求失败
Promise 被拒绝后,use 把错误交给错误边界,而不是交给 Suspense。于是:
Suspense 处理:Promise 尚未完成
Error Boundary 处理:Promise 已拒绝或渲染抛出错误
两者职责不同,不能用 Suspense 的 fallback 代替错误界面。
五、Suspense 边界如何与 Transition 协作
1. 没有 Transition 时的行为
假设当前页面已经显示 Overview,切换到 Details 后,Details 需要异步数据:
function App() {
const [tab, setTab] = useState<"overview" | "details">("overview");
return (
<>
<button onClick={() => setTab("overview")}>概览</button>
<button onClick={() => setTab("details")}>详情</button>
<Suspense fallback={<PageSpinner />}>
{tab === "overview" ? <Overview /> : <Details />}
</Suspense>
</>
);
}
切换到 details 后,新的子树暂停。React 可能显示整个边界的 PageSpinner,因此用户暂时看不到原来的 Overview。
这是一种合理行为:当前状态要求显示 Details,但 Details 尚未准备好。
2. 使用 Transition 保留已显示内容
function App() {
const [tab, setTab] = useState<"overview" | "details">("overview");
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: "overview" | "details") {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<button onClick={() => selectTab("overview")}>概览</button>
<button onClick={() => selectTab("details")}>详情</button>
{isPending && <span>正在切换…</span>}
<Suspense fallback={<PageSpinner />}>
{tab === "overview" ? <Overview /> : <Details />}
</Suspense>
</>
);
}
当 Transition 内部的更新导致已显示内容再次 Suspend 时,React 可以继续保留当前已提交的内容,等待新内容准备好,而不是立刻把整个边界替换为 fallback。
简化时序如下:
当前:
tab = overview
Overview 已提交
点击 Details:
tab = details 被标记为 Transition
Details 开始渲染
Details 暂停等待数据
Overview 继续显示
Details 数据完成
Details 渲染完成
React 提交 Details
这不是 Suspense 自动“保留旧内容”的普遍保证,而是 Transition 为这次更新提供了非紧急语义。若更新是紧急更新,React 可能更快展示 fallback。
3. Suspense 边界的位置决定用户看到什么
下面两个边界的用户体验不同:
<Suspense fallback={<PageSpinner />}>
<Header />
<MainContent />
<Sidebar />
</Suspense>
只要 MainContent 暂停,整个边界的内容都可能进入 fallback。
更细的划分是:
<Header />
<Suspense fallback={<MainSpinner />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
此时主内容和侧栏可以独立等待。边界不是性能魔法,而是 UI 状态协调单元:
- 哪些内容必须一起出现;
- 哪些内容可以独立加载;
- 用户在等待期间保留哪些已显示内容;
- 错误恢复应该重置哪一块。
4. Boundary 内部不要随意制造新的等待源
如果一次 Transition 会让同一个边界内部多个组件分别暂停,用户可能看到边界反复 fallback,或者等待时间变得难以解释。合理的边界设计通常围绕“用户能理解的内容单位”,而不是围绕每个请求机械拆分。
六、Deferred Value 与 Suspense 的组合:旧内容、后台新内容
useDeferredValue 特别适合搜索场景。与纯本地过滤不同,远程搜索可能触发 Suspense:
function SearchPage() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<Suspense fallback={<p>首次加载结果…</p>}>
<div style={{ opacity: query === deferredQuery ? 1 : 0.6 }}>
<SearchResults query={deferredQuery} />
</div>
</Suspense>
</>
);
}
状态变化可以分为两种情况。
首次加载
query = ""
deferredQuery = ""
SearchResults("") 尚未准备好
没有可复用的旧结果,Suspense 显示 fallback。
已有旧结果后输入新关键字
query = "react 19"
deferredQuery = "react"
SearchResults("react") 已显示
SearchResults("react 19") 在后台等待
这时可以继续显示旧结果,并降低透明度。等新 Promise 完成后,再提交新结果。
这里的关键点是:
- 输入框读取最新值;
- 结果组件读取延后的值;
- 结果区域内部仍然基于单个
deferredQuery快照; - 并没有把新关键字和旧结果误认为已经匹配;
- UI 明确通过样式或状态告诉用户结果正在更新。
如果不希望用户看到旧结果,也可以在 query !== deferredQuery 时显示独立的加载指示器。但不能仅仅依赖 Suspense fallback 来表达所有加载阶段,因为 Deferred Value 可能让旧树继续显示。
七、一致性:为什么并发渲染不会把界面“撕裂”
1. React 树内的一致提交
考虑一个列表筛选器:
function List({ query, items }: Props) {
const visibleItems = items.filter((item) =>
item.name.includes(query),
);
return (
<>
<h2>关键字:{query}</h2>
<p>数量:{visibleItems.length}</p>
<ul>
{visibleItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</>
);
}
标题、数量和列表都来自同一次组件调用中的 query。即使 React 在后台中断并重试渲染,它也不会把一次渲染调用中计算出的部分结果单独提交。
因此,一次提交满足:
标题 query = q
数量 = filter(items, q).length
列表 = filter(items, q)
而不是:
标题 query = q₂
数量 = filter(items, q₁).length
列表 = filter(items, q₁)
2. 违反一致性的常见来源:直接读取可变外部变量
下面的模式不安全:
let currentStore = {
query: "",
items: [],
};
function BadComponent() {
return (
<div>
<h2>{currentStore.query}</h2>
<List items={currentStore.items} />
</div>
);
}
如果 currentStore 在 React 渲染过程中被其他代码原地修改,组件的不同读取可能观察到不同版本的数据。React 无法管理这个普通可变对象的快照边界。
外部状态应通过适配器订阅。对于需要并发安全读取的外部 Store,应使用 useSyncExternalStore:
import { useSyncExternalStore } from "react";
type Store = {
getSnapshot(): StoreSnapshot;
subscribe(listener: () => void): () => void;
};
type StoreSnapshot = {
query: string;
items: string[];
};
const store: Store = createStore();
function StoreView() {
const snapshot = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
);
return (
<>
<h2>{snapshot.query}</h2>
<ul>
{snapshot.items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</>
);
}
useSyncExternalStore 的重点不是“让 Store 更方便”,而是为 React 提供:
- 如何订阅变化;
- 如何读取当前快照;
- 在并发渲染期间如何验证快照是否仍然一致。
3. 外部 Store 在 Transition 中的特殊边界
如果外部 Store 在一个非紧急 Transition 期间发生不可变更且无法一致读取的变化,React 可能放弃并发渲染,改用阻塞方式重新渲染,以避免用户看到不一致内容。
这意味着:
- Transition 不保证任何更新都能保持可中断;
- 外部 Store 的实现质量会影响并发效果;
getSnapshot应返回稳定、可比较的快照;- 不应在渲染期间原地修改 Store 数据。
4. 组件渲染必须保持纯
渲染函数中不应执行会产生外部可见副作用的操作:
function BadComponent() {
analytics.send("rendered"); // 不应在渲染期间发送
mutateGlobalState(); // 不应在渲染期间修改全局状态
return <div />;
}
因为 React 可能:
- 重新调用组件;
- 中断后再次调用;
- 渲染但最终不提交;
- 在开发模式中额外检查。
副作用应放到 Effect、事件处理函数或框架提供的明确生命周期中。否则同一个“未提交渲染”可能产生重复写入。
八、异步数据、缓存、过期响应与一致性
Suspense 只描述“渲染时等待数据”的 UI 协调方式,不能自动解决异步数据的所有问题。
1. 不缓存请求会导致重复工作
function DataView({ id }: { id: string }) {
const data = use(fetch(`/api/items/${id}`).then((r) => r.json()));
return <pre>{JSON.stringify(data)}</pre>;
}
每次渲染创建新的 Promise,可能造成重复请求和无限重试。必须使用稳定的资源缓存,或者使用框架的数据层。
2. 过期响应不应覆盖新状态
即使不使用 Suspense,异步请求也可能按错误顺序完成:
输入 "r" ──请求 A──────────────完成
输入 "re" ──请求 B────完成
如果 B 先完成,A 后完成,而代码无条件执行 setResults(A),界面就会被旧响应覆盖。
正确的策略包括:
- 以 query 为缓存键;
- 只从当前请求对应的资源读取;
- 使用请求序列号验证响应是否仍然有效;
- 使用
AbortController取消已经不需要的请求; - 让数据层管理去重、失效和错误。
“Transition 会丢弃旧渲染”不等于“浏览器会自动取消旧网络请求”。渲染工作和网络工作是两个生命周期。
3. Abort 与共享缓存的取舍
如果一个 Promise 只属于一个请求实例,可以在新请求开始时取消旧请求:
const controller = new AbortController();
fetch(url, { signal: controller.signal });
// 不再需要时:
controller.abort();
但如果多个组件共享同一个缓存 Promise,就不能因为其中一个消费者不再需要而直接取消请求。否则其他消费者也会收到 AbortError。
因此,取消策略必须与资源所有权一致:
独占请求:可以由拥有者取消
共享 Promise:需要引用计数或由缓存层统一管理
九、服务端渲染与 Server Components 的边界
1. Transition 是客户端交互概念
startTransition 和 useTransition 主要用于客户端状态更新和交互调度。服务端渲染过程没有浏览器输入事件,也没有客户端 DOM 提交,因此不能把服务端渲染理解成“在服务器上运行 Transition”。
服务端可以生成 HTML、流式发送 Suspense 边界,并把客户端需要的内容逐步传输;客户端收到后仍由 React 负责水合和交互。
2. 服务端 Suspense 与流式渲染
在支持 React Server Components 或流式 SSR 的框架中,服务端可以先输出页面中已经准备好的部分:
服务器开始渲染
├─ Header 已完成,先发送
├─ Main 数据未完成,保留 Suspense 边界
└─ Main 完成后再发送对应内容
浏览器先看到 fallback 或占位内容,数据准备好后再替换边界内部内容。这里的 Suspense 是服务端传输协议和客户端渲染协调的一部分,具体行为由框架的 SSR 实现决定。
3. Server Components 与客户端组件
Server Components 可以在服务端读取数据,但不能使用浏览器事件、客户端状态或客户端 Hook。需要交互的部分必须位于客户端组件边界内。
可以把职责划分为:
Server Component:
获取服务端数据
生成可序列化的组件输出
不直接处理浏览器交互
Client Component:
useState / useTransition / useDeferredValue
事件处理
浏览器 API
客户端 Suspense 和交互更新
服务端传给客户端的数据必须满足框架规定的序列化条件。不能把任意 Promise、数据库连接、函数闭包或不可序列化的类实例直接当作客户端 Props 使用。
4. 服务端缓存不等于客户端缓存
服务端请求缓存、RSC Payload 缓存、浏览器 HTTP 缓存和客户端 Promise 缓存处于不同层次:
服务端数据缓存
≠ RSC / HTML 缓存
≠ 浏览器 HTTP 缓存
≠ 客户端 React 资源缓存
某一层命中缓存,并不意味着其他层也会命中。调试“为什么仍然请求了两次”时,需要确认请求发生在哪一层,以及是否存在开发模式、预加载、重新水合或框架重验证行为。
十、错误处理:Suspense 只处理等待,不处理失败
一个完整的数据边界通常同时需要 Suspense 和 Error Boundary:
<ErrorBoundary resetKey={query}>
<Suspense fallback={<ResultsSkeleton />}>
<Results query={query} />
</Suspense>
</ErrorBoundary>
状态转移可以表示为:
资源不存在
│
▼
Promise pending ──────────────┐
│ │
│ resolve │ reject
▼ ▼
正常内容 Error Boundary
需要注意以下边界:
- Suspense fallback 用于等待;
- Error Boundary 用于渲染错误和 Promise rejection;
- 错误边界不会捕获事件处理函数中的异常;
- 错误边界也不自动捕获服务端请求的所有错误;
- 错误恢复通常需要重置边界状态,或者改变资源键。
上面的 resetKey={deferredQuery} 是一种常见恢复方式:当搜索关键字变化时,错误边界清除旧错误,允许新资源重新渲染。重试按钮则清除当前错误状态;如果缓存永久保存了 rejected Promise,重试前还必须删除对应缓存项,否则会立即再次抛出同一个错误。
十一、常见误解与失败表现
误解一:Transition 会让代码执行得更快
不会。Transition 主要改变“何时做、是否可被打断”,不改变单次计算的算法复杂度。
如果列表过滤是:
Transition 不会把它变成:
如果每次渲染都创建大量对象,仍应通过合理的数据结构、组件拆分和 memoization 减少实际工作。
误解二:Deferred Value 是防抖
防抖的模型是:
输入停止一段固定时间后,才触发一次操作
Deferred Value 的模型是:
输入立即更新
下游渲染由 React 按优先级推进
它没有固定等待时间,也不保证变化次数减少。
误解三:加上 Suspense 就自动拥有数据缓存
Suspense 需要一个能在渲染时提供“完成、等待或失败”状态的资源。它不负责决定:
- 请求是否去重;
- 数据缓存多久;
- 如何失效;
- 是否取消请求;
- 如何重试;
- 哪些错误应该展示给用户。
这些职责属于数据层或框架。
误解四:fallback 等于全局加载状态
Fallback 只代表对应 Suspense 边界中的内容尚未准备好。如果页面有多个边界,一个边界进入 fallback 不代表整个应用正在加载。
因此,加载提示应放在表达正确范围的位置。把整个应用包在一个巨大 Suspense 边界中,容易导致局部数据缺失时整页闪烁。
误解五:旧内容显示就是数据不一致
Deferred Value 或 Transition 下继续显示旧内容可能是有意的“过渡状态”,不等于撕裂。关键区别在于:
可接受:
输入框显示新 query,结果区域明确显示旧 query 的完整结果
不可接受:
标题显示新 query,数量和列表分别来自不同 query
前者是显式的延迟策略,后者是同一界面内部的快照混合。
十二、诊断并发问题的方法
1. 先判断问题发生在哪个阶段
遇到“输入卡顿”“页面闪烁”“结果错乱”时,先区分:
输入事件处理很慢?
└─ 可能是同步计算或事件处理函数过重
Render 很慢?
└─ 可能是组件树过大、重复计算、对象不稳定
Commit 后闪烁?
└─ 可能是 Suspense 边界、布局副作用或加载状态设计问题
请求顺序错误?
└─ 可能是缓存、取消或过期响应处理错误
外部状态撕裂?
└─ 可能是直接读取可变 Store
2. 记录逻辑快照,而不是只记录 DOM
可以在组件中记录:
console.log({
query,
deferredQuery,
});
并在数据层记录:
console.log("request start", query);
console.log("request resolve", query);
console.log("request reject", query);
观察以下关系:
组件提交的 deferredQuery
是否对应实际读取的资源键?
请求完成后是否覆盖了当前查询?
错误边界重试时是否创建了新的资源?
3. 使用 React DevTools Profiler
Profiler 可以帮助确认:
- 哪些组件在一次输入中重新渲染;
- 哪些渲染是由状态、Props 还是 Context 触发;
- Transition 是否真的把工作延后;
- 是否存在本可以避免的重复渲染。
但 Profiler 不能代替数据一致性检查。一个渲染很快的错误缓存仍然可能返回过期数据。
十三、工程取舍:四种机制如何选择
可以按状态来源和问题类型选择:
只需要让复杂下游落后
使用:
const deferredValue = useDeferredValue(value);
适合:
- 搜索结果;
- 本地大列表;
- 预览区域;
- 图表过滤。
需要把多个更新作为一次非紧急操作
使用:
startTransition(() => {
setPage(nextPage);
setSelectedId(nextId);
});
适合:
- 选项卡切换;
- 路由切换;
- 页面级状态转换;
- 提交后刷新非关键区域。
渲染依赖尚未完成的数据
使用:
<Suspense fallback={<Loading />}>
<AsyncContent />
</Suspense>
前提是数据读取机制真正支持 Suspense,例如框架提供的资源、React 19 的 use 配合稳定 Promise,或兼容的第三方数据层。
需要处理外部可变状态的一致快照
使用:
useSyncExternalStore(subscribe, getSnapshot)
不要在组件渲染期间直接读取和修改未受 React 管理的可变对象。
十四、最终检查:一条完整的数据流
一个较完整的搜索交互可以按下面的路径组织:
flowchart LR
A[用户输入] --> B[紧急状态 query]
B --> C[输入框立即更新]
B --> D[useDeferredValue]
D --> E[资源缓存按 query 查找]
E --> F{Promise 状态}
F -->|pending| G[Suspense fallback 或保留旧内容]
F -->|fulfilled| H[结果组件读取数据]
F -->|rejected| I[Error Boundary]
H --> J[基于同一 query 快照提交]
I --> K[重试或更换 query]
这条链路中每个机制负责不同问题:
query保证用户输入立即反馈;useDeferredValue允许慢速结果落后;- 资源缓存避免每次重试都创建新请求;
Suspense协调等待中的 UI;Error Boundary处理失败;- React 的原子提交保证结果区域不会由不同渲染快照拼接而成。
并发渲染的核心不是“让所有事情后台执行”,而是把更新分成可以被用户感知的优先级,并在渲染可能暂停、重试和重排的前提下,仍然只提交一致的界面快照。Transition 负责表达更新意图,Deferred Value 负责延后下游消费,Suspense 负责声明等待边界,而缓存、取消、错误恢复和外部 Store 适配则决定这套机制能否在真实应用中稳定工作。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 性能优化:Profiler、Memo、更新边界、长列表和 Bundle
- 下一篇:React 可访问性:语义、键盘、焦点、ARIA、Portal 和动态内容
- 延伸:React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
- 延伸:React Server Components:服务端边界、序列化、缓存和客户端交互
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论