This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/REACT/2. Hooks 篇/07-自定义 Hooks.md
T
2026-04-29 22:19:51 +08:00

13 KiB
Raw Blame History

tags, create time
tags create time
React
Custom Hooks
Frontend
2026-04-29 22:30

自定义 Hooks

概述

自定义 Hook 是 React 中最强大的逻辑复用机制。它以函数形式封装可复用的副作用逻辑和状态,通过 Hook 组合实现"代码即组件"的哲学。理解如何设计良好的自定义 Hook,是从中级迈向高级 React 开发者的分水岭。

命名规范与设计原则

[!question] 思考 为什么普通函数不能直接持有 React state?Hook 和普通函数的根本区别是什么? (提示:从 React 的内部执行上下文和 Fiber 架构角度理解)

必须以 use 开头

// ✅ 正确
function useFetch(url: string) { ... }
function useLocalStorage<T>(key: string) { ... }
function useWindowSize() { ... }

// ❌ 错误:不遵循 use 前缀约定,React 无法识别为 Hook
function fetchWithRetry(url: string) { ... }

[!warning] 前置依赖 所有自定义 Hook 示例默认已导入:

import { useState, useEffect, useCallback, useMemo, useRef, RefObject } from "react";

规则与常规函数不同

graph LR
    A["自定义 Hook"] --> B["可以在任何条件/循环内调用"]
    A --> C["可以嵌套调用(Hook 中再调其他 Hook)"]
    A --> D["⚠️ 但必须在组件顶层或 Hook 中调用"]
    
    D1["不能在回调/普通函数内调用"] --> D
    
    E["普通函数"] --> F["无执行顺序约束"]
    F --> G["不能直接持有 React state/effect"]
    
    style A fill:#61DAFB,color:#000

常见 Hook 模式

1. useFetch — 数据获取

interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
  refetch: () => void;
}

function useFetch<T>(url: string, options?: RequestInit): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  // ✅ 使用 useRef 跟踪组件挂载状态,避免卸载后 setState
  const mountedRef = useRef(true);

  const execute = useCallback(async () => {
    if (!mountedRef.current) return;
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(url, options ?? {});
      if (!res.ok) throw new Error(res.statusText);
      const json: T = await res.json();
      if (mountedRef.current) setData(json);
    } catch (err) {
      if (mountedRef.current) setError(err as Error);
    } finally {
      if (mountedRef.current) setLoading(false);
    }
  }, [url, JSON.stringify(options)]);

  useEffect(() => {
    execute();
    return () => { mountedRef.current = false; };
  }, [execute]);

  return { data, loading, error, refetch: execute };
}

2. useDebounce — 防抖

interface DebounceResult<T> {
  debouncedValue: T;
  cancel: () => void;
}

function useDebounce<T>(value: T, delay: number): DebounceResult<T> {
  const [debouncedValue, setDebouncedValue] = useState(value);
  const timerRef = useRef<ReturnType<typeof setTimeout>>();

  useEffect(() => {
    timerRef.current = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timerRef.current);
  }, [value, delay]);

  // ✅ 提供 cancel,外部可在需要时主动取消待触发的回调
  const cancel = useCallback(() => clearTimeout(timerRef.current), []);

  return { debouncedValue, cancel };
}

// 使用
function SearchInput() {
  const [query, setQuery] = useState("");
  const { debouncedValue } = useDebounce(query, 300);
  
  // 用 debouncedValue 发起 API 请求 —— 只在用户停止输入 300ms 后触发
  useEffect(() => { fetchData(debouncedValue); }, [debouncedValue]);
  
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

3. useLocalStorage — 持久化状态

function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    // ✅ SSR 安全:检查 window 是否存在
    if (typeof window === "undefined") return initialValue;
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T | ((prev: T) => T)) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    // ✅ SSR 安全
    if (typeof window !== "undefined") {
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    }
  };

  useEffect(() => {
    const handleStorage = (e: StorageEvent) => {
      if (e.key === key && e.newValue !== null) {
        setStoredValue(JSON.parse(e.newValue));
      }
    };
    // ✅ SSR 安全
    if (typeof window !== "undefined") {
      window.addEventListener("storage", handleStorage);
      return () => window.removeEventListener("storage", handleStorage);
    }
  }, [key]);

  return [storedValue, setValue];
}

