Skip to content
第 15 章 前端 ⏱ 13 分钟阅读

第 15 章:自定义 Hook ​

学习目标 ​

  • 理解自定义 Hook 是逻辑复用机制
  • 掌握 5 个常见自定义 Hook 写法
  • 学会封装可测试的 Hook
  • 避开自定义 Hook 的 3 个常见坑

一、什么是自定义 Hook? ​

以 use 开头的普通函数,内部调用其他 Hook,封装可复用逻辑。

tsx
function useToggle(initial = false) {
  const [v, setV] = useState(initial);
  const toggle = useCallback(() => setV(x => !x), []);
  return [v, toggle] as const;
}

// 使用
function Comp() {
  const [open, toggleOpen] = useToggle(false);
  return <button onClick={toggleOpen}>{open ? '关' : '开'}</button>;
}

💡 useCallback = 让函数引用稳定,保护 memo 子组件——没 memo 子组件就别包。

自定义 Hook 是普通函数,不是组件(不能返回 JSX)。

二、5 个最常用自定义 Hook ​

1. useLocalStorage ​

tsx
function useLocalStorage<T>(key: string, initial: T) {
  const [value, setValue] = useState<T>(() => {
    const saved = localStorage.getItem(key);
    return saved ? JSON.parse(saved) as T : initial;
  });

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

  return [value, setValue] as const;
}

// 使用
const [name, setName] = useLocalStorage('name', '游客');

2. useDebounce ​

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

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);    // ⚠️ 取消旧定时器
  }, [value, delay]);

  return debounced;
}

// 使用
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

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

3. usePrevious ​

tsx
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();
  useEffect(() => { ref.current = value; }, [value]);
  return ref.current;     // ⚠️ 返回的是上次的值
}

// 使用
function Counter() {
  const [count, setCount] = useState(0);
  const prev = usePrevious(count);
  return (
    <>
      <p>当前:{count},上次:{prev}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
    </>
  );
}

为啥 prev 总是"上次"的值——return ref.current 总在 useEffect 跑之前:

React 三大阶段:
1. Render 阶段 → 函数体执行(包括 return)
2. Commit 阶段 → DOM 更新
3. Effect 阶段 → useEffect 跑

关键:return ref.current 是 render 阶段同步跑,useEffect 是 commit 后才跑——所以 return 永远先于 effect。

时序演示(count: 0 → 1 → 2):

步骤countref.current返回的 prevDOM 显示
首次 render0undefinedundefined"当前 0,上次 undefined"
首次 effect00——
点 +1 render10(还没改)0"当前 1,上次 0"
点 +1 effect11——
点 +1 render21(还没改)1"当前 2,上次 1"
点 +1 effect22——

核心:return ref.current 这一刻,effect 还没跑,所以看到的永远是**"上次 effect 改的值"**(也就是上次的值)。

💡 return 在 render 阶段同步跑,useEffect 在 commit 后异步跑——所以 return ref.current 永远先于 effect,看到的永远是上次的值。

4. useFetch ​

tsx
interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({ data: null, loading: true, error: null });

  useEffect(() => {
    const ctrl = new AbortController();
    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: ctrl.signal })
      .then(r => r.json())
      .then(data => setState({ data, loading: false, error: null }))
      .catch(e => {
        if (!ctrl.signal.aborted) setState({ data: null, loading: false, error: e as Error });
      });

    return () => ctrl.abort();
  }, [url]);

  return state;
}

// 使用
function Comp() {
  const { data, loading, error } = useFetch<User[]>('/api/users');
  if (loading) return <Spinner />;
  if (error) return <Error />;
  return <List users={data!} />;
}

5. useEventListener ​

tsx
function useEventListener<K extends keyof WindowEventMap>(
  event: K,
  handler: (e: WindowEventMap[K]) => void,
) {
  useEffect(() => {
    window.addEventListener(event, handler);
    return () => window.removeEventListener(event, handler);
  }, [event, handler]);
}

// 使用
function Comp() {
  useEventListener('resize', () => console.log(window.innerWidth));
  useEventListener('keydown', e => { if (e.key === 'Escape') close(); });
}

三、Hook 之间的相互调用 ​

自定义 Hook 可以调用其他自定义 Hook:

tsx
function useUserWithCache(userId: number) {
  const cached = useLocalStorage(`user-${userId}`, null);   // 调其他 hook
  const [user, setUser] = useState(cached[0]);
  // ...
  return user;
}

四、自定义 Hook 与组件的区别 ​

维度自定义 Hook组件
命名use 开头大写开头
返回任意值JSX
渲染 UI❌✅
调用其他 Hook✅✅
复用逻辑✅⚠️ 通过 children/Props

⚠️ 坑 1:use 开头的函数必须调用其他 Hook,否则就是普通工具函数,应改名。

五、TypeScript 泛型 Hook ​

tsx
function useArray<T>(initial: T[] = []) {
  const [list, setList] = useState<T[]>(initial);

  return {
    list,
    add: (item: T) => setList(prev => [...prev, item]),
    remove: (idx: number) => setList(prev => prev.filter((_, i) => i !== idx)),
    clear: () => setList([]),
  };
}

// 自动推断类型
const { list, add } = useArray<number>([1, 2, 3]);

六、性能:稳定函数引用 ​

tsx
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
  const ref = useRef(fn);
  ref.current = fn;

  return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T;
}

// 始终是同一个引用,但调用的是最新 fn

七、自定义 Hook 测试 ​

tsx
import { renderHook, act } from '@testing-library/react';

test('useToggle', () => {
  const { result } = renderHook(() => useToggle(false));
  expect(result.current[0]).toBe(false);

  act(() => result.current[1]());
  expect(result.current[0]).toBe(true);
});

⚠️ 坑 2:测试 hook 用 @testing-library/react 的 renderHook,不要渲染到组件里绕一圈。

八、闭包陷阱 ​

tsx
function Bad() {
  const [count, setCount] = useState(0);

  // ⚠️ 闭包陷阱:logCount 永远打印 0
  const logCount = () => console.log(count);

  useEffect(() => {
    const id = setInterval(logCount, 1000);
    return () => clearInterval(id);
  }, []);    // 没依赖 count
}

修复方法:

tsx
// 方法 1:函数式 setState
useEffect(() => {
  const id = setInterval(() => setCount(c => { console.log(c); return c + 1; }), 1000);
}, []);

// 方法 2:ref 存最新值
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
  const id = setInterval(() => console.log(countRef.current), 1000);
}, []);

⚠️ 坑 3:hook 里通过闭包捕获的 state 是创建时的快照,依赖没写对就会读到旧值。

九、本章小结 ​

要点关键
命名约定use 开头
复用逻辑(不是 UI)
返回任意值(不是 JSX)
调用可以嵌套调用其他 hook
闭包陷阱ref 存最新值 / 函数式 setState
测试renderHook + act
性能useCallback 稳定引用

动手练习 ​

  1. useToggle:布尔值切换
  2. useLocalStorage:任意类型持久化
  3. useDebounce:值防抖
  4. useWindowSize:监听窗口尺寸变化
  5. 封装复杂 hook:把 Todo 列表的所有操作封到 useTodos hook

下一章:第 16 章:路由 →

本站基于 VitePress 构建 · 由 StackHub 团队维护