Files
cs-note/hhs/REACT/2. Hooks 篇/07-自定义 Hooks.md
T

464 lines
13 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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 中的依赖优化实践