自定义 Hook:抽象、复用与设计模式
第10章:自定义 Hook:抽象、复用与设计模式
自定义 Hook 是 React 组合性的核心表达形式——它们让有状态逻辑脱离渲染树,成为可独立测试的逻辑单元。
本章核心问题:自定义 Hook 的规则从何而来?什么时候该提取,什么时候不该? 读完本章你将理解:
- Hooks 规则源于 Fiber 的 Hook 链表实现——顺序必须一致
- 单一职责、一致的返回 API、正确处理依赖是设计优秀 Hook 的关键
- 自定义 Hook 让有状态逻辑可以像函数一样被命名、测试和复用
Level 1 · 你需要知道的(1-3年经验)
什么时候应该提取自定义 Hook?
提取自定义 Hook 的信号:
- 同样的 Hook 组合在多个组件里重复出现
- 一个组件里有大量 Hook 逻辑,让渲染逻辑难以看清
- 想给一段有状态的逻辑命名,让意图更清晰
- 需要测试一段有状态的逻辑(自定义 Hook 比组件更容易测试)
不应该提取的情况:只被一个组件使用,且逻辑足够简单。过度抽象和抽象不足一样有害。
useFetch:生产级别的数据获取 Hook
这是最常见的自定义 Hook 需求,也是最容易出错的:
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function useFetch<T>(url: string | null): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({ status: 'idle' });
useEffect(() => {
if (!url) {
setState({ status: 'idle' });
return;
}
setState({ status: 'loading' });
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json() as Promise<T>;
})
.then(data => setState({ status: 'success', data }))
.catch(error => {
if (error.name !== 'AbortError') {
setState({ status: 'error', error });
}
});
return () => controller.abort();
}, [url]);
return state;
}
// 使用示例
function UserProfile({ userId }: { userId: string }) {
const result = useFetch<User>(`/api/users/${userId}`);
if (result.status === 'loading') return <Skeleton />;
if (result.status === 'error') return <ErrorMessage error={result.error} />;
if (result.status === 'idle' || result.status !== 'success') return null;
return <div>{result.data.name}</div>;
}
为什么用判别联合类型(Discriminated Union)?
// 不好的设计:data、loading、error 三个独立字段
type BadState = {
data: T | null;
loading: boolean;
error: Error | null;
};
// 存在不可能的组合:{ data: user, loading: true, error: someError }
// 消费者需要写复杂的条件判断
// 好的设计:判别联合类型,每种状态互斥
type GoodState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T } // data 只在 success 时存在
| { status: 'error'; error: Error }; // error 只在 error 时存在
// TypeScript 会在类型层面保证不可能的组合不存在
useLocalStorage:序列化、SSR 安全与同步
function useLocalStorage<T>(
key: string,
defaultValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
// 懒初始化:只在挂载时读一次 localStorage
const [storedValue, setStoredValue] = useState<T>(() => {
// SSR 安全:服务端没有 window 对象
if (typeof window === 'undefined') return defaultValue;
try {
const item = window.localStorage.getItem(key);
return item !== null ? (JSON.parse(item) as T) : defaultValue;
} catch {
// JSON.parse 失败、localStorage 被禁用等情况
return defaultValue;
}
});
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue(prev => {
const nextValue = typeof value === 'function'
? (value as (prev: T) => T)(prev)
: value;
try {
window.localStorage.setItem(key, JSON.stringify(nextValue));
} catch {
// localStorage 满了或被禁用(隐私模式)
console.warn(`Failed to save to localStorage: ${key}`);
}
return nextValue;
});
},
[key]
);
// 跨标签页同步:监听 storage 事件
useEffect(() => {
function handleStorageChange(event: StorageEvent) {
if (event.key === key && event.newValue !== null) {
try {
setStoredValue(JSON.parse(event.newValue) as T);
} catch {
// ignore invalid JSON from other tabs
}
}
}
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [storedValue, setValue];
}
// 使用示例
function Settings() {
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
return (
<select value={theme} onChange={e => setTheme(e.target.value as 'light' | 'dark')}>
<option value="light">浅色</option>
<option value="dark">深色</option>
</select>
);
}
useWindowSize:响应式尺寸检测
type WindowSize = { width: number; height: number };
function useWindowSize(): WindowSize {
const [size, setSize] = useState<WindowSize>(() => ({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0,
}));
useEffect(() => {
// ResizeObserver 比 resize 事件更精准,但对 window 监听仍需 addEventListener
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return size;
}
// 在实际应用中,配合 useDebounce 避免频繁更新
function useWindowSizeDebounced(delayMs = 100): WindowSize {
const size = useWindowSize();
return useDebounce(size, delayMs);
// 注意:这里传了一个对象给 useDebounce,
// 每次 resize 都是新对象,需要在 useDebounce 内部处理引用问题
// 实际生产中可能需要比较对象内容而非引用
}
useIntersectionObserver:懒加载与无限滚动
type IntersectionOptions = {
root?: Element | null;
rootMargin?: string;
threshold?: number | number[];
};
function useIntersectionObserver(
ref: React.RefObject<Element | null>,
options: IntersectionOptions = {}
): boolean {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
options
);
observer.observe(element);
return () => observer.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, options.root, options.rootMargin, options.threshold]);
// 注意:options 对象本身不稳定,需要解构原始值作为依赖
return isIntersecting;
}
// 图片懒加载
function LazyImage({ src, alt }: { src: string; alt: string }) {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(ref, { threshold: 0.1 });
return (
<div ref={ref} className="lazy-image-container">
{isVisible ? (
<img src={src} alt={alt} />
) : (
<div className="placeholder" />
)}
</div>
);
}
// 无限滚动加载更多
function InfiniteList() {
const [items, setItems] = useState<Item[]>([]);
const [page, setPage] = useState(1);
const sentinelRef = useRef<HTMLDivElement>(null);
const isSentinelVisible = useIntersectionObserver(sentinelRef, { threshold: 1 });
useEffect(() => {
if (isSentinelVisible) {
setPage(prev => prev + 1);
}
}, [isSentinelVisible]);
useEffect(() => {
fetchItems(page).then(newItems => setItems(prev => [...prev, ...newItems]));
}, [page]);
return (
<div>
{items.map(item => <ItemCard key={item.id} item={item} />)}
<div ref={sentinelRef} /> {/* 哨兵元素 */}
</div>
);
}
测试自定义 Hook
自定义 Hook 的可测试性是提取它的重要理由之一。用 @testing-library/react-hooks(或 React Testing Library 的 renderHook)测试:
import { renderHook, act } from '@testing-library/react';
describe('useCounter', () => {
it('initializes with the given value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('increments correctly', () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
describe('useDebounce', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 300));
expect(result.current).toBe('initial');
});
it('debounces value updates', () => {
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'initial' } }
);
rerender({ value: 'updated' });
expect(result.current).toBe('initial'); // 还没到 300ms
act(() => jest.advanceTimersByTime(300));
expect(result.current).toBe('updated'); // 300ms 后更新
});
});
Level 2 · 它是怎么运行的(3-5年经验)
Hooks 规则的深层原因
在理解自定义 Hook 之前,必须先理解为什么 Hooks 有使用规则——只在函数组件顶层调用,不能在条件语句、循环、嵌套函数中调用。
答案藏在 React 的 Fiber 架构里。每个组件实例在 Fiber 节点上维护一个Hook 链表。第一次调用 useState 创建链表的第一个节点,第二次调用创建第二个,依此类推。每次重渲染时,React 按顺序遍历这个链表,把第 n 次调用的结果返回给第 n 个 Hook。
渲染 1:
useState(0) → 链表节点 1: { state: 0, queue: [] }
useState('') → 链表节点 2: { state: '', queue: [] }
useEffect(fn) → 链表节点 3: { effect: fn, deps: undefined }
渲染 2(依赖链表顺序来匹配状态):
useState(0) → 读取节点 1 → 返回 0(或更新后的值)
useState('') → 读取节点 2 → 返回 ''(或更新后的值)
useEffect(fn) → 读取节点 3 → 比较 deps
如果你在条件语句中调用 Hook,某次渲染可能跳过了它,导致后续 Hook 与链表节点错位——React 会读取错误的状态值,产生难以追踪的 bug。
自定义 Hook 只是把这个调用链提取到一个函数里,规则自然延伸到其中。
useDebounce:防抖的正确实现
function useDebounce<T>(value: T, delayMs: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
return () => clearTimeout(timer); // cleanup 取消上一个定时器
}, [value, delayMs]);
return debouncedValue;
}
// 配合 useFetch 实现搜索防抖
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
const results = useFetch<SearchResult[]>(
debouncedQuery ? `/api/search?q=${encodeURIComponent(debouncedQuery)}` : null
);
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索..."
/>
{results.status === 'loading' && <Spinner />}
{results.status === 'success' && (
<ul>{results.data.map(r => <li key={r.id}>{r.title}</li>)}</ul>
)}
</div>
);
}
useDebounce 的关键在于 cleanup:每次 value 变化时,Effect 先取消上一个定时器,再设置新的。这确保只有最后一次变化(经过 delayMs 静止后)才会更新 debouncedValue。
Level 3 · 规范怎么定义的(资深)
自定义 Hook 的设计原则
原则一:单一职责
每个自定义 Hook 应该只做一件事:
// 错误:一个 Hook 做了太多事
function useUserDashboard(userId: string) {
const user = useFetch<User>(`/api/users/${userId}`);
const [theme, setTheme] = useLocalStorage('theme', 'light');
const windowSize = useWindowSize();
// 这三件事完全不相关,应该在组件里分别调用
}
// 正确:各自独立,在组件中组合
function Dashboard({ userId }: { userId: string }) {
const user = useFetch<User>(`/api/users/${userId}`);
const [theme, setTheme] = useLocalStorage('theme', 'light');
const { width } = useWindowSize();
}
原则二:返回一致的 API
// 不好:返回值结构不稳定,有时是数组,有时是对象
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
return [count, () => setCount(c => c + 1), () => setCount(c => c - 1)]; // 位置语义弱
}
// 好:具名返回值,语义清晰
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}
命名约定:遵循社区惯例,use 前缀是必须的(让 React lint 规则识别),返回值用具名对象(方便解构和扩展)。
原则三:正确处理依赖
// 问题:options 对象作为依赖会每次都变化
function useEventListener(
event: string,
handler: (e: Event) => void,
options?: EventListenerOptions
) {
useEffect(() => {
document.addEventListener(event, handler, options);
return () => document.removeEventListener(event, handler, options);
}, [event, handler, options]); // options 是对象,每次新建
}
// 修复:使用 ref 稳定化 handler,解构 options 的原始值
function useEventListener(
event: string,
handler: (e: Event) => void,
options?: EventListenerOptions
) {
const handlerRef = useRef(handler);
handlerRef.current = handler; // 在渲染时同步更新
const capture = options?.capture;
const passive = options?.passive;
const once = options?.once;
useEffect(() => {
function stableHandler(e: Event) {
handlerRef.current(e);
}
document.addEventListener(event, stableHandler, { capture, passive, once });
return () => document.removeEventListener(event, stableHandler, { capture, passive, once });
}, [event, capture, passive, once]);
}
Level 4 · 边界与陷阱(所有人)
生产环境常见问题
在实际项目中,本章涵盖的概念最常见的问题包括:
-
忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。
-
过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。
-
文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。