React 中的自定义 Hook:正确拆分可复用逻辑

1. 概述

当 React 应用中多个组件需要相同的逻辑时——调用 API、读取 localStorage、防抖输入——你很容易陷入在组件间来回复制粘贴 state 和 useEffect 的困境。本文教你如何将这类逻辑封装到自定义 Hook中:一个以 use 开头的函数,利用现有的 Hook(useState、useEffect、useRef…)来实现逻辑复用。读完本文,你将能够编写 useFetch、useLocalStorage、useDebounce,并学会用 renderHook 进行测试。

2. 问题:组件间重复的逻辑

假设你有两个组件都需要调用 API 并管理 loading/error:

function CustomerList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/customers')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...渲染
}

function OrderList() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/orders')
      .then((res) => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  // ...渲染
}

CustomerList 和 OrderList 包含几乎相同的逻辑,只是 URL 不同。如果以后想添加重试或取消请求功能,就得在到处修改——这正是需要自定义 Hook 的信号。

3. 自定义 Hook 是什么

自定义 Hook 是一个普通的 JavaScript 函数,名称以 use 开头,内部调用其他 React Hook。它不返回 JSX——它返回 state 和函数,就像一个“迷你存储”附加到调用它的组件。

注意: 每次组件调用自定义 Hook 时,React 都会创建一个独立的 state 实例。两个组件都使用 useFetch 并不共享状态——它们只共享逻辑。

4. 编写第一个自定义 Hook:useFetch

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);

    fetch(url)
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        if (!cancelled) setData(json);
      })
      .catch((err) => {
        if (!cancelled) setError(err);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });

    return () => {
      cancelled = true;
    };
  }, [url]);

  return { data, loading, error };
}

解释:

  • cancelled 用于避免竞态条件:如果 url 在旧请求完成前改变,旧的结果不会被设置到 state 中。
  • 清理函数(return () => { cancelled = true })在组件卸载或 url 改变时运行——正确遵循 useEffect 的生命周期。
  • Hook 返回一个对象 { data, loading, error },便于调用方解构。

现在组件更简洁了:

function CustomerList() {
  const { data, loading, error } = useFetch('/api/customers');

  if (loading) return <p>正在加载...</p>;
  if (error) return <p>错误:{error.message}</p>;
  return <ul>{data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>;
}

function OrderList() {
  const { data, loading, error } = useFetch('/api/orders');
  // 渲染逻辑相同,不再有重复的 fetch 逻辑
}

5. 第二个例子:useLocalStorage

同步 state 和 localStorage 的逻辑也很容易被复制粘贴:

// useLocalStorage.js
import { useState, useEffect } from 'react';

export function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = window.localStorage.getItem(key);
    return stored !== null ? JSON.parse(stored) : initialValue;
  });

  useEffect(() => {
    window.localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

解释:

  • useState(() => ...) 使用惰性初始化器——函数只在挂载时运行一次,不会在每次渲染时重新读取 localStorage。
  • Hook 返回元组 [value, setValue],就像 useState 一样,用户能立即理解。

在组件中使用:

function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      当前主题:{theme}
    </button>
  );
}

5.1. 当 state 依赖前一个值时

如果你需要基于旧值进行更新(比如计数器),使用函数式更新以避免陈旧的闭包:

setValue((prev) => prev + 1); // 比 setValue(value + 1) 更安全

6. 第三个例子:useDebounce

防抖搜索输入是一个非常常见的需求:

// useDebounce.js
import { useState, useEffect } from 'react';

export function useDebounce(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer);
  }, [value, delayMs]);

  return debounced;
}

解释:

  • 每当 value 改变时,一个新的 setTimeout 被创建;旧的 setTimeout 被清理函数中的 clearTimeout 取消。
  • 只有当用户在 delayMs 内停止输入,debounced 才会更新。
