#React#TypeScript

React 自定义 Hooks 架构:提取、组合与测试

Hooks 不只是 useState/useEffect——自定义 Hooks 是 React 的"复用单元"。本文讲清什么时候提取自定义 Hook(重复逻辑/副作用/状态逻辑)、Hook 组合(Hook 调 Hook)、依赖与 stale closure、测试 Hooks(renderHook + act),以及"别过度抽象 Hook"。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年2月1日·更新于 2026年7月23日

React 自定义 Hooks 架构:提取、组合与测试

很多团队写 React 写了三年,项目里能找到的自定义 Hook 只有 useWindowSize。这不是因为他们没有可复用的逻辑——恰恰相反,他们的组件里有大量重复的状态管理、副作用、数据请求,只是这些逻辑从来没被"提取"出来。

自定义 Hook 不是高级技巧,它是 React 提供的唯一的复用单元。在 Hooks 出现之前,我们用 HOC(高阶组件)和 render props 来复用逻辑,结果都写成了嵌套地狱。Hooks 的革命性不在于"函数式组件",而在于它给了我们一种不引入新组件层级就能复用状态逻辑的方式。

但这不意味着"万物皆 Hook"。本文想讲清楚四件事:什么时候提取、怎么组合、怎么测、什么时候别提取。

一、自定义 Hook 是 React 的复用单元

先校准一下认知:自定义 Hook 的本质就是一个函数。它没有任何魔法,唯一的约束是——它必须以 use 开头,并且在内部调用其他 Hook。

typescript
// 这就是一个自定义 Hook,没什么神秘的
function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

为什么说它是"复用单元"?因为对比一下历史上的三种方案就明白了:

HOC(高阶组件):withAuth(withTheme(withRouter(Component)))——三层包裹,props 来源不透明,TypeScript 类型地狱,调试时 DevTools 里是一坨匿名组件。

render props:<Auth>{({ user }) => <Theme>{({ theme }) => ...}</Theme>}</Auth>——嵌套金字塔,回调地狱的 JSX 版。

自定义 Hook:const { user } = useAuth(); const { theme } = useTheme();——平铺、线性、类型自动推导、DevTools 可见。

Hooks 赢在没有引入新的组件树层级。逻辑复用不再需要"包裹组件",而是像调用函数一样自然。这是它真正取代 HOC 和 render props 的原因。

一句话:自定义 Hook 让"复用状态逻辑"和"复用普通函数"的写法统一了。

二、什么时候该提取自定义 Hook

不是所有逻辑都该变成 Hook。我见过有人把 const fullName = firstName + ' ' + lastName 包成 useFullName()——这是病,得治。

提取自定义 Hook 的合理信号有三个:

1. 重复的状态逻辑

同样的 useState + useEffect + handler 组合在两个以上组件里出现:

tsx
// 搜索框里
function SearchBox() {
  const [query, setQuery] = useState('');
  const [debounced, setDebounced] = useState('');
  useEffect(() => {
    const t = setTimeout(() => setDebounced(query), 300);
    return () => clearTimeout(t);
  }, [query]);
  // ...
}

// 用户名输入里又写了一遍——这就是信号
function UsernameInput() {
  const [name, setName] = useState('');
  const [debounced, setDebounced] = useState('');
  useEffect(() => {
    const t = setTimeout(() => setDebounced(name), 300);
    return () => clearTimeout(t);
  }, [name]);
  // ...
}

这种重复就该提取成 useDebounce。第二次手写一模一样的代码时就是信号,不要等到第三次。

2. 复杂的副作用

当一个 useEffect 里有:订阅、取消订阅、清理函数、状态同步,再叠加上业务逻辑,组件可读性会迅速崩塌。把副作用抽出去,组件只关心"我要什么数据",不关心"数据怎么来的"。

典型的就是数据请求、WebSocket 订阅、Intersection Observer——这些都有生命周期,需要 cleanup,天生适合 Hook 封装。

