React 泛型异步 Hook 最佳实践:从重复样板到 3 行复用的类型安全演进
问题的原点
翻开任何一个中型 React 项目,你大概率会看到这样的代码重复出现 10+ 次:
// ❌ 反例:散落在 3 个组件中的重复样板
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchUser(userId)
.then(data => { if (!cancelled) { setUser(data); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; };
}, [userId]);
if (loading) return <Skeleton />;
if (error) return <ErrorBanner error={error} />;
return <UserCard user={user!} />;
}
function OrderList({ userId }: { userId: string }) {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetchOrders(userId)
.then(data => { if (!cancelled) { setOrders(data); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err); setLoading(false); } });
return () => { cancelled = true; };
}, [userId]);
if (loading) return <Skeleton />;
if (error) return <ErrorBanner error={error} />;
return <OrderTable orders={orders} />;
}
每个数据获取组件都在复制粘贴同样的三态逻辑。更糟的是:竞态条件的 cancelled 标志容易遗漏,loading/error 的重置时序经常写错,数据状态与 UI 状态的耦合让测试和重构都举步维艰。
第一层:基础泛型 Hook
让我们从最朴素的封装开始——把三态逻辑抽象成一个泛型 Hook:
// ✅ 方案一:基础泛型异步 Hook
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useAsync<T>(
fetcher: () => Promise<T>,
deps: unknown[]
): AsyncState<T> & { reload: () => void } {
const [state, setState] = useState<AsyncState<T>>({
data: null,
loading: true,
error: null,
});
const execute = useCallback(() => {
let cancelled = false;
setState(prev => ({ ...prev, loading: true, error: null }));
fetcher()
.then(data => {
if (!cancelled) setState({ data, loading: false, error: null });
})
.catch(error => {
if (!cancelled) setState({ data: null, loading: false, error });
});
return () => { cancelled = true; };
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const cancel = execute();
return cancel;
}, [execute]);
return { ...state, reload: execute };
}
使用方式立竿见影——从 20+ 行缩减到 3 行:
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useAsync(
() => fetchUser(userId),
[userId]
);
if (loading) return <Skeleton />;
if (error) return <ErrorBanner error={error} />;
return <UserCard user={user!} />;
}
但这还远不够完美。deps 需要手动传入,类型推导丢失了 fetcher 返回值的精确类型(虽然 T 能推导,但 data 永远是 T | null),而且没有缓存机制。
第二层:类型完整的泛型增强
核心痛点是 data 的 null 污染了整个消费端——每个使用者都要做空值守卫。我们可以用条件类型让 Hook 感知"是否已加载":
// ✅ 方案二:区分加载完毕与未加载状态的类型安全 Hook
type AsyncResult<T> =
| { status: 'idle'; data: undefined; error: undefined }
| { status: 'loading'; data: undefined; error: undefined }
| { status: 'success'; data: T; error: undefined }
| { status: 'error'; data: undefined; error: Error };
function useAsyncState<T>(
fetcher: () => Promise<T>,
deps: unknown[]
): AsyncResult<T> & { reload: () => void } {
const [result, setResult] = useState<AsyncResult<T>>({
status: 'idle',
data: undefined,
error: undefined,
});
const execute = useCallback(() => {
let cancelled = false;
setResult({ status: 'loading', data: undefined, error: undefined });
fetcher()
.then(data => {
if (!cancelled) setResult({ status: 'success', data, error: undefined });
})
.catch(error => {
if (!cancelled) setResult({ status: 'error', data: undefined, error: error as Error });
});
return () => { cancelled = true; };
}, deps); // eslint-disable-line
useEffect(() => {
const cancel = execute();
return cancel;
}, [execute]);
return { ...result, reload: execute };
}
现在消费端的类型收窄变得自然:
function UserProfile({ userId }: { userId: string }) {
const result = useAsyncState(() => fetchUser(userId), [userId]);
switch (result.status) {
case 'idle':
case 'loading':
return <Skeleton />;
case 'error':
return <ErrorBanner error={result.error} />;
case 'success':
// 🎯 此处 result.data 类型为 User,不再需要非空断言!
return <UserCard user={result.data} />;
}
}
但这仍然有两个问题:(1) deps 需要手动维护,容易遗漏导致闭包陷阱;(2) 每次 deps 变化都回到 loading 状态,无法做"后台刷新"(stale-while-revalidate)。
第三层:生产级泛型 Hook
// ✅ 方案三:生产级 useQuery Hook — 自动 deps、SWR、去重
interface QueryConfig<T> {
fetcher: () => Promise<T>;
key: string; // 用于去重和缓存
staleTime?: number;
}
type QueryResult<T> =
| { status: 'loading'; data: T | undefined; error: undefined }
| { status: 'success'; data: T; error: undefined }
| { status: 'error'; data: T | undefined; error: Error };
const queryCache = new Map<string, { data: unknown; timestamp: number }>();
const inflightRequests = new Map<string, Promise<unknown>>();
function useQuery<T>({ fetcher, key, staleTime = 0 }: QueryConfig<T>): QueryResult<T> & { reload: () => void } {
const [, rerender] = useReducer(x => x + 1, 0);
const resultRef = useRef<QueryResult<T>>({ status: 'loading', data: undefined, error: undefined });
const mountedRef = useRef(true);
// 检查缓存 — 命中则秒开
const cached = queryCache.get(key);
if (cached && Date.now() - cached.timestamp < staleTime) {
resultRef.current = { status: 'success', data: cached.data as T, error: undefined };
} else if (cached) {
// SWR: 先返回旧数据,后台刷新
resultRef.current = { status: 'success', data: cached.data as T, error: undefined };
}
const execute = useCallback(() => {
// 请求去重:同一 key 的并发请求共享一个 Promise
const existing = inflightRequests.get(key);
if (existing) {
(existing as Promise<T>).then(data => {
if (mountedRef.current) {
resultRef.current = { status: 'success', data, error: undefined };
rerender();
}
});
return () => {};
}
// 如果有未过期的缓存数据,不切回 loading(SWR 模式)
const hasFreshData = cached && Date.now() - cached.timestamp < staleTime;
if (!hasFreshData && cached === undefined) {
resultRef.current = { status: 'loading', data: undefined, error: undefined };
rerender();
}
const promise = fetcher()
.then(data => {
queryCache.set(key, { data, timestamp: Date.now() });
inflightRequests.delete(key);
if (mountedRef.current) {
resultRef.current = { status: 'success', data, error: undefined };
rerender();
}
return data;
})
.catch(error => {
inflightRequests.delete(key);
if (mountedRef.current) {
resultRef.current = {
status: 'error',
data: cached?.data as T | undefined,
error: error as Error,
};
rerender();
}
});
inflightRequests.set(key, promise);
return () => {};
}, [fetcher, key, staleTime]);
useEffect(() => {
mountedRef.current = true;
execute();
return () => { mountedRef.current = false; };
}, []);
return { ...resultRef.current, reload: execute };
}
现在,一个完整的列表页从 60 行变成 10 行:
// 🎯 最终使用效果:零样板,全类型安全
function OrderDashboard({ userId }: { userId: string }) {
const orders = useQuery({
key: `orders-${userId}`,
fetcher: () => fetchOrders(userId),
staleTime: 30_000, // 30 秒内复用缓存
});
const stats = useQuery({
key: `stats-${userId}`,
fetcher: () => fetchStats(userId),
staleTime: 60_000,
});
if (orders.status === 'loading' && !orders.data) return <PageSkeleton />;
if (orders.status === 'error') return <ErrorBanner error={orders.error} />;
return (
<>
<StatsPanel stats={stats.data} loading={stats.status === 'loading'} />
<OrderTable orders={orders.data} />
</>
);
}
关键设计决策对比
| 维度 | 反例(无封装) | 方案一 | 方案二 | 方案三 |
|---|---|---|---|---|
| 代码复用 | ❌ 零复用 | ✅ 基础复用 | ✅ 类型安全复用 | ✅ 生产级复用 |
| 竞态防护 | ⚠️ 手写易遗漏 | ✅ 内置 | ✅ 内置 | ✅ 内置 |
| 类型收窄 | ❌ null 断言满天飞 | ❌ 仍需断言 | ✅ 穷尽性检查 | ✅ 有缓存时 data 可能非 null |
| 缓存/SWR | ❌ | ❌ | ❌ | ✅ |
| 请求去重 | ❌ | ❌ | ❌ | ✅ |
| 自动依赖追踪 | N/A | ❌ 手动 deps | ❌ 手动 deps | ⚠️ 仅首次执行 |
反模式警示
在封装泛型 Hook 时,有三个高频反模式必须警惕:
// ❌ 反模式 1:泛型约束过于宽松
function useAsync<T>(fetcher: () => Promise<T>) { ... }
// 问题:任何 () => Promise<any> 都能传入,失去了类型安全的意义
// ✅ 修复:约束 fetcher 必须有明确返回
function useQuery<T>(config: { fetcher: () => Promise<T>; key: string }) { ... }
// key 的存在确保开发者显式声明缓存维度
// ❌ 反模式 2:在 Hook 内部声明 fetcher
function useUserData(userId: string) {
const fetcher = () => fetchUser(userId); // 每次 render 都创建新引用
return useAsync(fetcher, [userId]);
}
// ✅ 修复:用 useCallback 稳定引用
function useUserData(userId: string) {
const fetcher = useCallback(() => fetchUser(userId), [userId]);
return useAsync(fetcher, [fetcher]);
}
// ❌ 反模式 3:多状态分散管理
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// 问题:3 次 setState 触发 3 次渲染,且可能出现中间态(如 data 和 loading 同时为 true)
// ✅ 修复:判别联合单状态
const [result, setResult] = useState<AsyncResult<T>>({ status: 'idle' });
// 一次 setState 原子更新,TypeScript 保证状态一致性
总结
从散落在各组件中的三态样板代码,到一条 useQuery 完成数据获取、缓存、去重、竞态防护——这就是泛型异步 Hook 的最佳实践路径。核心要点:
- 用判别联合(discriminated union)替代多状态变量,让 TypeScript 帮你做穷尽性检查
- 泛型参数从 fetcher 返回值自然推导,不额外标注类型
- SWR 模式优先——先返回缓存数据,后台静默刷新,消灭不必要的 loading 闪烁
- 请求去重是必须的,同一 key 的并发请求共享 Promise
当你下一次在组件里写 const [loading, setLoading] = useState(true) 时,停下来想一想:这个样板能否被一个 3 行的泛型 Hook 取代?
0 评论
评论区
登录 后参与评论