数据获取
本指南聚焦“拿到数据并稳定展示”,不绑定某个具体库,而是给出选择与组合的框架。
问题定义
数据获取通常不是“发一次请求”这么简单,而是包含缓存、一致性、并发、错误重试与用户体验。
所以重点不是“怎么写 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、耗时、状态码与用户可见错误。
延伸阅读
- Learn:使用 Effect 同步