--- tags: [React, Custom Hooks, Frontend] create time: 2026-04-29 22:30 --- # 自定义 Hooks ## 概述 自定义 Hook 是 React 中最强大的逻辑复用机制。它以函数形式封装可复用的副作用逻辑和状态,通过 Hook 组合实现"代码即组件"的哲学。理解如何设计良好的自定义 Hook,是从中级迈向高级 React 开发者的分水岭。 ## 命名规范与设计原则 > [!question] 思考 > 为什么普通函数不能直接持有 React state?Hook 和普通函数的根本区别是什么? > (提示:从 React 的内部执行上下文和 Fiber 架构角度理解) ### 必须以 `use` 开头 ```tsx // ✅ 正确 function useFetch(url: string) { ... } function useLocalStorage(key: string) { ... } function useWindowSize() { ... } // ❌ 错误:不遵循 use 前缀约定,React 无法识别为 Hook function fetchWithRetry(url: string) { ... } ``` > [!warning] 前置依赖 > 所有自定义 Hook 示例默认已导入: > ```tsx > import { useState, useEffect, useCallback, useMemo, useRef, RefObject } from "react"; > ``` ### 规则与常规函数不同 ```mermaid 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 — 数据获取 ```tsx interface UseFetchResult { data: T | null; loading: boolean; error: Error | null; refetch: () => void; } function useFetch(url: string, options?: RequestInit): UseFetchResult { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 — 防抖 ```tsx interface DebounceResult { debouncedValue: T; cancel: () => void; } function useDebounce(value: T, delay: number): DebounceResult { const [debouncedValue, setDebouncedValue] = useState(value); const timerRef = useRef>(); 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 setQuery(e.target.value)} />; } ``` ### 3. useLocalStorage — 持久化状态 ```tsx function useLocalStorage(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] { const [storedValue, setStoredValue] = useState(() => { // ✅ 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 — 视口检测 ```tsx function useIntersectionObserver( options?: IntersectionObserverInit ): [RefObject, boolean] { const [isVisible, setIsVisible] = useState(false); const ref = useRef(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 — 媒体查询 ```tsx 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 — 记录上一次值 ```tsx function usePrevious(value: T): T | undefined { const ref = useRef(); // ✅ useEffect 在渲染完成后才执行,恰好获取"上一轮"的值 useEffect(() => { ref.current = value; }, [value]); return ref.current; } // 使用:判断值是否发生变化 function Component({ count }) { const prevCount = usePrevious(count); return

{count === prevCount ? "不变" : "已变化!"}

; } ``` ### 7. useBoolean — 布尔状态简化器 ```tsx 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 ; } ``` ## 常见陷阱与避坑 > [!danger] Hook 设计的 5 个经典陷阱 > 以下是在生产环境中高频踩中的坑,务必警惕。 ### 陷阱 1:闭包陷阱(Stale Closure) ```tsx // ❌ 问题: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:无限循环 ```tsx // ❌ 问题:每次渲染都创建新对象,导致 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:异步操作缺少清理 ```tsx // ❌ 组件卸载后仍执行 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:过度封装 ```tsx // ❌ 为简单逻辑造 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); ``` ### 陷阱 5:副作用竞态(Race Condition) ```tsx // ❌ 快速切换搜索词时,旧请求可能晚于新请求返回 function SearchResults({ query }) { const [results, setResults] = useState([]); useEffect(() => { searchAPI(query).then(setResults); }, [query]); // ✅ 用 ref + 版本号追踪"当前请求" function useDebouncedSearch(query: string, delay = 300): T | null { const [result, setResult] = useState(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 的对比 ```mermaid 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 层层组合 ```tsx // 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(null); // Hook 中可以安全调用其他 Hook useClickOutside(overlayRef, close); return { isOpen, open: toggle, close, overlayRef }; } // Layer 3: 业务页面 function SettingsPanel() { const { isOpen, open, close, overlayRef } = useModal(); return (
{isOpen && }
); } ``` > [!tip] Hook 设计的 S.O.L.I.D 原则 > - **Single Responsibility**:一个 Hook 只做一件事(如 useFetch 只负责 fetch) > - **Open/Closed**:新需求加新 Hook,而非修改已有 Hook > - **Interface Segregation**:返回值尽量精确,不过度暴露内部细节 > - **Dependency Inversion**:Hook 应依赖抽象(接口),而非具体实现 ## 抽象层级参考 ```tsx // Level 1: 基础 Hook(操作层面) function useClickOutside(ref: RefObject, 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 中的依赖优化实践