4. useIntersectionObserver — 视口检测

function useIntersectionObserver(
  options?: IntersectionObserverInit
): [RefObject<HTMLDivElement | null>, boolean] {
  const [isVisible, setIsVisible] = useState(false);
  const ref = useRef<HTMLDivElement>(null);

  // ✅ 用 useMemo 缓存 options,避免每次渲染新建对象导致 observer 重建
  const memoizedOptions = useMemo(() => ({
    root: null,
    rootMargin: "0px",
    threshold: 0.1,
    ...options,
  }), [JSON.stringify(options)]);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsVisible(entry.isIntersecting);
    }, memoizedOptions);

    observer.observe(el);
    return () => observer.disconnect();
  }, [memoizedOptions]);

  return [ref, isVisible];
}

5. useMediaQuery — 媒体查询

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(false);

  useEffect(() => {
    const media = window.matchMedia(query);
    setMatches(media.matches);

    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    media.addEventListener("change", handler);
    return () => media.removeEventListener("change", handler);
  }, [query]);

  return matches;
}

// 使用
const isMobile = useMediaQuery("(max-width: 768px)");

6. usePrevious — 记录上一次值

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

  // ✅ useEffect 在渲染完成后才执行,恰好获取"上一轮"的值
  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

// 使用:判断值是否发生变化
function Component({ count }) {
  const prevCount = usePrevious(count);
  return <p>{count === prevCount ? "不变" : "已变化!"}</p>;
}

7. useBoolean — 布尔状态简化器

function useBoolean(initialValue = false) {
  const [value, setValue] = useState(initialValue);

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

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

// 使用:替代手动写 { checked, setChecked }
function ToggleButton() {
  const { value: on, toggle } = useBoolean();
  return <button onClick={toggle}>{on ? "ON" : "OFF"}</button>;
}

常见陷阱与避坑

[!danger] Hook 设计的 5 个经典陷阱 以下是在生产环境中高频踩中的坑,务必警惕。

陷阱 1:闭包陷阱(Stale Closure)

// ❌ 问题:count 在 useCallback 创建时被捕获,永远是初始值
function useCounter() {
  const [count, setCount] = useState(0);
  const double = useCallback(() => {
    console.log(count); // 始终是 0!
    setCount(count * 2);
  }, []); // 空依赖数组 → 捕获了初始状态
}

// ✅ 解法 1:使用函数式 setState
const double = useCallback(() => {
  setCount(c => c * 2); // 读取最新值
}, []);

// ✅ 解法 2:将 count 加入依赖
// const double = useCallback(() => {...}, [count]);

陷阱 2:无限循环

// ❌ 问题:每次渲染都创建新对象,导致 useEffect 无限触发
function BadComponent({ data }) {
  const [state, setState] = useState([]);
  
  useEffect(() => {
    setState([{ items: data }]); // 新数组 = 新引用
  }, [{ items: data }]); // ← 每次都是新对象,永远不等价
}

// ✅ 解法:正确声明依赖项
useEffect(() => {
  setState(prev => (prev[0]?.items === data ? prev : [{ items: data }]));
}, [data]);

陷阱 3:异步操作缺少清理

// ❌ 组件卸载后仍执行 setState → React 警告
useEffect(() => {
  fetch("/api").then(res => setData(res.data)); // 卸载后回调仍会执行
}, []);

// ✅ 使用 AbortController 取消请求
useEffect(() => {
  const controller = new AbortController();
  fetch("/api", { signal: controller.signal })
    .then(res => res.json())
    .then(setData)
    .catch(err => { if (err.name !== "AbortError") throw err; });
  
  return () => controller.abort();
}, []);

陷阱 4:过度封装

// ❌ 为简单逻辑造 Hook,反而增加复杂度
function useToggle(initial = false) {
  const [val, setVal] = useState(initial);
  return [val, () => setVal(v => !v), () => setVal(true), () => setVal(false)];
  // 返回值太长,调用方难以理解每个位置的含义
}

// ✅ 直接内联或提取有意义的语义化 Hook
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(!open)}>...</button>

