陌上人如玉
公子世无双

2.12 自定义 Hook

2.12 自定义 Hook

一句话:自定义 Hook 是「把有状态的逻辑抽出来复用」的方式——它复用的是逻辑而不是状态(每个使用它的组件都有独立的状态);命名必须以 use 开头。

一、什么是自定义 Hook

// 一个最简单的自定义 Hook
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);

  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return { value, toggle, setTrue, setFalse };
}

// 使用(注意:两个组件里的 value 是【各自独立】的)
function ComponentA() {
  const { value, toggle } = useToggle();
  return ;
}

function ComponentB() {
  const { value, toggle } = useToggle(true);    // 各自独立的状态
  return ;
}

核心特点:

特点 说明
复用逻辑,不复用状态 每个调用方有独立的 state(这是与「全局 store」的本质区别)
就是普通函数 没有特殊语法,只是「函数内部调用了其它 Hook」
命名必须以 use 开头 否则 ESLint 无法检查 Hook 规则,且语义不清
遵循 Hook 规则 必须在顶层调用,内部也不能有条件/循环里的 Hook

二、与普通函数、组件的区别

三种东西的边界:

① 纯函数(utils)
   ✅ 无状态、无副作用、无 Hook
   例:formatDate、debounce、groupBy

② 自定义 Hook(hooks)
   ✅ 内部使用了 React 的 Hook(useState / useEffect / useRef…)
   ✅ 用于「复用有状态的逻辑」
   例:useDebounce、useFetch、useLocalStorage

③ 组件(components)
   ✅ 返回 JSX(React 元素)
   ✅ 用于「复用 UI」
   例:
// 同一个需求:防抖。三种实现方式的区别

// ❌ 纯函数:无法在组件里正确使用(每次渲染都会重建定时器)
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}
// 在组件里用:const debounced = debounce(fn, 300); → 每次渲染都是新的闭包

// ✅ 自定义 Hook:把「跨渲染的定时器」用 useRef 保存
function useDebouncedCallback void>(fn: T, delay: number) {
  const timerRef = useRef();
  const fnRef = useRef(fn);
  useEffect(() => { fnRef.current = fn; });          // 保存最新回调

  return useCallback((...args: Parameters) => {
    clearTimeout(timerRef.current);
    timerRef.current = window.setTimeout(() => fnRef.current(...args), delay);
  }, [delay]);

  // 清理(组件卸载时)
  useEffect(() => () => clearTimeout(timerRef.current), []);
}

// ✅ 另一种形式:返回「防抖后的值」而不是「防抖后的函数」
function useDebouncedValue(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);          // ⭐ value 变化时清掉上一个定时器
  }, [value, delay]);

  return debounced;
}

三、两条命名与调用规则

① 命名必须 use 开头
   ✅ useDebounce / useFetch / useLocalStorage
   ❌ debounceValue / fetchData / localStorageHook

   原因:
     · ESLint 的 rules-of-hooks 靠前缀识别「可能包含 Hook 的函数」
     · 语义约定(告诉使用者「这是 Hook,要遵守规则」)

② 必须在顶层调用(与 Hook 规则一致)
   ❌ 在条件、循环、嵌套函数里调用自定义 Hook
   ❌ 在事件处理里调用
// ❌ 有条件地调用自定义 Hook
function Comp({ shouldTrack }) {
  if (shouldTrack) {
    const position = useMousePosition();    // ❌ 违反规则
  }
}

