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 中的依赖优化实践
|