React 自定义 Hooks
大约 10 分钟约 3098 字
React 自定义 Hooks
简介
React 自定义 Hooks 将组件逻辑提取为可复用函数,以 use 开头命名。通过组合 useState、useEffect、useRef、useCallback 等基础 Hook,封装通用逻辑如数据请求、表单管理、WebSocket 通信、本地存储和键盘事件等。自定义 Hooks 是 React 逻辑复用的核心机制,替代了高阶组件(HOC)和 render props 模式。理解 Hook 规则、闭包陷阱、依赖数组和 ref 的使用,是编写高质量自定义 Hook 的关键。
特点
实现
数据请求 Hook
// useFetch — 通用数据请求
function useFetch<T>(
url: string,
options?: {
method?: string;
body?: any;
headers?: Record<string, string>;
immediate?: boolean;
initialData?: T;
}
) {
const [data, setData] = useState<T | null>(options?.initialData ?? null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(options?.immediate !== false);
const abortRef = useRef<AbortController | null>(null);
const execute = useCallback(async (overrideOptions?: Partial<typeof options>) => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const res = await fetch(url, {
method: overrideOptions?.method || options?.method || 'GET',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: overrideOptions?.body || options?.body
? JSON.stringify(overrideOptions?.body || options?.body)
: undefined,
signal: abortRef.current.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const json = await res.json();
setData(json);
return json;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
const err = e as Error;
setError(err);
throw err;
} finally {
setLoading(false);
}
}, [url, options?.method, options?.body, options?.headers]);
useEffect(() => {
if (options?.immediate !== false) {
execute();
}
return () => abortRef.current?.abort();
}, [execute, options?.immediate]);
return { data, error, loading, refresh: execute, setData };
}
// 使用
const { data: users, loading, refresh } = useFetch<User[]>('/api/users');
// 带泛型的 POST 请求
const { execute: createUser } = useFetch<User>('/api/users', { immediate: false });
await createUser({ method: 'POST', body: { name: '张三' } });表单管理 Hook
// useForm — 表单状态管理
function useForm<T extends Record<string, any>>(initialValues: T) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const isDirty = useRef(false);
const setValue = useCallback((name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }));
setTouched(prev => ({ ...prev, [name]: true }));
setErrors(prev => ({ ...prev, [name]: undefined }));
isDirty.current = true;
}, []);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const target = e.target as HTMLInputElement;
setValue(name as keyof T, type === 'checkbox' ? target.checked : value);
}, [setValue]);
const setFieldError = useCallback((name: keyof T, message: string) => {
setErrors(prev => ({ ...prev, [name]: message }));
}, []);
const validate = useCallback((
rules: Partial<Record<keyof T, (v: any) => string | undefined>>
): boolean => {
const newErrors: typeof errors = {};
let isValid = true;
(Object.keys(rules) as (keyof T)[]).forEach(key => {
const rule = rules[key];
if (rule) {
const error = rule(values[key]);
if (error) {
newErrors[key] = error;
isValid = false;
}
}
});
setErrors(newErrors);
return isValid;
}, [values]);
const handleSubmit = useCallback(async (
onSubmit: (values: T) => Promise<void> | void,
rules?: Partial<Record<keyof T, (v: any) => string | undefined>>
) => {
if (rules && !validate(rules)) return;
setIsSubmitting(true);
try {
await onSubmit(values);
} finally {
setIsSubmitting(false);
}
}, [values, validate]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
isDirty.current = false;
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
isDirty: isDirty.current,
setValue,
handleChange,
setFieldError,
validate,
handleSubmit,
reset,
};
}
// 使用
const form = useForm({ name: '', email: '', age: '' });
<form onSubmit={e => {
e.preventDefault();
form.handleSubmit(async (values) => {
await api.createUser(values);
}, {
name: (v) => !v ? '姓名不能为空' : undefined,
email: (v) => !v.includes('@') ? '邮箱格式不正确' : undefined,
});
}}>
<input name="name" value={form.values.name} onChange={form.handleChange} />
{form.errors.name && <span className="error">{form.errors.name}</span>}
</form>事件与浏览器 API Hook
// useWindowSize — 窗口尺寸
function useWindowSize() {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
useEffect(() => {
const handler = () => setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, []);
return size;
}
// useLocalStorage — 本地存储
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const newValue = value instanceof Function ? value(prev) : value;
localStorage.setItem(key, JSON.stringify(newValue));
return newValue;
});
}, [key]);
return [storedValue, setValue];
}
// 使用
const [theme, setTheme] = useLocalStorage('theme', 'light');
const [user, setUser] = useLocalStorage<User>('user', null);
// useDebounce — 防抖
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 使用
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
useEffect(() => { fetchResults(debouncedSearch); }, [debouncedSearch]);
// useClickOutside — 点击外部
function useClickOutside(ref: React.RefObject<HTMLElement>, handler: () => void) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) return;
handler();
};
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}
// useKeyboardShortcut — 键盘快捷键
function useKeyPress(targetKey: string, callback: () => void, modifiers?: { ctrl?: boolean; shift?: boolean; alt?: boolean }) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === targetKey
&& (!modifiers?.ctrl || e.ctrlKey || e.metaKey)
&& (!modifiers?.shift || e.shiftKey)
&& (!modifiers?.alt || e.altKey)) {
e.preventDefault();
callback();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [targetKey, callback, modifiers]);
}
// 使用
useKeyPress('s', () => saveDocument(), { ctrl: true });
useKeyPress('Escape', () => closeModal());
// useIntersectionObserver — 元素可见性
function useIntersectionObserver(
options?: IntersectionObserverInit
): [React.RefObject<HTMLElement>, boolean] {
const ref = useRef<HTMLElement>(null!);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setIsVisible(entry.isIntersecting);
}, options);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [options]);
return [ref, isVisible];
}
// 使用 — 无限滚动
const [loaderRef, isVisible] = useIntersectionObserver({ threshold: 0 });
useEffect(() => {
if (isVisible) loadMore();
}, [isVisible]);WebSocket Hook
function useWebSocket(url: string, options?: {
onOpen?: () => void;
onMessage?: (data: any) => void;
onClose?: () => void;
onError?: (error: Event) => void;
reconnect?: boolean;
reconnectInterval?: number;
}) {
const [data, setData] = useState<any>(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout>>();
const connect = useCallback(() => {
setStatus('connecting');
const ws = new WebSocket(url);
ws.onopen = () => {
setStatus('open');
options?.onOpen?.();
};
ws.onmessage = (event) => {
const parsed = JSON.parse(event.data);
setData(parsed);
options?.onMessage?.(parsed);
};
ws.onclose = () => {
setStatus('closed');
options?.onClose?.();
if (options?.reconnect !== false) {
reconnectTimerRef.current = setTimeout(connect, options?.reconnectInterval || 3000);
}
};
ws.onerror = (error) => {
options?.onError?.(error);
};
wsRef.current = ws;
}, [url]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((msg: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(msg));
}
}, []);
return { data, status, send, connect };
}动画与定时器 Hook
// useInterval — 可暂停的 setInterval
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// 使用 — 倒计时
function Countdown({ initialSeconds }: { initialSeconds: number }) {
const [seconds, setSeconds] = useState(initialSeconds);
const [running, setRunning] = useState(true);
useInterval(() => {
setSeconds(s => {
if (s <= 1) { setRunning(false); return 0; }
return s - 1;
});
}, running ? 1000 : null);
return (
<div>
<span>{seconds}s</span>
<button onClick={() => setRunning(!running)}>
{running ? '暂停' : '继续'}
</button>
</div>
);
}
// useTimeout — 可取消的 setTimeout
function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}
// useAnimationFrame — requestAnimationFrame 循环
function useAnimationFrame(callback: (deltaTime: number) => void, running: boolean = true) {
const requestRef = useRef<number>();
const previousTimeRef = useRef<number>();
useEffect(() => {
if (!running) {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
return;
}
const animate = (time: number) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
callback(deltaTime);
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current) cancelAnimationFrame(requestRef.current);
};
}, [callback, running]);
}
// 使用 — 进度条动画
function AnimatedProgress({ target }: { target: number }) {
const [value, setValue] = useState(0);
useAnimationFrame(() => {
setValue(prev => {
const diff = target - prev;
if (Math.abs(diff) < 0.5) return target;
return prev + diff * 0.1;
});
}, value !== target);
return <progress value={value} max={100} />;
}状态机与Reducer Hook
// useToggle — 布尔切换
function useToggle(initialValue: boolean = false): [boolean, { on: () => void; off: () => void; toggle: () => void }] {
const [value, setValue] = useState(initialValue);
const handlers = useMemo(() => ({
on: () => setValue(true),
off: () => setValue(false),
toggle: () => setValue(v => !v),
}), []);
return [value, handlers];
}
// 使用
const [modalOpen, { on: openModal, off: closeModal, toggle: toggleModal }] = useToggle();
// useReducer 封装 — 状态机
interface StateMachineConfig<S extends string, E extends string> {
initial: S;
transitions: Record<S, Partial<Record<E, S>>>;
}
function useStateMachine<S extends string, E extends string>(
config: StateMachineConfig<S, E>
): [S, (event: E) => void, boolean] {
const [state, setState] = useState(config.initial);
const transitionHistory = useRef<S[]>([config.initial]);
const send = useCallback((event: E) => {
setState(prev => {
const next = config.transitions[prev]?.[event];
if (next) {
transitionHistory.current.push(next);
return next;
}
console.warn(`Invalid transition: state=${prev}, event=${event}`);
return prev;
});
}, [config.transitions]);
const canSend = useCallback((event: E) => {
return config.transitions[state]?.[event] !== undefined;
}, [state, config.transitions]);
return [state, send, canSend];
}
// 使用 — 订单状态机
type OrderState = 'idle' | 'loading' | 'success' | 'error';
type OrderEvent = 'FETCH' | 'SUCCESS' | 'FAIL' | 'RESET';
const [orderState, sendOrder, canSendOrder] = useStateMachine<OrderState, OrderEvent>({
initial: 'idle',
transitions: {
idle: { FETCH: 'loading' },
loading: { SUCCESS: 'success', FAIL: 'error' },
error: { FETCH: 'loading', RESET: 'idle' },
success: { RESET: 'idle' },
},
});副作用与异步 Hook
// useAsync — 异步操作管理
interface AsyncState<T> {
data: T | null;
error: Error | null;
loading: boolean;
}
function useAsync<T>(asyncFunction: () => Promise<T>, immediate: boolean = true) {
const [state, setState] = useState<AsyncState<T>>({
data: null,
error: null,
loading: immediate,
});
const mountedRef = useRef(true);
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const data = await asyncFunction();
if (mountedRef.current) {
setState({ data, loading: false, error: null });
}
return data;
} catch (error) {
if (mountedRef.current) {
setState({ data: null, loading: false, error: error as Error });
}
throw error;
}
}, [asyncFunction]);
useEffect(() => {
mountedRef.current = true;
if (immediate) execute();
return () => { mountedRef.current = false; };
}, [execute, immediate]);
return { ...state, execute };
}
// 使用
const { data: products, loading, error, execute: refetch } = useAsync(
() => api.getProducts(),
true
);
// usePromise — 带缓存的异步请求
function usePromise<T>(factory: () => Promise<T>, deps: any[]) {
const [state, setState] = useState<{ data: T | null; error: Error | null; loading: boolean }>({
data: null, error: null, loading: true,
});
const cache = useRef<Map<string, T>>(new Map());
useEffect(() => {
const cacheKey = JSON.stringify(deps);
if (cache.current.has(cacheKey)) {
setState({ data: cache.current.get(cacheKey)!, error: null, loading: false });
return;
}
let cancelled = false;
setState(prev => ({ ...prev, loading: true, error: null }));
factory().then(data => {
if (!cancelled) {
cache.current.set(cacheKey, data);
setState({ data, error: null, loading: false });
}
}).catch(error => {
if (!cancelled) {
setState({ data: null, error, loading: false });
}
});
return () => { cancelled = true; };
}, deps);
return state;
}引用与 DOM 操作 Hook
// usePrevious — 获取上一次渲染的值
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
// 使用 — 比较新旧值
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
useEffect(() => {
if (prevCount !== undefined && count > prevCount) {
console.log('计数增加了');
}
}, [count, prevCount]);
// useMeasure — 测量元素尺寸
function useMeasure<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [rect, setRect] = useState<DOMRectReadOnly>({
x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0,
} as DOMRectReadOnly);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(([entry]) => {
setRect(entry.contentRect);
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return { ref, rect };
}
// 使用
const { ref: containerRef, rect } = useMeasure<HTMLDivElement>();
console.log(`容器宽度: ${rect.width}, 高度: ${rect.height}`);
// useScrollLock — 锁定页面滚动
function useScrollLock(lock: boolean = true) {
const scrollRef = useRef(false);
useEffect(() => {
if (lock && !scrollRef.current) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
scrollRef.current = true;
} else if (!lock && scrollRef.current) {
document.body.style.overflow = '';
document.body.style.paddingRight = '';
scrollRef.current = false;
}
return () => {
if (scrollRef.current) {
document.body.style.overflow = '';
document.body.style.paddingRight = '';
scrollRef.current = false;
}
};
}, [lock]);
}地理位置与网络状态 Hook
// useGeolocation — 获取地理位置
function useGeolocation(options?: PositionOptions) {
const [position, setPosition] = useState<GeolocationPosition | null>(null);
const [error, setError] = useState<GeolocationPositionError | null>(null);
useEffect(() => {
if (!navigator.geolocation) {
setError({ code: 0, message: 'Geolocation not supported' } as GeolocationPositionError);
return;
}
const watchId = navigator.geolocation.watchPosition(
setPosition,
setError,
{ enableHighAccuracy: true, timeout: 10000, ...options }
);
return () => navigator.geolocation.clearWatch(watchId);
}, [options]);
return {
latitude: position?.coords.latitude ?? null,
longitude: position?.coords.longitude ?? null,
accuracy: position?.coords.accuracy ?? null,
error,
};
}
// useOnlineStatus — 网络状态
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const goOnline = () => setIsOnline(true);
const goOffline = () => setIsOnline(false);
window.addEventListener('online', goOnline);
window.addEventListener('offline', goOffline);
return () => {
window.removeEventListener('online', goOnline);
window.removeEventListener('offline', goOffline);
};
}, []);
return isOnline;
}
// 使用
const isOnline = useOnlineStatus();
if (!isOnline) {
showToast('网络连接已断开,部分功能不可用');
}
// useMediaQuery — 媒体查询
function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
// 使用
const isMobile = useMediaQuery('(max-width: 768px)');
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');
const isLandscape = useMediaQuery('(orientation: landscape)');Hook 组合模式
// 组合多个 Hook — 用户搜索功能
function useUserSearch() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
const { data: users, loading, error } = useFetch<User[]>(
`/api/users?q=${encodeURIComponent(debouncedSearch)}`,
{ immediate: false }
);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
useEffect(() => {
if (debouncedSearch) {
// useFetch 的 execute 会在 URL 变化时自动触发
}
}, [debouncedSearch]);
return {
searchTerm,
setSearchTerm,
users,
loading,
error,
selectedUser,
setSelectedUser,
};
}
// 条件性使用 Hook(通过包装组件解决)
// Hook 不能在条件语句中调用,但可以通过 props 控制内部行为
function useConditionalFetch<T>(url: string | null) {
const effectiveUrl = url ?? '';
const result = useFetch<T>(effectiveUrl, { immediate: !!url });
return url ? result : { data: null, error: null, loading: false, refresh: () => {}, setData: () => {} };
}Hook 测试最佳实践
import { renderHook, act } from '@testing-library/react';
// 测试 useToggle
describe('useToggle', () => {
it('初始值为 false', () => {
const { result } = renderHook(() => useToggle());
expect(result.current[0]).toBe(false);
});
it('toggle 切换值', () => {
const { result } = renderHook(() => useToggle());
act(() => result.current[1].toggle());
expect(result.current[0]).toBe(true);
act(() => result.current[1].toggle());
expect(result.current[0]).toBe(false);
});
it('on/off 显式设置', () => {
const { result } = renderHook(() => useToggle());
act(() => result.current[1].on());
expect(result.current[0]).toBe(true);
act(() => result.current[1].on());
expect(result.current[0]).toBe(true);
act(() => result.current[1].off());
expect(result.current[0]).toBe(false);
});
});
// 测试 useDebounce
describe('useDebounce', () => {
beforeEach(() => jest.useFakeTimers());
it('延迟更新值', () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'hello', delay: 300 } }
);
expect(result.current).toBe('hello');
rerender({ value: 'world', delay: 300 });
expect(result.current).toBe('hello');
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe('world');
});
});
// 测试 useFetch(mock fetch)
describe('useFetch', () => {
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 1 }) } as Response)
);
});
it('自动请求并返回数据', async () => {
const { result } = renderHook(() => useFetch('/api/test'));
expect(result.current.loading).toBe(true);
await act(() => new Promise(resolve => setTimeout(resolve, 0)));
expect(result.current.data).toEqual({ id: 1 });
expect(result.current.loading).toBe(false);
});
});优点
缺点
总结
自定义 Hooks 是 React 逻辑复用的核心机制。以 use 前缀命名,内部组合 useState/useEffect 等基础 Hook。常见模式包括数据请求(useFetch)、表单管理(useForm)和事件处理(useKeyPress)。useCallback/useMemo 避免闭包陷阱。useRef 持有跨渲染的稳定引用。
关键知识点
- Hook 命名必须以 use 开头,React 通过此约定识别 Hook。
- Hook 内部可以使用其他 Hook,形成组合链。
- useEffect 的清理函数处理副作用生命周期。
- useRef 持有跨渲染的稳定引用,不触发重渲染。
项目落地视角
- 建立项目级 Hooks 库(hooks/ 目录)。
- 为每个 Hook 编写 Storybook 文档。
- 复杂 Hook 使用 useReducer 替代多个 useState。
常见误区
- 在条件语句中调用 Hook 违反 Hook 规则。
- useEffect 依赖数组遗漏导致 stale closure。
- 在 Hook 中直接返回 JSX(应返回数据和回调)。
进阶路线
- 学习 ahooks、react-use 等社区 Hook 库。
- 研究 React 19 的 use() Hook 和 useOptimistic。
- 了解 Hook 的编译时优化(React Compiler)。
适用场景
- 跨组件复用的业务逻辑。
- 数据请求和缓存策略。
- DOM 事件和浏览器 API 封装。
落地建议
- 建立 hooks/ 目录统一管理。
- 每个 Hook 一个文件,导出类型和实现。
- 为 Hook 编写单元测试和文档。
排错清单
- 检查 useEffect 依赖数组是否完整。
- 确认 useCallback/useMemo 的依赖是否正确。
- 用 React DevTools 检查 Hook 状态。
复盘问题
- 你的项目中哪些重复逻辑可以提取为自定义 Hook?
- Hook 的闭包陷阱在什么场景下最容易出现?
- 如何为 Hook 编写有效的单元测试?