陷阱 5:副作用竞态(Race Condition)

// ❌ 快速切换搜索词时,旧请求可能晚于新请求返回
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  
  useEffect(() => {
    searchAPI(query).then(setResults);
  }, [query]);

// ✅ 用 ref + 版本号追踪"当前请求"
function useDebouncedSearch<T>(query: string, delay = 300): T | null {
  const [result, setResult] = useState<T | null>(null);
  const requestIdRef = useRef(0);

  useEffect(() => {
    const timer = setTimeout(async () => {
      const thisId = ++requestIdRef.current;
      const data = await searchAPI(query);
      if (thisId === requestIdRef.current) {
        setResult(data);
      }
    }, delay);
    return () => clearTimeout(timer);
  }, [query, delay]);

  return result;
}

[!tip] 自检清单

  • Hook 的依赖数组是否包含了所有引用的外部变量?
  • 异步操作中是否有清理机制防止"僵尸回调"?
  • Hook 是否在条件语句中嵌套了?(违反 Rules of Hooks)
  • Hook 的返回值类型是否清晰?优先用 interface/type 标注

组合模式与 HOC 的对比

flowchart LR
    subgraph Composition["Hook 组合(推荐)"]
        C1["useBoolean"] --> C3["useModal"]
        C2["useClickOutside"] --> C3
        C3 --> C4["useForm"]
        C4 --> C5["DashboardPage"]
        style C3 fill:#61DAFB,color:#000
        style C5 fill:#4FC08D,color:#fff
    end
    
    subgraph HOC["HOC 包装(不推荐)"]
        H1[Component] --> H2[HOC1]
        H2 --> H3[HOC2]
        H3 --> H4[HOC3]
        style H2 fill:#F5A87D,color:#000
        style H3 fill:#F5A87D,color:#000
        style H4 fill:#F5A87D,color:#000
    end

实战:Hook 层层组合

// Layer 1: 基础 Hook
function useBoolean(initial = false) { ... }
function useClickOutside(ref, handler) { ... }

// Layer 2: 组合基础 Hook → 业务 Hook
function useModal() {
  const { value: isOpen, toggle, setFalse: close } = useBoolean();
  const overlayRef = useRef<HTMLDivElement>(null);
  
  // Hook 中可以安全调用其他 Hook
  useClickOutside(overlayRef, close);
  
  return { isOpen, open: toggle, close, overlayRef };
}

// Layer 3: 业务页面
function SettingsPanel() {
  const { isOpen, open, close, overlayRef } = useModal();
  return (
    <div ref={overlayRef}>
      <button onClick={open}>{isOpen ? "关闭设置" : "打开设置"}</button>
      {isOpen && <SettingsForm onClose={close} />}
    </div>
  );
}

[!tip] Hook 设计的 S.O.L.I.D 原则

  • Single Responsibility:一个 Hook 只做一件事(如 useFetch 只负责 fetch)
  • Open/Closed:新需求加新 Hook,而非修改已有 Hook
  • Interface Segregation:返回值尽量精确,不过度暴露内部细节
  • Dependency Inversion:Hook 应依赖抽象(接口),而非具体实现

抽象层级参考

// Level 1: 基础 Hook(操作层面)
function useClickOutside(ref: RefObject<HTMLElement>, handler: () => void) {}
function useEventListener(target: any, event: string, fn: Function) {}

// Level 2: 业务 Hook(场景层面)
function useModal() { return { isOpen, open, close, overlayRef: useClickOutside(...) } }
function useForm(initialValues: FormValues) { return { values, errors, submit, reset } }

// Level 3: 领域 Hook(领域层面)
function usePermission(role: Role) {}       // 权限检查
function usePagination(state: PaginationState) {}  // 分页

关联笔记

  • 05-核心 Hooks — useState、useEffect 等基础 Hook,是理解自定义 Hook 的前置知识
  • 06-性能优化 Hooks — useMemo、useCallback 在自定义 Hook 中的依赖优化实践