第 15 章:自定义 Hook
学习目标
- 理解自定义 Hook 是逻辑复用机制
- 掌握 5 个常见自定义 Hook 写法
- 学会封装可测试的 Hook
- 避开自定义 Hook 的 3 个常见坑
一、什么是自定义 Hook?
以 use 开头的普通函数,内部调用其他 Hook,封装可复用逻辑。
tsx
function useToggle(initial = false) {
const [v, setV] = useState(initial);
const toggle = useCallback(() => setV(x => !x), []);
return [v, toggle] as const;
}
// 使用
function Comp() {
const [open, toggleOpen] = useToggle(false);
return <button onClick={toggleOpen}>{open ? '关' : '开'}</button>;
}💡
useCallback= 让函数引用稳定,保护 memo 子组件——没 memo 子组件就别包。自定义 Hook 是普通函数,不是组件(不能返回 JSX)。
二、5 个最常用自定义 Hook
1. useLocalStorage
tsx
function useLocalStorage<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) as T : initial;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(value));
}, [key, value]);
return [value, setValue] as const;
}
// 使用
const [name, setName] = useLocalStorage('name', '游客');2. useDebounce
tsx
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // ⚠️ 取消旧定时器
}, [value, delay]);
return debounced;
}
// 使用
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
fetch(`/api/search?q=${debouncedQuery}`).then(...);
}, [debouncedQuery]);3. usePrevious
tsx
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>();
useEffect(() => { ref.current = value; }, [value]);
return ref.current; // ⚠️ 返回的是上次的值
}
// 使用
function Counter() {
const [count, setCount] = useState(0);
const prev = usePrevious(count);
return (
<>
<p>当前:{count},上次:{prev}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</>
);
}为啥 prev 总是"上次"的值——return ref.current 总在 useEffect 跑之前:
React 三大阶段:
1. Render 阶段 → 函数体执行(包括 return)
2. Commit 阶段 → DOM 更新
3. Effect 阶段 → useEffect 跑关键:return ref.current 是 render 阶段同步跑,useEffect 是 commit 后才跑——所以 return 永远先于 effect。
时序演示(count: 0 → 1 → 2):
| 步骤 | count | ref.current | 返回的 prev | DOM 显示 |
|---|---|---|---|---|
| 首次 render | 0 | undefined | undefined | "当前 0,上次 undefined" |
| 首次 effect | 0 | 0 | — | — |
| 点 +1 render | 1 | 0(还没改) | 0 | "当前 1,上次 0" |
| 点 +1 effect | 1 | 1 | — | — |
| 点 +1 render | 2 | 1(还没改) | 1 | "当前 2,上次 1" |
| 点 +1 effect | 2 | 2 | — | — |
核心:return ref.current 这一刻,effect 还没跑,所以看到的永远是**"上次 effect 改的值"**(也就是上次的值)。
💡
return在 render 阶段同步跑,useEffect在 commit 后异步跑——所以return ref.current永远先于 effect,看到的永远是上次的值。
4. useFetch
tsx
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ data: null, loading: true, error: null });
useEffect(() => {
const ctrl = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: ctrl.signal })
.then(r => r.json())
.then(data => setState({ data, loading: false, error: null }))
.catch(e => {
if (!ctrl.signal.aborted) setState({ data: null, loading: false, error: e as Error });
});
return () => ctrl.abort();
}, [url]);
return state;
}
// 使用
function Comp() {
const { data, loading, error } = useFetch<User[]>('/api/users');
if (loading) return <Spinner />;
if (error) return <Error />;
return <List users={data!} />;
}5. useEventListener
tsx
function useEventListener<K extends keyof WindowEventMap>(
event: K,
handler: (e: WindowEventMap[K]) => void,
) {
useEffect(() => {
window.addEventListener(event, handler);
return () => window.removeEventListener(event, handler);
}, [event, handler]);
}
// 使用
function Comp() {
useEventListener('resize', () => console.log(window.innerWidth));
useEventListener('keydown', e => { if (e.key === 'Escape') close(); });
}三、Hook 之间的相互调用
自定义 Hook 可以调用其他自定义 Hook:
tsx
function useUserWithCache(userId: number) {
const cached = useLocalStorage(`user-${userId}`, null); // 调其他 hook
const [user, setUser] = useState(cached[0]);
// ...
return user;
}四、自定义 Hook 与组件的区别
| 维度 | 自定义 Hook | 组件 |
|---|---|---|
| 命名 | use 开头 | 大写开头 |
| 返回 | 任意值 | JSX |
| 渲染 UI | ❌ | ✅ |
| 调用其他 Hook | ✅ | ✅ |
| 复用逻辑 | ✅ | ⚠️ 通过 children/Props |
⚠️ 坑 1:
use开头的函数必须调用其他 Hook,否则就是普通工具函数,应改名。
五、TypeScript 泛型 Hook
tsx
function useArray<T>(initial: T[] = []) {
const [list, setList] = useState<T[]>(initial);
return {
list,
add: (item: T) => setList(prev => [...prev, item]),
remove: (idx: number) => setList(prev => prev.filter((_, i) => i !== idx)),
clear: () => setList([]),
};
}
// 自动推断类型
const { list, add } = useArray<number>([1, 2, 3]);六、性能:稳定函数引用
tsx
function useStableCallback<T extends (...args: any[]) => any>(fn: T): T {
const ref = useRef(fn);
ref.current = fn;
return useCallback((...args: Parameters<T>) => ref.current(...args), []) as T;
}
// 始终是同一个引用,但调用的是最新 fn七、自定义 Hook 测试
tsx
import { renderHook, act } from '@testing-library/react';
test('useToggle', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => result.current[1]());
expect(result.current[0]).toBe(true);
});⚠️ 坑 2:测试 hook 用
@testing-library/react的renderHook,不要渲染到组件里绕一圈。
八、闭包陷阱
tsx
function Bad() {
const [count, setCount] = useState(0);
// ⚠️ 闭包陷阱:logCount 永远打印 0
const logCount = () => console.log(count);
useEffect(() => {
const id = setInterval(logCount, 1000);
return () => clearInterval(id);
}, []); // 没依赖 count
}修复方法:
tsx
// 方法 1:函数式 setState
useEffect(() => {
const id = setInterval(() => setCount(c => { console.log(c); return c + 1; }), 1000);
}, []);
// 方法 2:ref 存最新值
const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000);
}, []);⚠️ 坑 3:hook 里通过闭包捕获的 state 是创建时的快照,依赖没写对就会读到旧值。
九、本章小结
| 要点 | 关键 |
|---|---|
| 命名约定 | use 开头 |
| 复用 | 逻辑(不是 UI) |
| 返回 | 任意值(不是 JSX) |
| 调用 | 可以嵌套调用其他 hook |
| 闭包陷阱 | ref 存最新值 / 函数式 setState |
| 测试 | renderHook + act |
| 性能 | useCallback 稳定引用 |
动手练习
- useToggle:布尔值切换
- useLocalStorage:任意类型持久化
- useDebounce:值防抖
- useWindowSize:监听窗口尺寸变化
- 封装复杂 hook:把 Todo 列表的所有操作封到
useTodoshook
下一章:第 16 章:路由 →