告别 React 异步“屎山”:useAsync 自定义 Hook 全解析

在这里插入图片描述

作为 React 开发者,你是否也被这些问题困扰?

  • useEffect 里塞满了 loadingerror 的状态管理?
  • 每个异步请求都要写一遍 try...catch...finally 的样板代码?
  • 因为依赖数组配置不当,导致组件疯狂重渲染?

如果你点头,那么这篇关于 useAsync 自定义 Hook 的深度解析,将彻底改变你的 React 开发体验。

一、问题根源:为什么 React 异步代码越写越乱?

在深入解决方案之前,我们先理解问题所在。一个典型的 React 异步数据获取组件通常长这样:

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 每次请求前都要重置状态,防止旧数据或错误干扰
    setLoading(true);
    setError(null);

    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => setError(err))
      .finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>加载失败:{error.message}</div>;

  return <h1>{user?.name}</h1>;
}

这段代码功能正确,但存在几个明显的痛点:

  1. 样板代码 (Boilerplate) 泛滥:每次发起请求,你都得重复写一遍 setLoading(true)setError(null)finally 里的 setLoading(false)
  2. 状态管理分散loadingerror 状态与业务逻辑混杂在一起,组件变得臃肿。
  3. 易出错:忘记在 finally 中重置 loading,或者忘记在请求开始前清空 error,都是常见的低级错误。
  4. 可读性差:当组件中有多个异步请求时,useEffect 会变得非常庞大,难以阅读和维护。

二、优雅的解决方案:useAsync 自定义 Hook

useAsync 的核心思想是将异步操作的通用逻辑(状态管理、错误处理、并发控制)抽象出来,形成一个可复用的工具

使用 useAsync 后,上面的组件可以被简化为:

import useAsync from './useAsync'; // 假设我们已经实现了这个Hook

function UserProfile({ userId }) {
  // 将异步逻辑封装成一个函数
  const fetchUser = () => fetch(`/api/users/${userId}`).then(res => res.json());

  // 直接调用 useAsync,传入函数和依赖项
  const { data: user, loading, error } = useAsync(fetchUser, [userId]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>加载失败:{error.message}</div>;

  return <h1>{user?.name}</h1>;
}

对比一下,代码行数减少了,逻辑也更清晰了。useAsync 返回了我们关心的三个核心状态:data, loading, error

三、源码解析:useAsync 的工作原理

一个基础版的 useAsync 实现其实非常直观,主要由以下几个部分构成:

// src/hooks/useAsync.js
import { useState, useCallback, useEffect } from 'react';

function useAsync(fn, deps = []) {
  // 1. 定义异步操作所需的状态
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  // 2. 使用 useCallback 包裹执行函数,防止因函数引用变化导致的无限循环
  const run = useCallback(async () => {
    // 3. 执行前准备:设置 loading 为 true,并清空上一次的错误
    setLoading(true);
    setError(null);

    try {
      // 4. 执行传入的异步函数
      const result = await fn();
      // 5. 成功:更新数据
      setData(result);
    } catch (err) {
      // 6. 失败:更新错误信息
      setError(err);
    } finally {
      // 7. 无论成功或失败:都将 loading 重置为 false
      setLoading(false);
    }
  }, deps); // 依赖项数组传入 useCallback

  // 8. 如果传入了 fn,则在组件挂载或依赖项变化时自动执行一次
  useEffect(() => {
    if (fn) {
      run();
    }
  }, [run]);

  // 9. 返回状态和手动执行函数
  return { data, error, loading, run };
}

export default useAsync;

设计精髓剖析

这个看似简单的实现,蕴含了三个关键设计点,使其变得健壮和易用:

  1. useCallback 防止无限重渲染

    • 问题:如果 run 函数在每次组件渲染时都被重新创建,那么它作为依赖项传递给 useEffect 时,会导致 useEffect 每次都执行,从而引发组件无限重渲染。
    • 解决方案useCallback 会返回一个记忆化的函数引用。只有当 deps 数组中的依赖项发生变化时,它才会返回一个新的函数。这保证了 run 的引用稳定性,是整个 Hook 正确工作的基石。
  2. 自动重置错误状态

    • 问题:如果上次请求失败了,error 状态会保留错误信息。当下一次请求开始时,如果不手动清空,UI 上会一直显示旧的错误,给用户造成困惑。
    • 解决方案:在 run 函数的开头,我们主动调用 setError(null)。这确保了每次新的请求都是一个“干净的开始”。
  3. finally 兜底 loading 状态

    • 问题:忘记在 catch 块中设置 setLoading(false) 是一个极其常见的错误,这会导致加载动画一直转个不停。
    • 解决方案:将 setLoading(false) 放在 finally 块中。finally 块无论 try 中的代码是正常结束还是抛出异常,总会执行。这从根本上杜绝了“无限加载”的 bug。

四、实战案例:从“面条代码”到“艺术品”

让我们通过一个更复杂的例子来感受 useAsync 的威力。假设一个用户资料页面,需要加载用户信息,并且有一个更新用户信息的按钮。

改造前(面条代码版)

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  
  const [updating, setUpdating] = useState(false);
  const [updateError, setUpdateError] = useState(null);

  // 加载用户信息
  useEffect(() => {
    setLoading(true);
    setError(null);
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  // 更新用户信息
  const handleUpdate = async (newData) => {
    setUpdating(true);
    setUpdateError(null);
    try {
      const updatedUser = await updateUser(userId, newData);
      setUser(updatedUser);
    } catch (err) {
      setUpdateError(err);
    } finally {
      setUpdating(false);
    }
  };

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={() => handleUpdate({ name: 'New Name' })} disabled={updating}>
        {updating ? 'Updating...' : 'Update Name'}
      </button>
      {updateError && <div>Update failed: {updateError.message}</div>}
    </div>
  );
}

可以看到,loadingerror 状态被复制了一份用于处理更新逻辑,代码开始变得冗余和混乱。

改造后(艺术品版)

function UserProfile({ userId }) {
  // 1. 加载用户信息:直接使用自动执行模式
  const { data: user, loading, error } = useAsync(() => fetchUser(userId), [userId]);

  // 2. 更新用户信息:使用手动执行模式,不自动运行
  const { loading: updating, error: updateError, run: runUpdate } = useAsync();

  const handleUpdate = (newData) => {
    // 3. 调用 runUpdate 来触发异步操作
    runUpdate(() => updateUser(userId, newData));
  };

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{user?.name}</h1>
      <button onClick={() => handleUpdate({ name: 'New Name' })} disabled={updating}>
        {updating ? 'Updating...' : 'Update Name'}
      </button>
      {updateError && <div>Update failed: {updateError.message}</div>}
    </div>
  );
}

