464 lines
13 KiB
Markdown
464 lines
13 KiB
Markdown
|
|
---
|
|||
|
|
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 中的依赖优化实践
|