function SearchBox() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 500);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`);
    }
  }, [debouncedQuery]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

7. 命名规则和 Hook 规则

自定义 Hook 必须遵循Hook 规则,就像内置 Hook 一样:

  1. 名称必须以 use 开头——ESLint 插件 eslint-plugin-react-hooks 依靠这个前缀来检查规则,如果没有 use,它将无法识别这是一个 Hook。
  2. 只在函数顶层调用 Hook,不要在 if、for 或回调中调用。
  3. 只在函数组件或其他自定义 Hook 中调用 Hook,不要在普通函数中调用。
npm install eslint-plugin-react-hooks --save-dev
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

启用 exhaustive-deps 帮助你不会忘记自定义 Hook 中 useEffect 的依赖项——这是导致“陈旧数据”bug 最常见的原因。

8. 测试自定义 Hook

由于自定义 Hook 不返回 JSX,你无法像测试组件那样 render 它。@testing-library/react 库提供了 renderHook 来在测试环境中调用 Hook。

<!-- 不适用于 React 项目;依赖项通过 npm 安装,见下文 -->
npm install --save-dev vitest @testing-library/react
// useDebounce.test.jsx
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useDebounce } from './useDebounce';

describe('useDebounce', () => {
  it('仅在延迟结束后更新值', () => {
    vi.useFakeTimers();
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'a', delay: 300 } }
    );

    expect(result.current).toBe('a');

    rerender({ value: 'ab', delay: 300 });
    expect(result.current).toBe('a'); // 尚未改变

    act(() => {
      vi.advanceTimersByTime(300);
    });

    expect(result.current).toBe('ab'); // 已完成防抖
    vi.useRealTimers();
  });
});

解释:

  • renderHook 接受一个调用 Hook 的函数,返回 result.current——Hook 在当前渲染时返回的值。
  • rerender 模拟组件使用新 props 重新渲染(就像用户输入时 value 改变)。
  • vi.useFakeTimers() + act(() => vi.advanceTimersByTime(...)) 允许你控制时间而无需真正等待,使测试快速且稳定。
✓ useDebounce > 仅在延迟结束后更新值

9. 常见错误和何时不应该使用

常见错误:

  • 在 useEffect 中忘记清理:对于 useFetch,如果没有 cancelled 标志,旧请求在返回时可能会覆盖新数据——导致显示错误的数据。
  • 缺少依赖项:如果 Hook 中的 useEffect 使用外部变量但没有在依赖数组中声明,Hook 将使用“过时的”值(陈旧闭包)。
  • 每次渲染都返回新对象的不必要做法:如果 Hook 每次都以对象字面量返回 { data, loading, error },下游使用 React.memo 的组件可能会因为引用改变而不必要地重新渲染。如果测量到真实问题,可以使用 useMemo。

何时不应该编写自定义 Hook:

  • 逻辑仅在一个地方使用且没有复用可能——拆分出来只会让代码更难跟踪(需要跳到另一个文件来理解)。
  • 逻辑完全是纯计算(纯函数),与 state/effect 无关——应该写普通 JavaScript 函数,不需要 Hook。
  • 当你需要在树中不同位置的多个组件间共享状态(不仅仅是逻辑)时——自定义 Hook 做不了这个,你需要 Context 或状态管理库(Zustand、Redux…)。

注意: 自定义 Hook 解决的是逻辑重复的问题,而不是组件间状态共享的问题。不要混淆这两个问题。

10. 结论

自定义 Hook 是一个工具,可以将有状态的逻辑从组件中提取出来,使代码更简洁、更容易测试、更易复用。你只需要记住:以 use 开头命名,遵守 Hook 规则,用 renderHook 而不是 render 来测试。通过上面的 useFetch、useLocalStorage、useDebounce,你已经掌握了框架来编写其他 Hook,如 useMediaQuery、useForm,真正践行“一次编写,到处使用”的精神。

reactcustom-hookjavascriptfrontendtesting