3. 跨组件共享的状态逻辑

useAuth、useTheme、useCurrentUser、useFeatureFlag——这些是"横切关注点"。多个不相关组件都要用,写到 props 里层层传递(prop drilling)就是灾难。提到 Hook 里(通常配合 Context)一劳永逸。

判断标准很简单:如果这段逻辑脱离了某个具体组件依然有意义,它就该是独立的 Hook。 useUserProfile(userId) 有意义,useButtonHoverState 就没意义——它和按钮组件是同生共死的。

三、Hook 组合:Hook 调 Hook

这是自定义 Hook 最强、也最容易被忽视的能力:Hook 可以调用其他 Hook。

这意味着你可以分层构建复杂逻辑。底层的、原子的 Hook 提供基础能力,上层的 Hook 组合它们提供业务语义。

举个真实的例子。假设我们要做一个 useAuth,它需要:

  • 从 localStorage 读 token
  • 监听 token 变化
  • 提供 login/logout

不要把所有东西塞进一个 Hook。拆成原子 Hook,再组合:

typescript
// 底层原子 Hook:负责 localStorage 读写
function useStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const raw = window.localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota 超了、隐私模式,静默失败
    }
  }, [key, value]);

  return [value, setValue] as const;
}

// 中间层:负责 token 的存储语义
function useToken() {
  const [token, setToken] = useStorage<string | null>('auth_token', null);
  return { token, setToken, isAuthenticated: token !== null };
}

// 顶层:业务语义
function useAuth() {
  const { token, setToken, isAuthenticated } = useToken();
  const [user, setUser] = useState<User | null>(null);

  useEffect(() => {
    if (!token) {
      setUser(null);
      return;
    }
    fetchCurrentUser(token).then(setUser).catch(() => setToken(null));
  }, [token, setToken]);

  const login = useCallback(
    async (credentials: Credentials) => {
      const { token: newToken } = await loginApi(credentials);
      setToken(newToken);
    },
    [setToken],
  );

  const logout = useCallback(() => setToken(null), [setToken]);

  return { user, isAuthenticated, login, logout };
}

这种分层的好处是每一层都能独立测试、独立复用。useStorage 可以用在设置页面,useToken 可以用在不需要用户信息只判断登录态的场合,useAuth 是完整方案。

组合 Hook 时唯一要记住的规则是 React 的 Rules of Hooks:不能在条件、循环、嵌套函数里调用 Hook。组合 Hook 时,被调用的 Hook 必须在组合 Hook 的顶层无条件执行。这是 React 的硬约束,违反它会导致渲染顺序错乱。

四、依赖数组的正确管理与 stale closure 陷阱

这一节是事故高发区。useEffect、useMemo、useCallback 的第二个参数(依赖数组)是 React 和闭包博弈的产物。理解错了,你会得到两类 bug:

  1. 无限重渲染:依赖里放了引用每次都变的对象/函数。
  2. stale closure(过时闭包):依赖漏了,effect 里用到了"旧"的值。

stale closure 是什么

typescript
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // 这里的 count 永远是 0!
      // 因为这个 effect 只在挂载时跑了一次,闭包捕获了最初的 count
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []); // <-- 依赖数组空,effect 只跑一次

  return <div>{count}</div>;
}

这个组件数字永远停在 1。因为 setInterval 的回调里捕获的 count 是第一次渲染时的 0,0 + 1 = 1,下一次 0 + 1 还是 1。

修法一:用函数式更新,不依赖外部值。

typescript
useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // c 是最新的
  }, 1000);
  return () => clearInterval(id);
}, []);

修法二:把 count 加进依赖(但这样每秒都会清掉定时器重建,性能差)。

修法三:用 ref 保存最新值,effect 里读 ref。

依赖数组的管理原则

不要"凭感觉"写依赖数组。规则是机械的:

  • effect 里用到了什么响应式值(state、props、派生的变量),就写什么。
  • 用 ESLint 的 react-hooks/exhaustive-deps 规则强制检查——它会告诉你漏了什么。如果它报警而你"故意"不想加,99% 的情况是你应该重构,而不是禁用规则。

