React 自定义 Hooks 架构:提取、组合与测试
Hooks 不只是 useState/useEffect——自定义 Hooks 是 React 的"复用单元"。本文讲清什么时候提取自定义 Hook(重复逻辑/副作用/状态逻辑)、Hook 组合(Hook 调 Hook)、依赖与 stale closure、测试 Hooks(renderHook + act),以及"别过度抽象 Hook"。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
React 自定义 Hooks 架构:提取、组合与测试
很多团队写 React 写了三年,项目里能找到的自定义 Hook 只有 useWindowSize。这不是因为他们没有可复用的逻辑——恰恰相反,他们的组件里有大量重复的状态管理、副作用、数据请求,只是这些逻辑从来没被"提取"出来。
自定义 Hook 不是高级技巧,它是 React 提供的唯一的复用单元。在 Hooks 出现之前,我们用 HOC(高阶组件)和 render props 来复用逻辑,结果都写成了嵌套地狱。Hooks 的革命性不在于"函数式组件",而在于它给了我们一种不引入新组件层级就能复用状态逻辑的方式。
但这不意味着"万物皆 Hook"。本文想讲清楚四件事:什么时候提取、怎么组合、怎么测、什么时候别提取。
一、自定义 Hook 是 React 的复用单元
先校准一下认知:自定义 Hook 的本质就是一个函数。它没有任何魔法,唯一的约束是——它必须以 use 开头,并且在内部调用其他 Hook。
// 这就是一个自定义 Hook,没什么神秘的
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const;
}
为什么说它是"复用单元"?因为对比一下历史上的三种方案就明白了:
HOC(高阶组件):withAuth(withTheme(withRouter(Component)))——三层包裹,props 来源不透明,TypeScript 类型地狱,调试时 DevTools 里是一坨匿名组件。
render props:<Auth>{({ user }) => <Theme>{({ theme }) => ...}</Theme>}</Auth>——嵌套金字塔,回调地狱的 JSX 版。
自定义 Hook:const { user } = useAuth(); const { theme } = useTheme();——平铺、线性、类型自动推导、DevTools 可见。
Hooks 赢在没有引入新的组件树层级。逻辑复用不再需要"包裹组件",而是像调用函数一样自然。这是它真正取代 HOC 和 render props 的原因。
一句话:自定义 Hook 让"复用状态逻辑"和"复用普通函数"的写法统一了。
二、什么时候该提取自定义 Hook
不是所有逻辑都该变成 Hook。我见过有人把 const fullName = firstName + ' ' + lastName 包成 useFullName()——这是病,得治。
提取自定义 Hook 的合理信号有三个:
1. 重复的状态逻辑
同样的 useState + useEffect + handler 组合在两个以上组件里出现:
// 搜索框里
function SearchBox() {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(query), 300);
return () => clearTimeout(t);
}, [query]);
// ...
}
// 用户名输入里又写了一遍——这就是信号
function UsernameInput() {
const [name, setName] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(name), 300);
return () => clearTimeout(t);
}, [name]);
// ...
}
这种重复就该提取成 useDebounce。第二次手写一模一样的代码时就是信号,不要等到第三次。
2. 复杂的副作用
当一个 useEffect 里有:订阅、取消订阅、清理函数、状态同步,再叠加上业务逻辑,组件可读性会迅速崩塌。把副作用抽出去,组件只关心"我要什么数据",不关心"数据怎么来的"。
典型的就是数据请求、WebSocket 订阅、Intersection Observer——这些都有生命周期,需要 cleanup,天生适合 Hook 封装。
3. 跨组件共享的状态逻辑
useAuth、useTheme、useCurrentUser、useFeatureFlag——这些是"横切关注点"。多个不相关组件都要用,写到 props 里层层传递(prop drilling)就是灾难。提到 Hook 里(通常配合 Context)一劳永逸。
判断标准很简单:如果这段逻辑脱离了某个具体组件依然有意义,它就该是独立的 Hook。 useUserProfile(userId) 有意义,useButtonHoverState 就没意义——它和按钮组件是同生共死的。
三、Hook 组合:Hook 调 Hook
这是自定义 Hook 最强、也最容易被忽视的能力:Hook 可以调用其他 Hook。
这意味着你可以分层构建复杂逻辑。底层的、原子的 Hook 提供基础能力,上层的 Hook 组合它们提供业务语义。
举个真实的例子。假设我们要做一个 useAuth,它需要:
- 从 localStorage 读 token
- 监听 token 变化
- 提供 login/logout
不要把所有东西塞进一个 Hook。拆成原子 Hook,再组合:
// 底层原子 Hook:负责 localStorage 读写
function useStorage<T>(key: string, initialValue: T) {
const [value, setValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const raw = window.localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : initialValue;
} catch {
return initialValue;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// quota 超了、隐私模式,静默失败
}
}, [key, value]);
return [value, setValue] as const;
}
// 中间层:负责 token 的存储语义
function useToken() {
const [token, setToken] = useStorage<string | null>('auth_token', null);
return { token, setToken, isAuthenticated: token !== null };
}
// 顶层:业务语义
function useAuth() {
const { token, setToken, isAuthenticated } = useToken();
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
if (!token) {
setUser(null);
return;
}
fetchCurrentUser(token).then(setUser).catch(() => setToken(null));
}, [token, setToken]);
const login = useCallback(
async (credentials: Credentials) => {
const { token: newToken } = await loginApi(credentials);
setToken(newToken);
},
[setToken],
);
const logout = useCallback(() => setToken(null), [setToken]);
return { user, isAuthenticated, login, logout };
}
这种分层的好处是每一层都能独立测试、独立复用。useStorage 可以用在设置页面,useToken 可以用在不需要用户信息只判断登录态的场合,useAuth 是完整方案。
组合 Hook 时唯一要记住的规则是 React 的 Rules of Hooks:不能在条件、循环、嵌套函数里调用 Hook。组合 Hook 时,被调用的 Hook 必须在组合 Hook 的顶层无条件执行。这是 React 的硬约束,违反它会导致渲染顺序错乱。
四、依赖数组的正确管理与 stale closure 陷阱
这一节是事故高发区。useEffect、useMemo、useCallback 的第二个参数(依赖数组)是 React 和闭包博弈的产物。理解错了,你会得到两类 bug:
- 无限重渲染:依赖里放了引用每次都变的对象/函数。
- stale closure(过时闭包):依赖漏了,effect 里用到了"旧"的值。
stale closure 是什么
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 这里的 count 永远是 0!
// 因为这个 effect 只在挂载时跑了一次,闭包捕获了最初的 count
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []); // <-- 依赖数组空,effect 只跑一次
return <div>{count}</div>;
}
这个组件数字永远停在 1。因为 setInterval 的回调里捕获的 count 是第一次渲染时的 0,0 + 1 = 1,下一次 0 + 1 还是 1。
修法一:用函数式更新,不依赖外部值。
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1); // c 是最新的
}, 1000);
return () => clearInterval(id);
}, []);
修法二:把 count 加进依赖(但这样每秒都会清掉定时器重建,性能差)。
修法三:用 ref 保存最新值,effect 里读 ref。
依赖数组的管理原则
不要"凭感觉"写依赖数组。规则是机械的:
- effect 里用到了什么响应式值(state、props、派生的变量),就写什么。
- 用 ESLint 的
react-hooks/exhaustive-deps规则强制检查——它会告诉你漏了什么。如果它报警而你"故意"不想加,99% 的情况是你应该重构,而不是禁用规则。
如果你发现一个 effect 的依赖太多、每次都触发,正确的反应不是用 // eslint-disable-next-line,而是:
- 用
useCallback/useMemo稳定依赖。 - 用 reducer 把多个 state 合并,用 dispatch 代替多个 state(dispatch 引用永远稳定)。
- 拆 effect,让每个 effect 只关心一件事。
ref:绕过闭包的逃生舱
当你确实需要在"只跑一次"的 effect 里访问最新值,用 ref:
function useLatest<T>(value: T) {
const ref = useRef(value);
ref.current = value; // 每次渲染都更新
return ref;
}
function Counter() {
const [count, setCount] = useState(0);
const countRef = useLatest(count);
useEffect(() => {
const id = setInterval(() => {
setCount(countRef.current + 1); // 永远是最新值
}, 1000);
return () => clearInterval(id);
}, []);
return <div>{count}</div>;
}
useLatest 是社区里很经典的一个工具 Hook,本质就是把"读最新值"这个需求显式化。
依赖数组的金科玉律:不要骗它。它说缺依赖,就补;补不了就重构;重构不了就用 ref。永远不要 disable lint。
五、测试自定义 Hooks
Hook 不能脱离组件直接调用(因为它们依赖 React 的渲染上下文)。要测 Hook,用 @testing-library/react 的 renderHook,配合 act。
renderHook + act 基础
import { renderHook, act } from '@testing-library/react';
import { useToggle } from './useToggle';
test('useToggle 在 toggle 调用后翻转状态', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current[0]).toBe(false);
act(() => {
result.current[1](); // 调用 toggle
});
expect(result.current[0]).toBe(true);
});
两个关键点:
result.current:始终指向 Hook 最新一次的返回值。每次重渲染后result.current会更新,但你必须用result.current(而不是解构出const [on] = result.current——那会拿到旧值)。act:任何会引起状态更新的操作(调用 setState、resolve promise、触发 timer)都必须包在act()里。否则 React 会警告、且断言可能基于未刷新的状态。
测带 effect 的 Hook(以 useDebounce 为例)
import { renderHook, act } from '@testing-library/react';
import { vi } from 'vitest'; // 或 jest
test('useDebounce 在延迟后更新 debounced 值', () => {
vi.useFakeTimers();
const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
initialProps: { value: 'hello' },
});
// 立刻读,应该是初始值
expect(result.current).toBe('hello');
// 改变输入
rerender({ value: 'world' });
expect(result.current).toBe('hello'); // 还没到时间
// 快进 200ms
act(() => {
vi.advanceTimersByTime(200);
});
expect(result.current).toBe('hello'); // 还差 100ms
// 快进到 300ms
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current).toBe('world');
vi.useRealTimers();
});
注意 rerender:测试 Hook 响应 props/state 变化时,用 rerender 模拟组件重渲染。配合 initialProps 传初始值。
测异步 Hook(useAsync 一类)
异步场景要 waitFor:
test('useAsync 在 resolve 后返回 data', async () => {
const fetcher = vi.fn().mockResolvedValue({ id: 1, name: 'Alice' });
const { result } = renderHook(() => useAsync(fetcher));
expect(result.current.state).toBe('loading');
await waitFor(() => {
expect(result.current.state).toBe('success');
});
expect(result.current.data).toEqual({ id: 1, name: 'Alice' });
});
记住:异步断言永远不要直接 expect,要包在 waitFor 或 findBy* 里,否则你测的是"还没 resolve 时的状态",永远红或永远绿。
六、常见模式:手写五个高复用 Hook
把这几个写熟,日常 80% 的复用需求就覆盖了。
useDebounce(防抖)
import { useEffect, useState } from 'react';
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer); // value 或 delay 变了就清掉旧定时器
}, [value, delay]);
return debounced;
}
要点:cleanup 函数是防抖的核心。每次 value 变化都会触发 effect 重跑,旧的 timer 被清理,只有"最后一次变化后安静了 delay 毫秒"才会真正 setState。
useLocalStorage(持久化状态)
import { useCallback, useEffect, useState } from 'react';
function useLocalStorage<T>(
key: string,
initialValue: T,
): readonly [T, (value: T | ((prev: T) => T)) => void] {
const readValue = useCallback((): T => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch (err) {
console.warn(`useLocalStorage: 读取 ${key} 失败`, err);
return initialValue;
}
}, [key, initialValue]);
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
setStoredValue((prev) => {
const next =
typeof value === 'function' ? (value as (p: T) => T)(prev) : value;
try {
window.localStorage.setItem(key, JSON.stringify(next));
} catch (err) {
console.warn(`useLocalStorage: 写入 ${key} 失败`, err);
}
return next;
});
},
[key],
);
// 监听其他 tab 对同一 key 的修改
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue) as T);
} catch {
/* ignore */
}
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, [key]);
return [storedValue, setValue] as const;
}
注意几个工程细节:SSR 安全(typeof window === 'undefined')、try/catch 兜底(隐私模式、quota 满了)、跨标签页同步(监听 storage 事件)、支持函数式更新。这些是"能跑"和"线上不出事"的区别。
useAsync(异步请求状态机)
import { useEffect, useState } from 'react';
type AsyncState<T> =
| { state: 'idle' }
| { state: 'loading' }
| { state: 'success'; data: T }
| { state: 'error'; error: Error };
function useAsync<T>(
asyncFn: () => Promise<T>,
deps: unknown[] = [],
) {
const [status, setStatus] = useState<AsyncState<T>>({ state: 'idle' });
useEffect(() => {
let cancelled = false;
setStatus({ state: 'loading' });
asyncFn()
.then((data) => {
if (!cancelled) setStatus({ state: 'success', data });
})
.catch((error: Error) => {
if (!cancelled) setStatus({ state: 'error', error });
});
return () => {
cancelled = true; // 防止卸载后 setState
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return status;
}
这里有两个关键工程点:cancelled 标志位防止组件卸载后还 setState(会触发 React 警告甚至内存泄漏);deps 由调用方显式传入,避免把 asyncFn 这种引用不稳定的函数直接放进依赖(会无限重跑)。
usePrevious(取上一次渲染的值)
import { useEffect, useRef } from 'react';
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value; // 在渲染完成后更新,所以本次返回的还是旧值
}, [value]);
return ref.current;
}
理解这道题就理解了 ref 和 effect 的执行时机:useEffect 在 DOM 提交后才跑,所以这次 render 返回 ref.current(旧的),effect 里再把它更新成新值,下一次 render 就能拿到这次的"上一个"。
useMediaQuery(响应式状态)
import { useEffect, useState } from 'react';
function useMediaQuery(query: string): boolean {
const getMatches = () =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false;
const [matches, setMatches] = useState(getMatches);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches); // 挂载时同步一次(query 可能变了)
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
注意:addEventListener('change', ...) 是现代写法,老代码里的 addListener 已废弃。SSR 安全也要照顾到。
七、别过度抽象:不是所有逻辑都要变 Hook
这一节可能比前面所有加起来都重要。
我见过太多团队的代码库,为了"复用",把任何三行以上的逻辑都包成 useXxx。结果:
useFullName(firstName, lastName)——返回拼接字符串。这就是个纯函数,包成 Hook 多此一举,每次渲染还都走一遍 Hook 调度。useIsEven(number)——返回number % 2 === 0。同上。useFormatDate(date)——包了date-fns的format。同上。
判断标准:如果这段逻辑不调用任何 React API(useState、useEffect、useRef 等),它就不该是 Hook,它该是个普通函数。
// ❌ 多此一举
function useFullName(first: string, last: string) {
return `${first} ${last}`;
}
// ✅ 老老实实当普通函数
function getFullName(first: string, last: string) {
return `${first} ${last}`;
}
另一种过度抽象是提前抽象。当你只有一处用到某段逻辑时,先内联。等第二处真的出现了,再提取。不要为了"将来可能复用"提前抽象——你预测不准,提前抽出来的 Hook 接口十有八九会在第二次使用时被迫改造,反而比一开始内联更费事。
规则:Rule of Three 的一半——第二次重复时就该提,但第一次就该克制。宁可内联两次,也不要为了"看似干净"提前抽。
还有一个反向陷阱:别为了减少组件层级把 UI 也塞进 Hook。Hook 返回的应该是数据和行为,而不是 JSX。如果你写出了 useModal() 返回一段 <dialog> JSX,那它其实是个组件,叫 <Modal> 更合适。Hook 管逻辑,组件管渲染,分工别乱。
八、命名约定:use 前缀不是建议,是强制
最后一条,但很重要:自定义 Hook 必须以 use 开头。
这不是代码风格问题,是 React 的硬性约束。原因有二:
-
linter 依赖它。
eslint-plugin-react-hooks通过函数名是否以use开头来判断"这是个 Hook",从而检查它内部是否违反 Rules of Hooks。你把 Hook 命名为fetchUser,linter 不会检查它内部的 Hook 调用顺序,bug 就溜过去了。 -
React DevTools 依赖它。DevTools 的 Hooks 调试面板靠
use前缀识别和分组 Hook 状态。没有前缀的 Hook 在面板里就是黑盒。
反过来也成立:不要把不是 Hook 的函数命名为 use 开头。useFullName 如果不调用任何 Hook,会误导 linter 去检查它、误导同事以为里面有状态。
命名上还有几个实战经验:
- 返回值语义化:
[state, setState]这种元组适合简单 Hook(useState风格),复杂 Hook 返回对象{ data, loading, error, refetch }更易扩展。 - 行为动词优先:
useDebounce(做了什么)比useDebouncedValue(返回什么)更稳,因为行为不会变,返回值可能会扩。 - 避免
useData这种空泛命名:什么数据?叫useUser、useOrders、useCurrentUser都比useData强一万倍。名字模糊通常是抽象边界没想清楚的信号。
小结
- 自定义 Hook 是 React 唯一的复用单元,它取代了 HOC 和 render props,因为复用状态逻辑时不再引入新的组件树层级。
- 提取时机看三信号:重复的状态逻辑、复杂的副作用、跨组件共享的横切关注点。第一次重复就该克制内联,第二次就该提取。
- Hook 可以调 Hook——分层组合(
useAuth → useToken → useStorage)让每一层可独立测试、独立复用。 - 依赖数组不要骗它:
exhaustive-deps报警就补,补不了就重构,重构不了用 ref,永远别 disable lint。stale closure 不是 React 的 bug,是你没把依赖写全。 - 测试用
renderHook+act:状态更新必包 act,异步必包 waitFor,重渲染用 rerender。Hook 的测试比组件测试简单得多,没有理由不测。
最后送一句我这些年反复验证的话:
"用不用自定义 Hook"不是技术问题,是判断问题。好的 Hook 抽象让代码像散文一样清晰,坏的 Hook 抽象让代码比重复三遍更难懂。区别只在于——你是为了消除重复,还是为了显得有架构。
Related