React 与异步数据:SWR 模型、Suspense 思路与错误边界

前端应用一旦复杂起来,最折腾人的往往不是单次请求,而是「请求 + 缓存 + 失效 + 重试 + 乐观更新」这一整套。
和其说是在写 Ajax,不如说是在写一个小型数据层。

SWR(stale-while-revalidate)的基本想法可以概括成一句话:

先用旧数据顶上去,再在后台偷偷拉一遍新数据更新。

一个典型的数据流是这样的:

  1. 组件挂载,先从缓存里拿到旧数据(如果有),立即渲染,避免白屏;
  2. 后台再发一遍请求拿最新数据;
  3. 请求成功后,更新缓存和所有消费这个缓存的组件。

像 SWR、React Query 这类库,本质上就是在实现这样一套数据层行为:

  • 把「以 key 为索引的缓存」抽象出来;
  • 掌控请求的生命周期(loading、error、success);
  • 管缓存的失效策略和并发请求。

以 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 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。