自定义 Hooks 是 React 组件逻辑复用的核心手段。它允许你将组件中与状态、副作用、订阅等相关的逻辑抽离成可独立使用的函数,从而实现“逻辑共享、视图分离”。本质上,自定义 Hooks 就是以 use 开头的普通 JavaScript 函数,内部可以调用其他内置或自定义 Hooks。
7.5.1 自定义 Hooks 的工作原理
从实现机制看,自定义 Hooks 本身并不“创造”新的状态或副作用,而是对 React 内置 Hooks 的二次封装。其运行完全依赖于 React 的 Hooks 链表机制:
- 调用顺序归位:React 通过调用顺序来关联每个 Hook 的内部状态。自定义 Hook 在组件中被调用时,其内部使用的
useState、useEffect等会按顺序插入到当前组件的 Hooks 链表中。 - 闭包持有状态:Hook 通过闭包保持与组件 Fiber 节点的绑定,因此自定义 Hook 内部的状态更新,会触发使用该 Hook 的组件重新渲染。
- 完全隔离:同一个自定义 Hook 在不同组件或同一组件的不同实例中调用,其状态互相独立,因为每次调用都会按序分配新的存储单元。
因此,写自定义 Hook 就是在写一个“携带 Hook 逻辑的可复用函数”——其本身不保存任何持久化状态,状态完全寄存在调用它的组件实例中。
简单示例:封装一个计数逻辑
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
return { count, increment, decrement };
}
// 在组件中使用
function Counter() {
const { count, increment, decrement } = useCounter(10);
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
useCounter 内部调用了 useState 和 useCallback,React 会将这些 Hook 链接到 Counter 组件上,count 的变化会触发 Counter 重渲染。
7.5.2 封装原则
1. 以 use 开头命名
React 约定所有 Hook 函数必须以 use 开头。这不仅是让 Lint 工具(eslint-plugin-react-hooks)能够正确检测 Hook 使用规则的约束,也是让开发者一眼识别这是一个包含了 React 运行时逻辑的函数,而不是普通的工具函数。
✅ useWindowSize
❌ windowSizeHook 或 getWindowSize
2. 单一职责:一个 Hook 只做一件事
自定义 Hook 应该像组件一样遵循单一职责原则。一个 Hook 如果混合了数据请求、状态同步、DOM 操作等多种逻辑,会变得难以测试和维护。正确的做法是拆分成多个独立的 Hook,再在组件中组合使用。
// ✅ 职责清晰
function useUserData(userId) { /* 请求用户数据 */ }
function useOnlineStatus(userId) { /* 订阅在线状态 */ }
function useWindowResize(callback) { /* 监听窗口变化 */ }
// ❌ 大杂烩
function useUserEverything(userId) { /* 请求、状态、缓存、日志混在一起 */ }
3. 明确的输入输出,避免隐式依赖
自定义 Hook 的输入(参数)和输出(返回值)应该是稳定且可预测的。避免在 Hook 内部直接访问全局变量、上下文之外的模块变量,应通过参数传入。这样 Hook 成为一个纯函数化的逻辑单元,便于测试与复用。
// ✅ 明确依赖
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
// ❌ 隐式依赖外部变量
let appTitle = 'App';
function useDocumentTitle() {
useEffect(() => {
document.title = appTitle; // 变更来源不明
});
}
4. 返回值解构友好,保持接口稳定
很多人选择返回对象(便于按需解构)、数组(类似 useState)或两者的结合。需要保证返回的引用在依赖不变时应当保持稳定,避免引发不必要的重渲染。可以使用 useMemo 稳定组合对象。
function usePagination(total, pageSize) {
const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(total / pageSize);
const goTo = useCallback((page) => { /* ... */ }, []);
// 稳定对象引用,避免调用方重复 effect
return useMemo(() => ({
currentPage,
totalPages,
goTo
}), [currentPage, totalPages, goTo]);
}
5. 处理好副作用清理与依赖声明
如果 Hook 内部使用了 useEffect、addEventListener、定时器等,必须返回清理函数。依赖数组要如实声明所有使用的外部变量,避免闭包过期或内存泄漏。
function useTimer(ms) {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), ms);
return () => clearInterval(id);
}, [ms]); // ms 变化时重建定时器
return tick;
}
6. 避免过早抽象,先写重复再提炼
业务开发中,不要一看到两个组件有相似逻辑就立即抽成 Hook。过早抽象会让 Hook 承担过多的条件判断和配置项,反而复杂化。合理做法是:允许重复出现 2-3 次后,再抽取共用逻辑,此时边界更清晰。
7. 不改变调用方的渲染行为(保持透明)
自定义 Hook 内部不应使用 React.memo、shouldComponentUpdate 等影响渲染行为的技术,因为这些会干扰调用方的优化策略。Hook 只负责逻辑,渲染优化留给组件自身决定。
7.5.3 实战:封装一个完整的网络请求 Hook
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(url)
.then(res => res.json())
.then(result => {
if (!cancelled) {
setData(result);
setError(null);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setData(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [url]);
return { data, error, loading };
}
这个 Hook 遵循了单一职责(只做数据获取)、明确的输入(url)、稳定的输出对象、处理了竞态清理。任何组件都可以直接使用它来获取数据,不必重复编写相似代码。
7.5.4 总结
自定义 Hooks 的本质是逻辑复用而非状态复用,它是 React 组合模式的延伸。遵循命名规范、单一职责、输入输出透明、副作用管理完善等原则,可以让你的代码库保持低耦合、高可测试性。在大型项目中,充分使用自定义 Hooks 能将组件层级的复杂逻辑抽丝剥茧,形成清晰的“逻辑层”,是 React 开发中最重要的技能之一。