// ✅ 把条件放到 Hook 内部
function useMousePosition(enabled: boolean) {
  const [pos, setPos] = useState({ x: 0, y: 0 });

  useEffect(() => {
    if (!enabled) return;                   // ✅ 条件在内部
    const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, [enabled]);

  return pos;
}

function Comp2({ shouldTrack }) {
  const position = useMousePosition(shouldTrack);   // ✅ 无条件调用
}

四、设计原则

① 单一职责:一个 Hook 只做一件事
   ❌ useUserDataAndThemeAndCart()
   ✅ useUser() / useTheme() / useCart()

② 返回「稳定」的东西
   · 返回的函数用 useCallback(避免调用方依赖它时反复触发)
   · 返回的对象/数组用 useMemo(如果调用方会把它放进依赖)

③ 返回「ref 而不是值」时要注意语义
   · 返回 ref → 调用方能传给 JSX(如 useLocalStorage、useElementSize)
   · 返回 [value, setValue] → 更像 useState 的用法(熟悉)

④ 接收「ref 或值」都支持(更灵活)
   const el = useElementSize(containerRef);       // 传 ref
   const el2 = useElementSize(selector);           // 也支持选择器

⑤ 清理必须内部完成(调用方不需要关心)
   · 定时器、监听、Observer、订阅都在 Hook 内部清理
   · 这是自定义 Hook 最大的价值之一

⑥ 不要把「纯计算」抽成 Hook
   · 没有 Hook 调用的逻辑 → 普通工具函数(更易测试、更快)
// ⑤ 的价值示例:调用方完全不用操心清理
function useWindowSize() {
  const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });

  useEffect(() => {
    const onResize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
    window.addEventListener('resize', onResize);
    return () => window.removeEventListener('resize', onResize);   // ⭐ 内部清理
  }, []);

  return size;
}

// 调用方极其简洁,也不会忘记清理
function Layout() {
  const { width } = useWindowSize();
  return 
{width > 768 ? '桌面' : '移动'}
; }

五、返回值的两种风格

// ① 返回数组(像 useState,可以用任意名字解构)
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  return [value, toggle] as const;         // ⭐ as const 保证元组类型
}

const [isOpen, toggleOpen] = useToggle();          // 调用方可以自由命名 ✅

// ② 返回对象(调用方按需取,加字段不破坏调用方)
function useFetchData(url: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const refetch = useCallback(() => { /* ... */ }, [url]);

  return { data, loading, error, refetch };        // ✅ 后续加字段不影响调用方
}

const { data, loading } = useFetchData('/api/users');   // 按需解构
风格 优点 适用
数组 调用方可自由命名;像 useState 返回值少且语义固定(如 [value, setValue])
对象 可选择性解构;新增字段不破坏调用方 返回值多({ data, loading, error })

注意:返回对象时每次渲染都是新对象,如果调用方把它放进依赖数组会出问题。所以「需要放进依赖」的场景要么返回数组(元素自身稳定),要么在 Hook 内部用 useMemo 稳定返回对象(但这通常得不偿失)。

六、高频自定义 Hook 清单

// ===== 状态类 =====
useToggle()                        // 布尔切换
useCounter(initial)                // 计数器
useLocalStorage(key, initial)      // 同步到 localStorage
useSessionStorage(key, initial)
useMap / useSet                    // 提供操作方法的 Map / Set

// ===== 副作用类 =====
useDebounce(value, delay)          // 防抖后的值
useDebounceFn(fn, delay)           // 防抖后的函数
useThrottle(value, interval)
useInterval(fn, delay)             // 可动态调整间隔的 setInterval
useTimeout(fn, delay)

// ===== 浏览器 API =====
useWindowSize()
useMediaQuery(query)
useOnlineStatus()
useClickOutside(ref, handler)
useEventListener(target, type, handler)
useScrollPosition()
useIntersectionObserver(ref, options)
useResizeObserver(ref, callback)

// ===== 数据类 =====
useFetch(url, options)             // 简化版请求
useAsync(fn, deps)                 // 通用异步
usePrevious(value)                 // 上一次的值
useIsFirstRender()                 // 是否首次渲染
useIsomorphicLayoutEffect()        // SSR 安全的 useLayoutEffect

// ===== 表单类 =====
useForm(initialValues)             // 或直接用 react-hook-form
useFieldArray()
// 几个高频 Hook 的典型实现

// ① useClickOutside
function useClickOutside(handler: () => void) {
  const ref = useRef(null);

  useEffect(() => {
    const onClick = (e: MouseEvent) => {
      if (!ref.current?.contains(e.target as Node)) handler();
    };
    document.addEventListener('pointerdown', onClick);
    return () => document.removeEventListener('pointerdown', onClick);
  }, [handler]);

  return ref;                       // ⭐ 返回 ref 让调用方绑到元素上
}

