React 与异步数据:SWR 模型、Suspense 思路与错误边界
前端应用一旦复杂起来,最折腾人的往往不是单次请求,而是「请求 + 缓存 + 失效 + 重试 + 乐观更新」这一整套。
和其说是在写 Ajax,不如说是在写一个小型数据层。
SWR 模型:请求不只是“发一次”
SWR(stale-while-revalidate)的基本想法可以概括成一句话:
先用旧数据顶上去,再在后台偷偷拉一遍新数据更新。
一个典型的数据流是这样的:
- 组件挂载,先从缓存里拿到旧数据(如果有),立即渲染,避免白屏;
- 后台再发一遍请求拿最新数据;
- 请求成功后,更新缓存和所有消费这个缓存的组件。
像 SWR、React Query 这类库,本质上就是在实现这样一套数据层行为:
- 把「以 key 为索引的缓存」抽象出来;
- 掌控请求的生命周期(loading、error、success);
- 管缓存的失效策略和并发请求。
用 React Query 管服务器状态
以 React Query 为例,入口是一个 QueryClient:
1const queryClient = new QueryClient();
2
3function App() {
4 return (
5 <QueryClientProvider client={queryClient}>
6 <Root />
7 </QueryClientProvider>
8 );
9}
组件里声明「我要什么数据」:
1function TodoList() {
2 const { data, isLoading, error } = useQuery({
3 queryKey: ['todos'],
4 queryFn: () => fetch('/api/todos').then(r => r.json()),
5 });
6
7 if (isLoading) return <div>加载中...</div>;
8 if (error) return <div>出错了</div>;
9
10 return (
11 <ul>
12 {data.map((todo: any) => (
13 <li key={todo.id}>{todo.title}</li>
14 ))}
15 </ul>
16 );
17}
几个直接的好处:
- 多个组件共用同一份缓存,不会各自发请求;
- 自动处理 loading、error 状态;
- 自带重试、轮询、失效等策略。
写入与乐观更新
数据不只是「读」,还要「写」。
以 React Query 的 useMutation 为例,可以在写入时做乐观更新:
1function useToggleTodo() {
2 const queryClient = useQueryClient();
3
4 return useMutation({
5 mutationFn: (id: string) =>
6 fetch(`/api/todos/${id}/toggle`, { method: 'POST' }),
7 onMutate: async (id: string) => {
8 await queryClient.cancelQueries({ queryKey: ['todos'] });
9
10 const previous = queryClient.getQueryData<any[]>(['todos']);
11
12 queryClient.setQueryData<any[]>(['todos'], old =>
13 old?.map(todo =>
14 todo.id === id ? { ...todo, done: !todo.done } : todo
15 ) ?? []
16 );
17
18 return { previous };
19 },
20 onError: (_err, _id, context) => {
21 if (context?.previous) {
22 queryClient.setQueryData(['todos'], context.previous);
23 }
24 },
25 onSettled: () => {
26 queryClient.invalidateQueries({ queryKey: ['todos'] });
27 },
28 });
29}
这个流程里同时兼顾了:
- 用户立即看到结果(乐观更新);
- 出错时可以回滚;
- 最终仍以服务器数据为准。
Suspense 思路:把加载当作“渲染的一部分”
Suspense for Data Fetching 还没有在所有场景里完全铺开,但它背后的思路值得留意:
- 把「数据还没准备好」也当作一种渲染状态;
- 组件在渲染时如果发现数据还没准备好,可以「挂起」(suspend);
- 外层的
<Suspense>负责在这段时间内渲染 fallback UI。
示意写法大致类似:
1function App() {
2 return (
3 <Suspense fallback={<div>加载中...</div>}>
4 <UserProfile />
5 </Suspense>
6 );
7}
组件内部不再自己手动管理 isLoading,而是交给数据层和 Suspense:
1function UserProfile() {
2 const user = useUserResource(); // 数据没准备好时会挂起
3 return <div>{user.name}</div>;
4}
这种模式下,UI 对「数据是否 ready」这件事变得更声明式,而不是到处写 if/else。
错误边界:和异步数据配套出现的那一层
除了 loading 以外,还要考虑错误。
错误边界(Error Boundary)可以捕获子树中的渲染错误,提供一个降级 UI:
1class ErrorBoundary extends React.Component<
2 { fallback: React.ReactNode },
3 { hasError: boolean }
4> {
5 constructor(props: any) {
6 super(props);
7 this.state = { hasError: false };
8 }
9
10 static getDerivedStateFromError() {
11 return { hasError: true };
12 }
13
14 render() {
15 if (this.state.hasError) {
16 return this.props.fallback;
17 }
18 return this.props.children;
19 }
20}
配合异步数据时,常见的写法是:
- 外层包一层 Error Boundary;
- 数据层在请求失败时抛出错误或返回特定状态;
- Error Boundary 捕获后展示错误 UI,并提供重试入口。
和状态管理的分工
异步数据这块,和全局 Store 的边界大致可以这样划:
- 服务器状态:交给 React Query / SWR 一类库;
- 纯客户端状态(比如某个开关、临时编辑状态):用本地 state 或轻量全局 Store;
- URL 相关状态:交给路由和查询参数。
一个请求结果不一定非得进「大 Store」才能算结构化,大多数时候,让专门的数据层库来管服务器状态,会比自己手写一套更省心。
小结
- 真正的异步数据问题通常不是「发不出请求」,而是缓存、失效、重试、乐观更新这整条链路。
- SWR 模型和 React Query 这一类库,提供的是「以 key 为索引的服务器状态」抽象,让组件专注于描述自己要什么。
- Suspense 把「数据还没准备好」纳入渲染流程,配合 Error Boundary 可以形成一套完整的 loading/错误降级体验。
- 服务器状态和本地 UI 状态、URL 状态分工清楚,可以避免把一切都塞进同一个全局 Store。