如果你发现一个 effect 的依赖太多、每次都触发,正确的反应不是用 // eslint-disable-next-line,而是:

  • 用 useCallback / useMemo 稳定依赖。
  • 用 reducer 把多个 state 合并,用 dispatch 代替多个 state(dispatch 引用永远稳定)。
  • 拆 effect,让每个 effect 只关心一件事。

ref:绕过闭包的逃生舱

当你确实需要在"只跑一次"的 effect 里访问最新值,用 ref:

typescript
function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value; // 每次渲染都更新
  return ref;
}

function Counter() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(countRef.current + 1); // 永远是最新值
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
}

useLatest 是社区里很经典的一个工具 Hook,本质就是把"读最新值"这个需求显式化。

依赖数组的金科玉律:不要骗它。它说缺依赖,就补;补不了就重构;重构不了就用 ref。永远不要 disable lint。

五、测试自定义 Hooks

Hook 不能脱离组件直接调用(因为它们依赖 React 的渲染上下文)。要测 Hook,用 @testing-library/react 的 renderHook,配合 act。

renderHook + act 基础

typescript
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';

test('useToggle 在 toggle 调用后翻转状态', () => {
  const { result } = renderHook(() => useToggle(false));

  expect(result.current[0]).toBe(false);

  act(() => {
    result.current[1](); // 调用 toggle
  });

  expect(result.current[0]).toBe(true);
});

两个关键点:

  1. result.current:始终指向 Hook 最新一次的返回值。每次重渲染后 result.current 会更新,但你必须用 result.current(而不是解构出 const [on] = result.current——那会拿到旧值)。
  2. act:任何会引起状态更新的操作(调用 setState、resolve promise、触发 timer)都必须包在 act() 里。否则 React 会警告、且断言可能基于未刷新的状态。

测带 effect 的 Hook(以 useDebounce 为例)

typescript
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest'; // 或 jest

test('useDebounce 在延迟后更新 debounced 值', () => {
  vi.useFakeTimers();

  const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
    initialProps: { value: 'hello' },
  });

  // 立刻读,应该是初始值
  expect(result.current).toBe('hello');

  // 改变输入
  rerender({ value: 'world' });
  expect(result.current).toBe('hello'); // 还没到时间

  // 快进 200ms
  act(() => {
    vi.advanceTimersByTime(200);
  });
  expect(result.current).toBe('hello'); // 还差 100ms

  // 快进到 300ms
  act(() => {
    vi.advanceTimersByTime(100);
  });
  expect(result.current).toBe('world');

  vi.useRealTimers();
});

注意 rerender:测试 Hook 响应 props/state 变化时,用 rerender 模拟组件重渲染。配合 initialProps 传初始值。

测异步 Hook(useAsync 一类)

异步场景要 waitFor:

typescript
test('useAsync 在 resolve 后返回 data', async () => {
  const fetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' });

  const { result } = renderHook(() => useAsync(fetcher));

  expect(result.current.state).toBe('loading');

  await waitFor(() => {
    expect(result.current.state).toBe('success');
  });
  expect(result.current.data).toEqual({ id: 1, name: 'Alice' });
});

记住:异步断言永远不要直接 expect,要包在 waitFor 或 findBy* 里,否则你测的是"还没 resolve 时的状态",永远红或永远绿。

六、常见模式:手写五个高复用 Hook

把这几个写熟,日常 80% 的复用需求就覆盖了。

useDebounce(防抖)

typescript
import { useEffect, useState } from 'react';

function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer); // value 或 delay 变了就清掉旧定时器
  }, [value, delay]);

  return debounced;
}

要点:cleanup 函数是防抖的核心。每次 value 变化都会触发 effect 重跑,旧的 timer 被清理,只有"最后一次变化后安静了 delay 毫秒"才会真正 setState。

