React 基础体系 · 第 11/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复
异步数据组件同时面对五个不同问题:
- 请求取消:组件不再需要结果时,如何停止网络请求,并避免过期结果写入状态。
- 缓存:相同数据如何复用,如何区分“正在请求”“已经成功”“已经失败”和“需要重新验证”。
- 并发:多个组件或多个渲染版本同时读取数据时,如何避免重复请求、竞态和不一致。
- Suspense:组件如何声明“数据尚未准备好”,并把等待状态交给最近的边界。
- 错误恢复:请求失败后,如何展示错误、重试、清理坏缓存,并保证恢复过程不会再次使用旧错误。
这些概念相关,但不是同一个层次。AbortController 解决的是请求生命周期;缓存解决的是数据复用与请求合并;并发渲染解决的是多个 UI 版本的协调;Suspense 解决的是渲染期间的等待;错误边界解决的是渲染错误的隔离。把它们混为一个“异步 Hook”通常会导致边界条件丢失。
一、先区分三类异步数据状态
一个请求通常至少有以下状态:
absent —— 没有缓存,也没有进行中的请求
pending —— 请求进行中
success —— 请求成功,缓存中有数据
error —— 请求失败,缓存中有错误
stale —— 有旧数据,但需要重新验证
stale 不是 HTTP 标准状态,而是应用缓存中的语义:旧数据仍然可以展示,但不能认为它已经代表服务器当前状态。
可以把一次读取抽象为:
在普通 React 状态代码中,组件会把这三种结果转换成 loading/data/error 状态;在 Suspense 模型中,组件直接读取资源:
- 有数据:返回数据,继续渲染;
- 没数据但正在请求:抛出 Promise,暂停当前子树;
- 请求失败:抛出 Error,交给错误边界。
这里的“抛出 Promise”不是普通业务代码推荐的控制流,而是 Suspense 约定的渲染协议。只有被 Suspense 识别的渲染路径才应该使用这种方式。
二、请求取消:停止网络活动并防止过期结果
2.1 AbortController 的语义
浏览器的 fetch 接受一个 AbortSignal:
const controller = new AbortController();
fetch("/api/users/42", {
signal: controller.signal,
}).then(async response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
});
controller.abort();
调用 abort() 后:
fetchPromise 通常以DOMException、AbortError或其他环境相关的中止错误拒绝;- 浏览器可以停止尚未完成的网络工作;
- 服务器是否已经收到请求、是否已经完成处理,不由客户端保证;
- 取消不会撤销服务器已经执行的副作用。
因此,取消请求不是“回滚请求”。对于 GET,它通常用于节省客户端和网络资源;对于写操作,不能依赖客户端取消实现事务回滚,服务端仍应设计幂等性、请求 ID 或事务语义。
2.2 Effect 中的取消和竞态防护
当请求由 useEffect 触发时,最小正确结构如下:
import { useEffect, useState } from "react";
type User = {
id: string;
name: string;
};
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };
export function UserPanel({ userId }: { userId: string }) {
const [state, setState] = useState<State>({ status: "idle" });
useEffect(() => {
const controller = new AbortController();
let active = true;
setState({ status: "loading" });
fetch(`/api/users/${encodeURIComponent(userId)}`, {
signal: controller.signal,
})
.then(async response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as User;
})
.then(data => {
if (active) {
setState({ status: "success", data });
}
})
.catch(error => {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
if (active) {
setState({
status: "error",
error: error instanceof Error ? error : new Error(String(error)),
});
}
});
return () => {
active = false;
controller.abort();
};
}, [userId]);
switch (state.status) {
case "idle":
case "loading":
return <p>加载中……</p>;
case "error":
return <p role="alert">加载失败:{state.error.message}</p>;
case "success":
return <p>{state.data.name}</p>;
}
}
这个例子中有两个保护层:
controller.abort():尝试停止旧请求;active = false:即使底层实现没有及时停止,旧 Promise 完成后也不能更新当前组件。
第二层仍然需要,因为“请求被取消”和“回调一定不会执行”不是同一个保证。某些工作可能已经完成,或者并非所有异步步骤都真正连接到了 AbortSignal。
2.3 竞态是如何发生的
假设组件先显示用户 A,然后快速切换到用户 B:
t0: 发起请求 A
t1: userId 改为 B
t2: 发起请求 B
t3: B 返回,显示 B
t4: A 返回
如果没有清理和版本检查,t4 会把 A 写回屏幕,导致 UI 显示错误用户。
形式化地说,每次参数变化都产生一个版本号:
只有满足:
的结果才允许提交到当前 UI。active 标志就是一个简单的版本有效性判断;也可以使用递增请求 ID:
const requestId = useRef(0);
useEffect(() => {
const id = ++requestId.current;
fetchData().then(data => {
if (id === requestId.current) {
setData(data);
}
});
}, [key]);
不过,useEffect 中的异步请求仍然属于“提交后的外部同步”。React 可以在渲染阶段反复计算组件,但 Effect 只应在组件提交后执行;不能在组件函数体中直接启动一个不可控的副作用请求。
三、缓存不只是 Map:它还要描述生命周期
3.1 请求缓存和结果缓存
应用层缓存至少有两种复用:
- 请求去重:同一个键已有进行中的 Promise,后来的读取共享它;
- 结果复用:请求已经成功,后来的读取直接使用结果。
只做结果缓存不能避免并发重复请求。以下代码在两个组件同时执行时可能发起两次请求:
const cache = new Map<string, User>();
async function getUser(id: string) {
const cached = cache.get(id);
if (cached) {
return cached;
}
const response = await fetch(`/api/users/${id}`);
const data = (await response.json()) as User;
cache.set(id, data);
return data;
}
两个调用都可能在 cache.set 之前观察到缓存为空。要实现请求去重,缓存中必须先保存 Promise:
const pending = new Map<string, Promise<User>>();
function getUserOnce(id: string): Promise<User> {
const existing = pending.get(id);
if (existing) {
return existing;
}
const request = fetch(`/api/users/${encodeURIComponent(id)}`)
.then(async response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return (await response.json()) as User;
})
.finally(() => {
pending.delete(id);
});
pending.set(id, request);
return request;
}
这里的关键顺序是:
- 创建 Promise;
- 立即放入
pending; - 返回 Promise;
- 完成后移除进行中记录。
如果先异步执行再放入缓存,同一事件循环中的后续调用仍可能重复创建请求。
3.2 HTTP 缓存和 React 数据缓存不是一回事
浏览器 HTTP 缓存由响应头控制,例如:
Cache-Control: max-age=60, stale-while-revalidate=300
ETag: "user-42-v7"
它决定浏览器是否复用网络响应、是否发送条件请求。React 数据缓存则决定:
- 哪些组件共享数据;
- 哪个键对应哪份数据;
- 失败是否缓存;
- 何时重新验证;
- 切换路由时是否保留内存中的结果。
两者可以协同,但不能互相替代。应用内缓存命中了,不代表浏览器 HTTP 缓存命中了;应用内缓存未命中,也不代表一定会走真实网络。
3.3 一个可用于 Suspense 的最小资源缓存
下面的代码实现了:
- 按键缓存;
- 进行中请求共享;
- 成功结果缓存;
- 错误缓存;
read()返回、抛 Promise 或抛 Error;- 手动失效。
import { use } from "react";
type Entry<T> =
| { status: "pending"; promise: Promise<void> }
| { status: "success"; value: T }
| { status: "error"; error: Error };
const userCache = new Map<string, Entry<User>>();
type User = {
id: string;
name: string;
};
function loadUser(id: string): Promise<User> {
return fetch(`/api/users/${encodeURIComponent(id)}`).then(async response => {
if (!response.ok) {
throw new Error(`加载用户失败:HTTP ${response.status}`);
}
return (await response.json()) as User;
});
}
function getUserEntry(id: string): Entry<User> {
const existing = userCache.get(id);
if (existing) {
return existing;
}
let entry: Entry<User>;
const promise = loadUser(id).then(
value => {
entry = { status: "success", value };
userCache.set(id, entry);
},
reason => {
const error =
reason instanceof Error ? reason : new Error(String(reason));
entry = { status: "error", error };
userCache.set(id, entry);
},
);
entry = { status: "pending", promise };
userCache.set(id, entry);
return entry;
}
function readUser(id: string): User {
const entry = getUserEntry(id);
if (entry.status === "pending") {
throw entry.promise;
}
if (entry.status === "error") {
throw entry.error;
}
return entry.value;
}
function invalidateUser(id: string) {
userCache.delete(id);
}
export function UserView({ userId }: { userId: string }) {
const user = readUser(userId);
return <p>{user.name}</p>;
}
这段实现有一个容易忽略的细节:Promise 的拒绝被第二个回调消费并转换成缓存中的 error,因此不会留下一个无人处理的拒绝。readUser 后续会主动抛出这个错误,让错误边界接管。
使用 use 读取 Promise 也是 React 19 的能力之一:
import { use } from "react";
function UserViewWithUse({ userId }: { userId: string }) {
const promise = getUserPromise(userId);
const user = use(promise);
return <p>{user.name}</p>;
}
但 getUserPromise(userId) 必须返回稳定的、可缓存的 Promise。下面这种写法会在每次渲染都创建新 Promise:
function BrokenUserView({ userId }: { userId: string }) {
const user = use(loadUser(userId)); // 每次渲染都可能创建新请求
return <p>{user.name}</p>;
}
如果该 Promise 被挂起并触发重新渲染,组件可能再次创建 Promise,形成请求循环或重复请求。正确做法是把 Promise 放入模块缓存、框架数据缓存,或者由服务端/父组件创建并稳定传递。
四、Suspense:渲染等待协议,而不是请求库
4.1 Suspense 的控制流
Suspense 的基本结构如下:
import { Suspense } from "react";
export function Page() {
return (
<Suspense fallback={<p>正在加载用户……</p>}>
<UserView userId="42" />
</Suspense>
);
}
当 UserView 在渲染期间抛出一个仍未完成的 Promise 时:
- React 捕获这个 Promise;
- React 渲染
fallback; - Promise 完成后安排重试;
- 重试时,如果资源已有成功值,
UserView正常返回 JSX; - 如果资源变成错误,读取时抛出 Error,由错误边界处理。
对应的时序可以表示为:
sequenceDiagram
participant R as React 渲染
participant C as 数据缓存
participant N as 网络请求
participant S as Suspense
participant E as ErrorBoundary
R->>C: read(key)
C->>N: 首次读取,发起请求
C-->>R: 抛出 pending Promise
R->>S: 捕获 Promise
S-->>R: 渲染 fallback
N-->>C: 成功或失败
C-->>R: 安排重试
R->>C: 再次 read(key)
alt 成功
C-->>R: 返回数据
R-->>R: 渲染内容
else 失败
C-->>R: 抛出 Error
R->>E: 错误边界捕获
E-->>R: 渲染错误 UI
end
Suspense 本身不负责:
- 发起 HTTP 请求;
- 取消请求;
- 缓存请求;
- 定义重试次数;
- 记录服务端错误;
- 自动提供错误 UI。
它只处理“这棵子树当前无法完成渲染”的协调。
4.2 Suspense 边界的位置决定加载体验
边界太外层时,整个页面可能被一个请求阻塞:
<Suspense fallback={<PageSkeleton />}>
<Header />
<UserPanel />
<Recommendations />
</Suspense>
UserPanel 尚未准备好时,Header 和 Recommendations 也会被一起替换。
边界更细时,可以让已准备好的内容先显示:
<>
<Header />
<Suspense fallback={<UserSkeleton />}>
<UserPanel userId="42" />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</>
边界划分不是性能数字上的固定规则,而是产品加载结构的表达:哪些内容必须一起出现,哪些内容可以独立等待。
五、并发:请求并行、渲染并发和 UI 优先级是三件事
5.1 请求并行不等于 React 并发渲染
以下两个请求可以并行发生:
const [user, posts] = await Promise.all([
getUserOnce("42"),
getPostsOnce("42"),
]);
这是 JavaScript Promise 和网络调度层面的并行。
React 并发渲染则是:React 可以在不阻塞用户交互的前提下,开始计算一个低优先级 UI,在需要时暂停、丢弃或重新计算它。它不保证:
- 浏览器一定同时执行所有代码;
- HTTP 请求自动并行;
- 所有渲染都在后台线程执行;
startTransition会取消网络请求。
例如:
import { startTransition, useState } from "react";
function SearchBox() {
const [input, setInput] = useState("");
const [query, setQuery] = useState("");
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
const next = event.target.value;
setInput(next);
startTransition(() => {
setQuery(next);
});
}
return (
<>
<input value={input} onChange={onChange} />
<SearchResults query={query} />
</>
);
}
这里输入框更新是紧急更新,搜索结果更新是 Transition。React 可以优先保持输入响应,再处理结果区域。
但如果 SearchResults 内部基于 query 发起请求,旧请求是否取消仍需要数据层显式处理。Transition 改变的是 React 更新优先级,不是 fetch 的 signal。
5.2 Transition 与 Suspense 的关系
如果低优先级更新触发了 Suspense,React 可以尽量保留当前已经显示的内容,而不是立刻把整个区域替换成加载占位:
import { startTransition, useState } from "react";
function ProfileSwitcher() {
const [profileId, setProfileId] = useState("a");
function switchProfile(id: string) {
startTransition(() => {
setProfileId(id);
});
}
return (
<>
<button onClick={() => switchProfile("a")}>A</button>
<button onClick={() => switchProfile("b")}>B</button>
<Suspense fallback={<p>加载资料……</p>}>
<Profile id={profileId} />
</Suspense>
</>
);
}
这不表示旧数据和新数据可以随意混合。React 仍然需要维护一致的组件树;Suspense 只控制未完成子树的展示策略。若数据键发生变化,缓存层必须分别保存 a 和 b 的数据,不能使用一个没有键约束的全局变量。
5.3 useDeferredValue 的含义
useDeferredValue 延后的是一个值在 UI 中的传播:
const deferredQuery = useDeferredValue(query);
它可以让昂贵的结果列表晚于输入框更新,但它不是请求去重器,也不是取消器。用它之后,旧的 deferredQuery 仍可能触发读取;数据层应通过键缓存共享请求,并在不再需要时决定是否取消。
六、错误传播:Promise 等待和 Error 失败必须分开处理
一个完整的 Suspense 树通常同时需要两个边界:
<ErrorBoundary>
<Suspense fallback={<p>加载中……</p>}>
<UserView userId="42" />
</Suspense>
</ErrorBoundary>
执行路径是:
pending Promise → Suspense fallback
rejected Promise → 缓存 Error → ErrorBoundary fallback
success value → 正常 UI
如果只有 Suspense 而没有错误边界,请求失败时不会自动得到友好的错误页面。反过来,如果只有错误边界而没有 Suspense,等待期间也没有合适的加载占位。
6.1 一个可重试的错误边界
React 的错误边界通常使用类组件实现。它捕获后代渲染、生命周期和构造过程中的错误,但不能自动捕获:
- 事件处理函数中的错误;
- 任意异步回调中的错误;
- 错误边界自身抛出的错误;
- 某些服务端渲染阶段的错误,具体行为由框架负责。
import React from "react";
type ErrorBoundaryProps = {
children: React.ReactNode;
resetKey: string;
};
type ErrorBoundaryState = {
error: Error | null;
};
class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return {
error: error instanceof Error ? error : new Error(String(error)),
};
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("渲染错误", error, info.componentStack);
}
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (prevProps.resetKey !== this.props.resetKey && this.state.error) {
this.setState({ error: null });
}
}
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;
}
}
但仅清除错误边界状态还不够。如果数据缓存仍然保留失败状态:
缓存:error
边界:清空 error,重新渲染
读取缓存:仍然抛出同一个 error
边界:再次显示错误
重试必须同时完成两个动作:
- 让错误边界允许子树重新渲染;
- 删除或重新验证对应的数据缓存。
例如:
function UserSection({ userId }: { userId: string }) {
const [attempt, setAttempt] = useState(0);
function retry() {
invalidateUser(userId);
setAttempt(value => value + 1);
}
return (
<ErrorBoundary resetKey={`${userId}:${attempt}`}>
<Suspense fallback={<p>加载中……</p>}>
<UserView key={`${userId}:${attempt}`} userId={userId} />
</Suspense>
<button onClick={retry}>重新加载</button>
</ErrorBoundary>
);
}
示例中 key 和 resetKey 都参与恢复:
invalidateUser使数据读取重新发请求;resetKey清除错误边界状态;- 子树
key可用于重新挂载本地状态,但不应被误认为缓存失效机制。
生产代码通常会把重试操作封装到数据层,避免页面组件分别操作缓存和边界。
七、失败缓存、重试和取消之间的关系
7.1 是否缓存错误
失败结果是否进入缓存取决于错误类型:
| 错误 | 通常策略 |
|---|---|
| 用户主动取消 | 不作为业务失败缓存 |
| 网络瞬断 | 可短时间缓存或立即重试,但应有限制 |
401 |
交给认证流程,不应无止境重试 |
403 |
通常缓存到权限状态变化 |
404 |
可缓存一段时间,避免反复请求不存在资源 |
429 |
遵守 Retry-After,不能立即密集重试 |
5xx |
指数退避、限制次数,并保留可恢复 UI |
如果所有错误都永久缓存,临时网络故障会变成永久错误;如果所有错误都立即重试,服务端故障会被放大。
7.2 取消请求不能直接删除共享 Promise
假设两个组件共享同一个请求:
组件 A ─┐
├── GET /api/users/42
组件 B ─┘
A 卸载时调用 abort(),如果 B 仍然需要数据,A 的取消会错误地中断 B 的请求。
因此,共享缓存中的取消通常需要引用计数或订阅管理:
引用数 = 2
A 卸载 → 引用数 = 1,不取消
B 卸载 → 引用数 = 0,可在延迟后取消
但 Suspense 的读取本身并不自动提供一个简单的“订阅结束回调”。因此,自己实现 Suspense 缓存时,常见取舍是:
- 只做请求共享,不主动取消共享 Promise;
- 将取消用于输入搜索等 Effect 驱动请求;
- 使用成熟的数据框架,由其管理订阅、缓存和垃圾回收;
- 设计明确的资源生命周期,而不是在每个
read()后猜测组件是否仍然存在。
“请求已经没有当前消费者”与“请求暂时没有完成渲染”也不是完全相同的状态,尤其在并发渲染中不能仅凭一次 render 判断资源已经无用。
八、缓存一致性:键、时间和失效必须明确
8.1 缓存键必须完整表达请求输入
以下请求不能共用同一个 "users" 键:
GET /api/users?page=1&sort=name
GET /api/users?page=2&sort=name
GET /api/users?page=1&sort=createdAt
一个缓存键至少应包含所有影响响应的输入:
type UserListKey = {
page: number;
sort: "name" | "createdAt";
filter: string;
};
function keyOf(input: UserListKey) {
return JSON.stringify([
"users",
input.page,
input.sort,
input.filter,
]);
}
如果遗漏了 sort,缓存命中时得到的是“结构正确但语义错误”的数据,这类问题通常比直接报错更难诊断。
8.2 TTL、SWR 和失效
设缓存条目为:
type CacheEntry<T> = {
value: T;
fetchedAt: number;
};
若当前时间为 ,获取时间为 ,TTL 为 ,则:
直觉是:数据年龄没有超过允许的新鲜窗口,可以直接使用。
在 stale-while-revalidate 模式下:
- 新鲜:直接返回;
- 过期但有旧值:先返回旧值,同时后台重新请求;
- 没有值:等待首次请求;
- 重新验证失败:可以继续展示旧值,同时记录错误或在下次读取时提示。
这与 Suspense 的首次读取不同。首次读取没有可显示的数据,通常需要挂起;重新验证已有旧数据时,产品可能更希望保持旧 UI,只在局部显示“正在更新”。
8.3 失效不是“把所有缓存清空”
更新用户资料后,至少可能影响:
user:42
users:list:page=1
dashboard:summary
只删除 user:42 未必足够;清空整个缓存又可能造成大量重复请求。失效策略必须根据数据依赖设计:
- 精确键失效;
- 标签或资源族失效;
- 服务端返回版本号;
- 使用 HTTP
ETag重新验证; - 通过事件或 WebSocket 推送变更。
React 不会自动知道“修改用户资料应该使列表缓存失效”。这是数据层或应用协议的职责。
九、端到端示例:Suspense、错误边界和手动重试
下面组合前面的资源缓存、Suspense 和错误恢复:
import { Suspense, useState } from "react";
import { ErrorBoundary } from "./ErrorBoundary";
export function UserRoute() {
const [retryCount, setRetryCount] = useState(0);
const userId = "42";
function retry() {
invalidateUser(userId);
setRetryCount(count => count + 1);
}
return (
<ErrorBoundary resetKey={`${userId}:${retryCount}`}>
<Suspense fallback={<UserFallback />}>
<UserView key={`${userId}:${retryCount}`} userId={userId} />
</Suspense>
<button onClick={retry}>重新加载用户</button>
</ErrorBoundary>
);
}
function UserFallback() {
return (
<section aria-busy="true">
<p>正在加载用户资料……</p>
</section>
);
}
执行过程如下:
首次访问
userCache 没有 42
→ 创建 fetch Promise
→ 写入 pending
→ readUser 抛出 Promise
→ Suspense 显示 UserFallback
请求成功
Promise 完成
→ pending 改为 success
→ React 重试 UserView
→ readUser 返回 User
→ 显示用户资料
请求失败
Promise 拒绝
→ pending 改为 error
→ React 重试 UserView
→ readUser 抛出 Error
→ ErrorBoundary 显示错误
点击重试
invalidateUser("42")
→ 删除 error 条目
→ retryCount 增加
→ ErrorBoundary 清除自身错误
→ UserView 重新读取
→ 创建新请求
→ 重新经历 pending/success 或 pending/error
这个示例使用了简单的全局内存缓存,因此只适合演示机制。生产实现还需要考虑缓存大小、过期、服务端渲染隔离、认证信息、请求取消、日志和多标签页同步。
十、服务端组件和客户端组件的边界
React 19 的异步能力必须结合运行环境理解。
10.1 服务端数据读取
在支持 React Server Components 的框架中,服务端组件可以在服务端直接等待数据:
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const user = await fetchUserOnServer(id);
return <h1>{user.name}</h1>;
}
但这段代码的具体可用形式取决于框架的路由、服务端组件和 fetch 缓存策略。React 本身不规定所有框架都必须以相同方式缓存 fetch。
服务端数据读取的特点是:
- 请求通常发生在服务端;
- 服务端可以把结果流式传给客户端;
- 服务端请求取消可能来自客户端断开连接,但是否传递到下游由运行时和框架决定;
- 服务端模块级缓存必须考虑进程、请求和租户边界。
尤其不能把带用户身份的数据无条件放进全局服务端缓存,否则可能发生跨用户数据泄漏。服务端缓存键必须包含必要的身份、权限和区域上下文,或者使用请求隔离的缓存。
10.2 客户端组件
客户端组件不能假设服务端变量、数据库连接或服务端私密凭据可直接使用。客户端请求应通过公开 HTTP API 或框架提供的安全数据通道完成。
use 可以在支持的 React 19 环境中读取 Promise,但客户端 Promise 必须稳定。常见来源包括:
- 父组件从服务端传下来的 Promise;
- 模块级资源缓存;
- 框架提供的路由数据;
- 专门的数据缓存库。
不能把带认证密钥的服务端 Promise 或服务器内部对象直接暴露给客户端。
10.3 预取与真正读取
预取是提前开始请求:
function preloadUser(id: string) {
getUserEntry(id);
}
真正读取仍然发生在组件渲染时:
function UserView({ userId }: { userId: string }) {
const user = readUser(userId);
return <p>{user.name}</p>;
}
预取可以缩短等待,但不能保证请求成功,也不能代替错误处理。用户没有进入对应页面时,预取还可能浪费资源,因此应根据交互概率、网络状态和缓存容量取舍。
十一、常见错误及诊断路径
错误一:在组件函数体中直接 fetch
function BadComponent() {
fetch("/api/data"); // 每次渲染都可能执行
return <div />;
}
失败表现是重复请求、开发模式下请求次数增加、渲染重试时请求失控。诊断时检查 Network 面板中的请求数量,并确认请求是否位于 Effect、稳定缓存或框架数据层中。
错误二:只取消请求,不保护状态提交
如果底层请求库没有完全遵守取消信号,旧请求仍可能调用回调。诊断方法是记录:
请求 ID、参数、开始时间、结束时间、是否 aborted、提交时的当前参数
如果日志显示旧参数的完成回调提交到了新参数组件,就需要增加版本检查或 active 标志。
错误三:把所有错误都当成可重试错误
401、403、参数校验失败和权限错误通常不能通过重复发送相同请求解决。重试前应区分:
type AppError =
| { kind: "aborted" }
| { kind: "http"; status: number; message: string }
| { kind: "network"; cause: unknown }
| { kind: "parse"; cause: unknown };
解析错误尤其重要:HTTP 状态为 200 不代表响应体一定符合预期。response.json() 也可能失败,数据层应把它纳入错误路径。
错误四:错误边界重试但不清缓存
如果失败条目永久保存在缓存中,边界每次恢复都会立即再次抛出相同错误。诊断时同时查看:
ErrorBoundary 是否已清除 error
数据缓存是否仍为 error
重试是否生成了新的请求 ID
错误五:把 Strict Mode 的重复 Effect 当成真实重复请求
开发环境的 Strict Mode 可能通过额外的挂载、清理和重新挂载帮助发现副作用问题。Effect 必须能够正确清理,但不能靠“全局布尔值”粗暴屏蔽第二次执行。应使用 AbortController、幂等请求设计和正确的缓存去重。
错误六:把 startTransition 当成取消器
Transition 不会调用 AbortController.abort(),也不保证旧 HTTP 请求停止。若搜索输入快速变化,需要数据层显式取消旧请求,或至少让旧结果无法提交。
十二、如何选择实现层次
可以按需求选择不同方案:
仅一次性组件请求
使用 useEffect + AbortController + active 标志,适合简单的客户端交互。
多组件共享数据
增加按键的 Promise 缓存和结果缓存,明确失效、过期和错误策略。
Suspense 驱动的读取
使用稳定资源缓存,让读取结果遵守“返回值、抛 Promise、抛 Error”的协议,并配置 Suspense 与错误边界。
路由级数据和服务端渲染
优先使用框架的数据加载、预取、流式渲染和请求缓存能力,同时检查其服务端缓存隔离规则。
复杂生产数据层
当需求包含分页、无限滚动、重试、焦点恢复、离线、乐观更新、跨组件失效、垃圾回收和多标签页同步时,手写一个 Map 通常不足。应选择明确实现这些语义的数据层,但仍需理解底层机制,因为缓存库无法改变 HTTP 取消、React 边界和服务端权限的基本事实。
结语
可靠的 React 异步数据流可以按以下因果关系建立:
请求生命周期
→ AbortSignal 与清理
→ 过期结果不能提交
数据复用
→ 稳定缓存键
→ 共享 pending Promise
→ 结果、错误、过期和失效状态
React 并发协调
→ Transition / Deferred Value 调整 UI 优先级
→ 不自动取消网络请求
→ 仍需保持数据键和提交一致性
渲染等待
→ pending Promise 交给 Suspense
→ 成功值继续渲染
→ Error 交给错误边界
错误恢复
→ 分类错误
→ 清理或重新验证坏缓存
→ 重置边界和局部 UI 状态
请求取消解决“不要继续做无用工作”,缓存解决“不要重复做相同工作”,并发解决“多个 UI 版本如何协调”,Suspense 解决“未完成数据如何参与渲染”,错误恢复解决“失败后如何回到可用状态”。只有把这些责任分开,再用明确的数据流连接起来,异步组件才不会在快速切换、重复渲染、网络失败和服务端边界下失去一致性。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React Router 完整指南:嵌套路由、Loader、Action、错误与权限
- 下一篇:React 状态管理选型:Redux Toolkit、Zustand、服务端缓存和边界
- 延伸:React Effect 完整指南:同步外部系统、依赖、清理和竞态
- 延伸:React 并发渲染:Transition、Deferred Value、Suspense 和一致性
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论