前端开发··2 阅读·预计 22 分钟

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),而且没有缓存机制。

第二层:类型完整的泛型增强

核心痛点是 datanull 污染了整个消费端——每个使用者都要做空值守卫。我们可以用条件类型让 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 的最佳实践路径。核心要点:

  1. 用判别联合(discriminated union)替代多状态变量,让 TypeScript 帮你做穷尽性检查
  2. 泛型参数从 fetcher 返回值自然推导,不额外标注类型
  3. SWR 模式优先——先返回缓存数据,后台静默刷新,消灭不必要的 loading 闪烁
  4. 请求去重是必须的,同一 key 的并发请求共享 Promise

当你下一次在组件里写 const [loading, setLoading] = useState(true) 时,停下来想一想:这个样板能否被一个 3 行的泛型 Hook 取代?

0 评论

评论区

登录 后参与评论