useLocalStorage(持久化状态)

typescript
import { useCallback, useEffect, useState } from 'react';

function useLocalStorage<T>(
  key: string,
  initialValue: T,
): readonly [T, (value: T | ((prev: T) => T)) => void] {
  const readValue = useCallback((): T => {
    if (typeof window === 'undefined') return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch (err) {
      console.warn(`useLocalStorage: 读取 ${key} 失败`, err);
      return initialValue;
    }
  }, [key, initialValue]);

  const [storedValue, setStoredValue] = useState<T>(readValue);

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      setStoredValue((prev) => {
        const next =
          typeof value === 'function' ? (value as (p: T) => T)(prev) : value;
        try {
          window.localStorage.setItem(key, JSON.stringify(next));
        } catch (err) {
          console.warn(`useLocalStorage: 写入 ${key} 失败`, err);
        }
        return next;
      });
    },
    [key],
  );

  // 监听其他 tab 对同一 key 的修改
  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        try {
          setStoredValue(JSON.parse(e.newValue) as T);
        } catch {
          /* ignore */
        }
      }
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, [key]);

  return [storedValue, setValue] as const;
}

注意几个工程细节:SSR 安全(typeof window === 'undefined')、try/catch 兜底(隐私模式、quota 满了)、跨标签页同步(监听 storage 事件)、支持函数式更新。这些是"能跑"和"线上不出事"的区别。

useAsync(异步请求状态机)

typescript
import { useEffect, useState } from 'react';

type AsyncState<T> =
  | { state: 'idle' }
  | { state: 'loading' }
  | { state: 'success'; data: T }
  | { state: 'error'; error: Error };

function useAsync<T>(
  asyncFn: () => Promise<T>,
  deps: unknown[] = [],
) {
  const [status, setStatus] = useState<AsyncState<T>>({ state: 'idle' });

  useEffect(() => {
    let cancelled = false;
    setStatus({ state: 'loading' });

    asyncFn()
      .then((data) => {
        if (!cancelled) setStatus({ state: 'success', data });
      })
      .catch((error: Error) => {
        if (!cancelled) setStatus({ state: 'error', error });
      });

    return () => {
      cancelled = true; // 防止卸载后 setState
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return status;
}

这里有两个关键工程点:cancelled 标志位防止组件卸载后还 setState(会触发 React 警告甚至内存泄漏);deps 由调用方显式传入,避免把 asyncFn 这种引用不稳定的函数直接放进依赖(会无限重跑)。

usePrevious(取上一次渲染的值)

typescript
import { useEffect, useRef } from 'react';

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>(undefined);

  useEffect(() => {
    ref.current = value; // 在渲染完成后更新,所以本次返回的还是旧值
  }, [value]);

  return ref.current;
}

理解这道题就理解了 ref 和 effect 的执行时机:useEffect 在 DOM 提交后才跑,所以这次 render 返回 ref.current(旧的),effect 里再把它更新成新值,下一次 render 就能拿到这次的"上一个"。

useMediaQuery(响应式状态)

typescript
import { useEffect, useState } from 'react';

function useMediaQuery(query: string): boolean {
  const getMatches = () =>
    typeof window !== 'undefined' ? window.matchMedia(query).matches : false;

  const [matches, setMatches] = useState(getMatches);

  useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);

    setMatches(mql.matches); // 挂载时同步一次(query 可能变了)
    mql.addEventListener('change', handler);
    return () => mql.removeEventListener('change', handler);
  }, [query]);

  return matches;
}

注意:addEventListener('change', ...) 是现代写法,老代码里的 addListener 已废弃。SSR 安全也要照顾到。

七、别过度抽象:不是所有逻辑都要变 Hook

这一节可能比前面所有加起来都重要。

