React 基础体系 · 第 11/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 异步数据:请求取消、缓存、并发、Suspense 和错误恢复

异步数据组件同时面对五个不同问题:

  1. 请求取消:组件不再需要结果时,如何停止网络请求,并避免过期结果写入状态。
  2. 缓存:相同数据如何复用,如何区分“正在请求”“已经成功”“已经失败”和“需要重新验证”。
  3. 并发:多个组件或多个渲染版本同时读取数据时,如何避免重复请求、竞态和不一致。
  4. Suspense:组件如何声明“数据尚未准备好”,并把等待状态交给最近的边界。
  5. 错误恢复:请求失败后,如何展示错误、重试、清理坏缓存,并保证恢复过程不会再次使用旧错误。

这些概念相关,但不是同一个层次。AbortController 解决的是请求生命周期;缓存解决的是数据复用与请求合并;并发渲染解决的是多个 UI 版本的协调;Suspense 解决的是渲染期间的等待;错误边界解决的是渲染错误的隔离。把它们混为一个“异步 Hook”通常会导致边界条件丢失。


一、先区分三类异步数据状态

一个请求通常至少有以下状态:

absent   —— 没有缓存,也没有进行中的请求
pending  —— 请求进行中
success  —— 请求成功,缓存中有数据
error    —— 请求失败,缓存中有错误
stale    —— 有旧数据,但需要重新验证

stale 不是 HTTP 标准状态,而是应用缓存中的语义:旧数据仍然可以展示,但不能认为它已经代表服务器当前状态。

可以把一次读取抽象为:

read(key){value数据已经可用promise数据尚未可用error读取失败read(key) \rightarrow \begin{cases} value & \text{数据已经可用}\\ promise & \text{数据尚未可用}\\ error & \text{读取失败} \end{cases}

在普通 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() 后:

  • fetch Promise 通常以 DOMExceptionAbortError 或其他环境相关的中止错误拒绝;
  • 浏览器可以停止尚未完成的网络工作;
  • 服务器是否已经收到请求、是否已经完成处理,不由客户端保证;
  • 取消不会撤销服务器已经执行的副作用。

因此,取消请求不是“回滚请求”。对于 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>;
  }
}

这个例子中有两个保护层:

  1. controller.abort():尝试停止旧请求;
  2. active = false:即使底层实现没有及时停止,旧 Promise 完成后也不能更新当前组件。

第二层仍然需要,因为“请求被取消”和“回调一定不会执行”不是同一个保证。某些工作可能已经完成,或者并非所有异步步骤都真正连接到了 AbortSignal

2.3 竞态是如何发生的

假设组件先显示用户 A,然后快速切换到用户 B

t0: 发起请求 A
t1: userId 改为 B
t2: 发起请求 B
t3: B 返回,显示 B
t4: A 返回

如果没有清理和版本检查,t4 会把 A 写回屏幕,导致 UI 显示错误用户。

形式化地说,每次参数变化都产生一个版本号:

v1=A,v2=Bv_1 = A,\quad v_2 = B

只有满足:

completion.version=current.versioncompletion.version = current.version

的结果才允许提交到当前 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;
}

这里的关键顺序是:

  1. 创建 Promise;
  2. 立即放入 pending
  3. 返回 Promise;
  4. 完成后移除进行中记录。

如果先异步执行再放入缓存,同一事件循环中的后续调用仍可能重复创建请求。

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 时:

  1. React 捕获这个 Promise;
  2. React 渲染 fallback
  3. Promise 完成后安排重试;
  4. 重试时,如果资源已有成功值,UserView 正常返回 JSX;
  5. 如果资源变成错误,读取时抛出 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 尚未准备好时,HeaderRecommendations 也会被一起替换。

边界更细时,可以让已准备好的内容先显示:

<>
  <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 更新优先级,不是 fetchsignal

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 只控制未完成子树的展示策略。若数据键发生变化,缓存层必须分别保存 ab 的数据,不能使用一个没有键约束的全局变量。

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
边界:再次显示错误

重试必须同时完成两个动作:

  1. 让错误边界允许子树重新渲染;
  2. 删除或重新验证对应的数据缓存。

例如:

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>
  );
}

示例中 keyresetKey 都参与恢复:

  • 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;
};

若当前时间为 t t ,获取时间为 tf t_f ,TTL 为 Δ \Delta ,则:

fresh    ttf<Δfresh \iff t - t_f < \Delta

直觉是:数据年龄没有超过允许的新鲜窗口,可以直接使用。

在 stale-while-revalidate 模式下:

  1. 新鲜:直接返回;
  2. 过期但有旧值:先返回旧值,同时后台重新请求;
  3. 没有值:等待首次请求;
  4. 重新验证失败:可以继续展示旧值,同时记录错误或在下次读取时提示。

这与 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 标志。

错误三:把所有错误都当成可重试错误

401403、参数校验失败和权限错误通常不能通过重复发送相同请求解决。重试前应区分:

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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。