React 学习站
React›进阶›进阶

自定义 Hook

进阶进阶

一句话定义

自定义 Hook 是一个名字以 use 开头、内部可以调用其他 Hook 的函数——它复用的是「带状态的行为逻辑」,而不是 UI;每次调用得到彼此独立的一套状态。

为什么重要

组件复用解决「长得像」,自定义 Hook 解决「行为像」:拉取数据、订阅主题、防抖、轮询、本地存储……这些横切逻辑一旦散落在组件里,就是重复与 bug 的温床。它是 React 生态第三方 Hook 库(react-use、ahooks 等)得以存在的基石。

前置知识

  • kp-010 effect 与清理。
  • kp-014 Hooks 规则(自定义 Hook 同样遵守)。

核心概念

  • 命名约定即 API:use 前缀让 lint 与读者都明白「内部含 Hook,有状态、有快照语义」。
  • 每次调用独立:两个组件各调一次 useToggle(),状态互不相干(因为 Hook 状态挂在各自的组件实例上)。
  • 组合即复用:自定义 Hook 内部自由组合 useState/useEffect/useRef,对调用方只暴露「值 + 行为」。
  • 返回形状:数组([value, fn],像 useState)或对象(多个值时更易读)。
  • 不共享数据,共享行为:要共享数据用 Context(kp-012)或状态库(kp-021)。

原理 / 机制

把 kp-010 的 usePersistentState 升级为「带过期与容错」的成品,展示成熟 Hook 的设计过程:

jsxexport function usePersistentState(key, initial, { validate } = {}) {
  const [value, setValue] = useState(() => {
    try {
      const raw = localStorage.getItem(key);
      if (raw === null) return initial;
      const parsed = JSON.parse(raw);
      return validate ? validate(parsed) ?? initial : parsed; // 脏数据回退
    } catch {
      return initial; // JSON 解析失败回退
    }
  });

  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch { /* 存储满/隐私模式:静默降级 */ }
  }, [key, value]);

  return [value, setValue];
}

设计要点清单(写一个 Hook 前过一遍):

  1. 参数最小化:调用方不该理解内部细节。
  2. 边界处理内聚:脏数据、异常、竞态在 Hook 内消化。
  3. 返回稳定形状:数组或对象,别混。
  4. 命名表达「行为」而非「实现」:useOnlineStatus 而不是 useWindowEvents。
  5. 文档写清副作用与清理语义。

跨 Hook 组合(Hook 调 Hook):

jsxfunction useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

// 组合:搜索 = 持久化关键词 + 防抖请求
function useSearch(initial) {
  const [q, setQ] = usePersistentState('search.q', initial);
  const debouncedQ = useDebouncedValue(q, 300);
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    fetch(`/api/search?q=${encodeURIComponent(debouncedQ)}`)
      .then(r => r.json())
      .then(data => { if (!ignore) { setResults(data); setLoading(false); } });
    return () => { ignore = true; setLoading(false); };
  }, [debouncedQ]);

  return { q, setQ, results, loading };
}

直观类比

自定义 Hook 像装修队分包:水电(数据请求)、油漆(样式订阅)、门窗(事件监听)各自是专业队伍(Hook),你家(组件)只负责验收结果(调用拿到状态)。两户人家(两个组件实例)各请各的施工队,互不串工——「独立状态、共享工艺」。

实例 / 案例

拆分一个「仪表盘组件」:原来 200 行混合了轮询、键盘快捷键、图表配色;抽出三个 Hook 后组件只剩 JSX 与布局——

  • usePolling(url, ms):定时拉数据,页面隐藏时暂停(visibility API)。
  • useHotkeys(map):键盘映射集中管理。
  • useThemeTokens():从 Context 拿主题派生 CSS 变量。

组件从「逻辑面条」变成「编排层」,且三个 Hook 都可以单测(kp-027)。

常见误区

  • 自定义 Hook 里返回 JSX:那就不是 Hook,是组件(useCard() 返回 JSX → 改写成 <Card/>)。
  • 以为 Hook 之间共享状态:useUser() 在两个组件各调一次会发两次请求——要共享数据得配 Context 或外部 store。
  • 过度抽象:只用一次、且只有两行的逻辑不值得抽 Hook;抽象要等「第三个使用者」出现。
  • 忘了清理或竞态守卫:Hook 内的 effect 问题会被 N 个组件放大;抽 Hook 前先确认基础 effect 写对了(kp-010)。
  • 参数是不稳定引用导致 effect 疯狂重跑:调用方传内联对象/函数 → Hook 内依赖数组每次都变;在 Hook 内用 useMemo 归一,或文档要求调用方保证引用稳定。

自测题

  1. 「两个组件调用同一个 useToggle(),状态为什么互不影响」?用 Hooks 链表(kp-014)解释。
  2. 把 kp-011 的受控表单逻辑抽成 useForm(initial),设计其返回值形状。
  3. 自定义 Hook 与普通工具函数(formatDate())的本质区别是什么?
  4. useSearch 里如果调用方每次渲染都传新的 initial,会发生什么?如何防御?

与其他知识点的关系

  • 向前:是 kp-010/kp-012/kp-016 等模式的「复用外壳」。
  • 向后:kp-016 的 reducer 常被打包进 Hook;kp-017 的 ref 模式常封装为 useEventListener 类工具;kp-021 的数据层(TanStack Query)本质是「巨型数据 Hook」;kp-027 中 Hook 可脱离组件直接测试。

延伸阅读

  • Reusing Logic with Custom Hooks:<https://react.dev/learn/reusing-logic-with-custom-hooks>
  • ahooks(社区 Hook 库范例):<https://ahooks.js.org/>