我见过太多团队的代码库,为了"复用",把任何三行以上的逻辑都包成 useXxx。结果:

  • useFullName(firstName, lastName) ——返回拼接字符串。这就是个纯函数,包成 Hook 多此一举,每次渲染还都走一遍 Hook 调度。
  • useIsEven(number) ——返回 number % 2 === 0。同上。
  • useFormatDate(date) ——包了 date-fns 的 format。同上。

判断标准:如果这段逻辑不调用任何 React API(useState、useEffect、useRef 等),它就不该是 Hook,它该是个普通函数。

typescript
// ❌ 多此一举
function useFullName(first: string, last: string) {
  return `${first} ${last}`;
}

// ✅ 老老实实当普通函数
function getFullName(first: string, last: string) {
  return `${first} ${last}`;
}

另一种过度抽象是提前抽象。当你只有一处用到某段逻辑时,先内联。等第二处真的出现了,再提取。不要为了"将来可能复用"提前抽象——你预测不准,提前抽出来的 Hook 接口十有八九会在第二次使用时被迫改造,反而比一开始内联更费事。

规则:Rule of Three 的一半——第二次重复时就该提,但第一次就该克制。宁可内联两次,也不要为了"看似干净"提前抽。

还有一个反向陷阱:别为了减少组件层级把 UI 也塞进 Hook。Hook 返回的应该是数据和行为,而不是 JSX。如果你写出了 useModal() 返回一段 <dialog> JSX,那它其实是个组件,叫 <Modal> 更合适。Hook 管逻辑,组件管渲染,分工别乱。

八、命名约定:use 前缀不是建议,是强制

最后一条,但很重要:自定义 Hook 必须以 use 开头。

这不是代码风格问题,是 React 的硬性约束。原因有二:

  1. linter 依赖它。eslint-plugin-react-hooks 通过函数名是否以 use 开头来判断"这是个 Hook",从而检查它内部是否违反 Rules of Hooks。你把 Hook 命名为 fetchUser,linter 不会检查它内部的 Hook 调用顺序,bug 就溜过去了。

  2. React DevTools 依赖它。DevTools 的 Hooks 调试面板靠 use 前缀识别和分组 Hook 状态。没有前缀的 Hook 在面板里就是黑盒。

反过来也成立:不要把不是 Hook 的函数命名为 use 开头。useFullName 如果不调用任何 Hook,会误导 linter 去检查它、误导同事以为里面有状态。

命名上还有几个实战经验:

  • 返回值语义化:[state, setState] 这种元组适合简单 Hook(useState 风格),复杂 Hook 返回对象 { data, loading, error, refetch } 更易扩展。
  • 行为动词优先:useDebounce(做了什么)比 useDebouncedValue(返回什么)更稳,因为行为不会变,返回值可能会扩。
  • 避免 useData 这种空泛命名:什么数据?叫 useUser、useOrders、useCurrentUser 都比 useData 强一万倍。名字模糊通常是抽象边界没想清楚的信号。

小结

  1. 自定义 Hook 是 React 唯一的复用单元,它取代了 HOC 和 render props,因为复用状态逻辑时不再引入新的组件树层级。
  2. 提取时机看三信号:重复的状态逻辑、复杂的副作用、跨组件共享的横切关注点。第一次重复就该克制内联,第二次就该提取。
  3. Hook 可以调 Hook——分层组合(useAuth → useToken → useStorage)让每一层可独立测试、独立复用。
  4. 依赖数组不要骗它:exhaustive-deps 报警就补,补不了就重构,重构不了用 ref,永远别 disable lint。stale closure 不是 React 的 bug,是你没把依赖写全。
  5. 测试用 renderHook + act:状态更新必包 act,异步必包 waitFor,重渲染用 rerender。Hook 的测试比组件测试简单得多,没有理由不测。

最后送一句我这些年反复验证的话:

"用不用自定义 Hook"不是技术问题,是判断问题。好的 Hook 抽象让代码像散文一样清晰,坏的 Hook 抽象让代码比重复三遍更难懂。区别只在于——你是为了消除重复,还是为了显得有架构。

Related

相关推荐