// 使用
function Dropdown() {
  const [open, setOpen] = useState(false);
  const ref = useClickOutside(() => setOpen(false));
  return 
...
; } // ② useIsFirstRender function useIsFirstRender() { const isFirst = useRef(true); useEffect(() => { isFirst.current = false; }, []); return isFirst.current; } // ③ usePrevious function usePrevious(value: T): T | undefined { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; } // ④ useInterval(可动态调整间隔) function useInterval(callback: () => void, delay: number | null) { const savedCallback = useRef(callback); useEffect(() => { savedCallback.current = callback; }, [callback]); useEffect(() => { if (delay === null) return; const id = setInterval(() => savedCallback.current(), delay); return () => clearInterval(id); }, [delay]); }

七、组合多个 Hook

// 自定义 Hook 可以调用其它自定义 Hook(组合是它的核心价值)
function useUserProfile(userId: string) {
  // ① 请求数据
  const { data: user, loading, error, refetch } = useFetch(`/api/users/${userId}`);

  // ② 本地偏好
  const [favorites, setFavorites] = useLocalStorage('favorites', []);

  // ③ 计算派生值
  const isFavorite = useMemo(
    () => user != null && favorites.includes(user.id),
    [user, favorites]
  );

  // ④ 跨标签页同步(订阅 storage 事件)
  useEffect(() => {
    const onStorage = (e: StorageEvent) => {
      if (e.key === 'favorites') setFavorites(JSON.parse(e.newValue ?? '[]'));
    };
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, [setFavorites]);

  const toggleFavorite = useCallback(() => {
    if (!user) return;
    setFavorites((prev) =>
      prev.includes(user.id) ? prev.filter((id) => id !== user.id) : [...prev, user.id]
    );
  }, [user, setFavorites]);

  return { user, loading, error, refetch, isFavorite, toggleFavorite };
}

// 使用:一个 Hook 聚合了一整块业务逻辑
function ProfilePage({ userId }: { userId: string }) {
  const { user, loading, error, isFavorite, toggleFavorite } = useUserProfile(userId);

  if (loading) return ;
  if (error) return ;
  if (!user) return null;

  return (
    

{user.name}

); }

八、常见坑

// ① 命名不以 use 开头 → lint 失效 + 语义不清
function fetchData() { const [d, setD] = useState(null); }   // ❌
function useFetchData() { const [d, setD] = useState(null); } // ✅

// ② 有条件地调用自定义 Hook
if (cond) useSomething();               // ❌

// ③ 在自定义 Hook 内部有条件地调用 Hook
function useBad(cond: boolean) {
  const [a] = useState(0);
  if (cond) {
    const [b] = useState(1);            // ❌
  }
}

// ④ 把「纯计算」抽成 Hook(没必要)
function useFullName(first: string, last: string) {
  return `${first} ${last}`;            // ❌ 没有 Hook,应该是普通函数
}

// ⑤ 忘记在内部清理副作用
function useBadEffect() {
  useEffect(() => {
    window.addEventListener('scroll', onScroll);   // ❌ 没有清理
  }, []);
}

// ⑥ 返回「每次渲染都是新对象」的结果,调用方放进依赖导致死循环
function useValue() {
  return { data: someValue };           // ⚠️ 调用方放进依赖会出问题
}

// ⑦ 返回的函数不稳定 → 调用方的 effect 反复执行
function useHandler() {
  return () => doSomething();           // ❌ 每次渲染都是新函数
  // ✅ return useCallback(() => doSomething(), []);
}

// ⑧ 在 Hook 里用「每次渲染都新建的对象」作为依赖
useEffect(() => {}, [{ a: 1 }]);        // ❌

// ⑨ 抽 Hook 抽得「过度」(一个 Hook 里塞了十几个 useState)
// → 按职责拆分

// ⑩ Hook 里依赖了调用方没传的东西(隐式耦合)
// → 通过参数显式传入(如 useFetch(url) 而不是读全局变量)

// ⑪ 认为「自定义 Hook 的状态是共享的」
// → 每个调用方有独立状态;要共享应该用 Context 或 store

// ⑫ 在 Hook 里返回 ref 却没有说明「要绑到哪个元素上」
// → 用 JSDoc 或类型说明(`@returns 需要绑定到目标元素的 ref`)

面试延伸

  1. 「自定义 Hook 是什么?」
    自定义 Hook 是一个以 use 开头的函数,它内部可以调用其它 Hook,用来「复用有状态的逻辑」。关键点是:① 复用逻辑而不是状态——每个调用它的组件都有独立的 state;② 它只是普通函数(没有特殊语法),但必须遵守 Hook 规则(顶层调用);③ 命名必须以 use 开头,否则 ESLint 无法检查、语义也不清晰。

  2. 「自定义 Hook 和普通函数有什么区别?」
    判断标准是「内部有没有用到 Hook」:① 用到了 Hook(useState/useEffect/useRef…)→ 自定义 Hook(可以保持跨渲染的状态、可以注册副作用);② 纯计算、无状态、无副作用→ 普通工具函数(更易测试、更轻量)。不要为了「看起来像 Hook」而把纯函数写进 Hook——那样只是多了一层函数调用。

  3. 「自定义 Hook 和组件有什么区别?」
    接口不同:组件返回 JSX(React 元素),是「UI 的复用单位」;自定义 Hook 返回任意值(状态、方法、ref),是「逻辑的复用单位」。一个常见的组合是「用自定义 Hook 提供逻辑,用组件提供 UI」——这样同一套逻辑可以配不同的 UI(Headless UI 的思路)。

  4. 「为什么自定义 Hook 必须 use 开头?」
    两个原因:① ESLint 依赖命名识别——rules-of-hooks 只检查 use 开头的函数内部的 Hook 调用;不这么命名会让 lint 形同虚设,隐藏 bug;② 语义约定——use 前缀明确告诉调用者「这里有状态/副作用,必须遵守 Hook 规则(顶层调用)」。所以这是功能性要求,不只是风格。

  5. 「自定义 Hook 的状态是共享的吗?」
    不是。每个调用它的组件都有独立的状态(因为 useState 的状态挂在调用它的组件实例上)。这与「全局 store」有本质区别:useToggle() 在两个组件里各自独立,而 Zustand 的 useStore() 两个组件共享同一份数据。需要共享状态时应该用 Context 或状态管理库,而不是自定义 Hook。

  6. 「怎么设计一个好的自定义 Hook?」
    六条经验:① 单一职责(一个 Hook 只做一件事);② 内部完成清理(定时器/监听/Observer 都由 Hook 负责,调用方不用操心);③ 返回稳定引用(函数用 useCallback,避免调用方的依赖反复变化);④ 返回值风格按数量选(少用数组、多用对象);⑤ 参数显式(不要在 Hook 里读全局变量,通过参数传入);⑥ 纯逻辑不要抽成 Hook(没有 Hook 调用就用普通函数)。

  7. 「自定义 Hook 能做哪些典型的事?」
    三类:① 状态封装(useToggle、useCounter、useLocalStorage、useForm);② 副作用协调(useDebounce、useInterval、useEventListener、useIntersectionObserver);③ 业务逻辑聚合(useUserProfile、useCart、usePermission——把「请求 + 本地状态 + 派生值 + 操作方法」打包成一块)。第三类是实际项目中最有价值的用法。

  8. 「怎么处理自定义 Hook 里 useEffect 的依赖问题?」
    三条经验:① Hook 内部的 Effect 依赖 Hook 的参数与内部状态(而不是外部变量);② 需要「读最新值但不重建订阅」时用 useRef 保存(useInterval、useEventListener 里都是这个模式);③ 回调参数用 ref 保存(const fnRef = useRef(fn); useEffect(() => { fnRef.current = fn; })),这样就能在依赖数组里只写「真正需要重建的项」(如 delay),避免调用方传入的内联函数导致反复重建。

一句话速记

自定义 Hook 是「复用有状态逻辑」的方式(复用逻辑不复用状态,每个调用方独立);命名必须 use 开头(否则 lint 失效)、必须顶层调用(条件逻辑放进 Hook 内部);它和普通函数的边界是「内部有没有用 Hook」,和组件的边界是「返回 JSX 还是任意值」;设计要点是「单一职责 + 内部完成清理 + 返回稳定引用 + 参数显式」;高频清单里有 useDebounce、useLocalStorage、useFetch、useEventListener、useClickOutside、useInterval 等;要共享状态就用 Context/store,不要指望自定义 Hook。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.12 自定义 Hook

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