跳转到主要内容

数据获取

本指南聚焦“拿到数据并稳定展示”,不绑定某个具体库,而是给出选择与组合的框架。

问题定义

数据获取通常不是“发一次请求”这么简单,而是包含缓存、一致性、并发、错误重试与用户体验。

所以重点不是“怎么写 fetch”,而是如何构建一条在高延迟、失败、频繁导航下仍然正确的“数据管线”。

推荐做法

先建立清晰的“归属模型”:请求在哪发生、状态在哪存放、UI 怎么消费。

  • 把数据获取放在最接近“数据边界”的位置(路由/服务端/集中层),UI 只消费结果。
  • 为请求建立统一的“键”(key),让缓存、失效与预取可控。
  • 把错误可视化,并给用户可操作的恢复路径(重试/回退)。

当这三点建立起来后,剩下的更多是“策略选择”:过期、重试以及 UI 状态呈现。

决策框架

在选库或选模式前,先回答这些问题。多数“数据获取问题”的本质是:边界、过期策略与体验。

  • 在哪里执行:服务端/客户端/两者?(SEO、密钥、延迟、bundle 体积)
  • 何时触发:进页面就拉、交互后拉、还是后台刷新?
  • 要多“新”:强一致、够用即可、还是最终一致?
  • 失败怎么处理:重试、降级、展示过期缓存、还是阻塞 UI?

如果你能为某个功能回答这四个问题,你就能更有把握地选实现,并且未来也能在不重写 UI 的前提下替换策略。

请求键:缓存、去重、失效

稳定的请求键是从“在 Effect 里发请求”走向工程化的第一步:它让并发去重、缓存复用与精准失效成为可能。

一个好的 `key` 应该是确定性的、包含所有影响结果的输入,并且能通过前缀进行批量 `invalidate`。

TypeScript
type CacheEntry<T> = { data: T; updatedAt: number };

const cache = new Map<string, CacheEntry<any>>();
const inflight = new Map<string, Promise<any>>();

export async function fetchJsonCached<T>(
  key: string,
  url: string,
  options: { ttlMs?: number; signal?: AbortSignal } = {}
) {
  const ttlMs = options.ttlMs ?? 30_000;
  const hit = cache.get(key) as CacheEntry<T> | undefined;
  const now = Date.now();

  if (hit && now - hit.updatedAt < ttlMs) return hit.data;
  const existing = inflight.get(key) as Promise<T> | undefined;
  if (existing) return existing;

  const p = (async () => {
    const res = await fetch(url, { signal: options.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = (await res.json()) as T;
    cache.set(key, { data, updatedAt: Date.now() });
    return data;
  })();

  inflight.set(key, p);
  try {
    return await p;
  } finally {
    inflight.delete(key);
  }
}

export function invalidate(keyPrefix: string) {
  for (const k of cache.keys()) {
    if (k.startsWith(keyPrefix)) cache.delete(k);
  }
}

过期策略(Staleness)

过期策略是产品决策。同一个应用里,不同页面完全可以(也应该)使用不同的 staleness 策略。

  • 强新鲜度:每次导航/交互都阻塞等待;搭配重试与错误边界。
  • stale-while-revalidate:先展示缓存,再后台刷新,最后更新 UI。
  • 最终一致:允许短暂不一致,并在 UI 上明确提示(例如“2 分钟前更新”)。

重试与退避(最小实现)

重试要克制:只重试幂等请求;尊重 `AbortSignal`;加入退避避免“集体重试”。

TypeScript
export async function retry<T>(
  run: () => Promise<T>,
  options: { retries?: number; baseDelayMs?: number; signal?: AbortSignal } = {}
) {
  const retries = options.retries ?? 2;
  const baseDelayMs = options.baseDelayMs ?? 200;
  for (let attempt = 0; attempt <= retries; attempt += 1) {
    if (options.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
    try {
      return await run();
    } catch (e) {
      if (attempt === retries) throw e;
      const delay = baseDelayMs * Math.pow(2, attempt);
      await new Promise<void>((resolve, reject) => {
        const id = setTimeout(resolve, delay);
        options.signal?.addEventListener(
          'abort',
          () => {
            clearTimeout(id);
            reject(new DOMException('Aborted', 'AbortError'));
          },
          { once: true }
        );
      });
    }
  }
  throw new Error('unreachable');
}

常见坑

  • 在 Effect 里直接写请求却没处理取消/竞态,导致旧请求覆盖新状态。
  • 把“缓存”当成“状态”,导致到处重复 fetch、难以统一失效策略。

一个简单判断:如果你在多个组件里反复写“loading/error 状态 + fetch + 缓存”,就应该抽成共享的数据层或 hook。

示例模式

一个最小但稳健的模式:显式建模状态,处理取消,并避免过期结果写入。

TypeScript
import { useEffect, useState } from 'react';

type State<T> =
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'success'; data: T };

export function useJson<T>(url: string) {
  const [state, setState] = useState<State<T>>({ status: 'loading' });

  useEffect(() => {
    let isStale = false;
    const controller = new AbortController();

    async function run() {
      setState({ status: 'loading' });
      try {
        const res = await fetch(url, { signal: controller.signal });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = (await res.json()) as T;
        if (!isStale) setState({ status: 'success', data });
      } catch (e) {
        if (controller.signal.aborted) return;
        if (!isStale) setState({ status: 'error', error: e as Error });
      }
    }

    run();
    return () => {
      isStale = true;
      controller.abort();
    };
  }, [url]);

  return state;
}

上线检查清单

  • 每个请求都有归属:路由边界或集中数据层,不要散落到叶子组件。
  • 每个页面都建模 `loading`/`error`/`empty`,并提供重试或恢复动作。
  • 缓存有明确的 `key` 策略与失效策略(何时失效、失效哪些)。
  • 请求可观测:记录 request id、耗时、状态码与用户可见错误。

延伸阅读

数据获取 - Guides - React 文档 - React 文档