Files
cs-note/hhs/REACT/2. Hooks 篇/07-自定义 Hooks.md
T
2026-05-24 11:42:38 +08:00

464 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<T>(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<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 — 防抖
```tsx
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 — 持久化状态
```tsx
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 — 视口检测
```tsx
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 — 媒体查询
```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<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 — 布尔状态简化器
```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 <button onClick={toggle}>{on ? "ON" : "OFF"}</button>;
}
```
## 常见陷阱与避坑
> [!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);
<button onClick={() => setOpen(!open)}>...</button>
```
### 陷阱 5:副作用竞态(Race Condition)
```tsx
// ❌ 快速切换搜索词时,旧请求可能晚于新请求返回
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 的对比
```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<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 应依赖抽象(接口),而非具体实现
## 抽象层级参考
```tsx
// 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 中的依赖优化实践