代码量显著减少,逻辑也被清晰地划分为两个独立的异步关注点。useAsync 像一个优雅的管家,将所有繁琐的状态管理都打理得井井有条。

五、进阶优化:让 useAsync 更上一层楼

基础版本的 useAsync 已经能解决 80% 的问题,但追求极致的我们还可以做一些优化,让它变得更加强大和健壮。

1. 支持手动触发(Lazy Execution)

有时我们不希望 useAsync 在组件挂载时自动执行,而是希望在用户点击按钮等事件发生后才执行。我们可以给 useAsync 增加一个配置项。

优化后的 useAsync:

function useAsync(fn, deps = [], { lazy = false } = {}) {
  // ... 内部实现不变 ...

  // 根据 lazy 配置决定是否自动执行
  useEffect(() => {
    if (fn && !lazy) {
      run();
    }
  }, [run, lazy]);

  return { data, error, loading, run };
}

// 使用方式
// 自动执行(默认)
const { data } = useAsync(() => fetchData(), [deps]);

// 手动执行(懒加载)
const { run } = useAsync(null, [], { lazy: true }); 
// 或者
const { run } = useAsync(() => submitForm(), [formData], { lazy: true });
// 然后在需要时调用 run()

2. 支持请求取消(Race Condition Handling)

这是一个非常重要的优化点。当一个组件快速触发了两次请求(例如,快速切换两个不同的用户 ID),如果第一个请求因为网络原因返回较慢,它的结果会在第二个请求之后到达,从而错误地覆盖了最新的数据。这就是“竞态条件”。

我们可以通过一个“取消令牌”(Cancel Token)机制来解决这个问题。

再次优化的 useAsync:

function useAsync(fn, deps = [], { lazy = false } = {}) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  // 使用 ref 来存储取消函数
  const cancelRef = useRef(null);

  const run = useCallback(async () => {
    // 如果上一次请求还在进行,就取消它
    if (cancelRef.current) {
      cancelRef.current();
    }

    let isCancelled = false;
    cancelRef.current = () => {
      isCancelled = true;
    };

    setLoading(true);
    setError(null);

    try {
      // 将取消令牌传递给业务函数
      const result = await fn({ isCancelled });
      // 在更新状态前检查是否已被取消
      if (!isCancelled) {
        setData(result);
      }
    } catch (err) {
      if (!isCancelled) {
        setError(err);
      }
    } finally {
      if (!isCancelled) {
        setLoading(false);
      }
    }
  }, deps);

  useEffect(() => {
    if (fn && !lazy) {
      run();
    }
    // 在组件卸载时取消请求
    return () => {
      if (cancelRef.current) {
        cancelRef.current();
      }
    };
  }, [run, lazy]);

  return { data, error, loading, run };
}

现在,你的业务代码可以检查 isCancelled 标志来提前终止耗时操作。

六、何时不使用 useAsync

useAsync 虽好,但并非万能。了解它的适用边界同样重要。

场景 推荐方案 理由
简单的单次或偶发请求 useAsync 轻量、无依赖、代码简洁,完美匹配。
需要数据缓存、背景刷新 React Query / SWR 这些库是专业的数据获取层,提供了强大的缓存、失效、重试等机制。
复杂的分页、无限滚动 React Query / SWR 内置了对分页和无限查询的优秀支持,无需手动管理页码和数据拼接。
WebSocket 实时数据 专用 useWebSocket Hook WebSocket 是长连接,需要专门的生命周期管理和消息处理逻辑。
全局状态管理 Redux Toolkit / Zustand 用于管理跨越多个不相关组件的复杂应用状态,而非单次请求。

结论useAsync 是一个优秀的轻量级、通用型解决方案,特别适合内部系统、管理后台、快速原型等场景。对于需要精细化数据控制的大型应用,选择 React Query 等专业库是更工程化的做法。

七、总结与展望

useAsync 自定义 Hook 是 React 哲学中“将复杂逻辑抽象为可复用组件”的完美体现。它通过不到 50 行代码,解决了 React 开发中一个普遍存在的痛点,让我们的组件代码回归清晰和优雅。

它带来的核心价值:

  • 减少样板代码:将异步操作的“骨架”代码一次性写好,业务代码只需关注核心逻辑。
  • 提升代码可读性与可维护性:将状态管理逻辑从组件中剥离,使组件更专注于 UI 渲染。
  • 封装最佳实践:内置了 useCallback、错误重置、finally 兜底等防止常见错误的机制。
  • 易于扩展:可以轻松添加请求取消、乐观更新等高级功能。

useAsync 只是一个开始。当你开始思考如何抽象和复用项目中的其他重复逻辑(如表单处理 useForm、列表状态 useList)时,你就已经从一个单纯的“代码编写者”,成长为一个懂得“设计代码”的高级开发者。


更多推荐