自定义 Hook 是厨房里的预制调料包——常用组合提前配好,用时一倒就行。
一、自定义 Hook 是什么
自定义 Hook 就是一个以 use 开头的函数,里面调用其他 Hook,把可复用的有状态逻辑封装起来。
// 一个最简单的自定义 Hook
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = () => setValue(prev => !prev);
return [value, toggle];
}
它做的事很简单:把 useState + 一个 toggle 函数打包成一个整体。别的组件想用这个逻辑,调一下就行:
function Modal() {
const [isOpen, toggle] = useToggle(false);
// 直接用,不用自己写 useState + toggle 逻辑
return (
<>
<button onClick={toggle}>打开/关闭</button>
{isOpen && <div>弹窗内容</div>}
</>
);
}
1.1 和普通函数的区别
自定义 Hook 不是”普通函数”——它能调用其他 Hook(useState、useEffect 等),普通函数不行:
// ❌ 普通函数里调 Hook → 报错!
function getToggle() {
const [value, setValue] = useState(false); // React 不允许
return value;
}
// ✅ 以 use 开头的函数 → React 认可它是 Hook
function useToggle() {
const [value, setValue] = useState(false); // 合法
return value;
}
React 靠函数名是否以 use 开头来识别 Hook。这不是约定,是规则——违反就会出错。
1.2 它复用的是”有状态的逻辑”
注意措辞:有状态的逻辑,不是 UI,不是纯数据。
- 不是复用 UI:自定义 Hook 不返回 JSX,返回的是数据和方法。UI 复用靠组件。
- 不是复用纯数据:纯函数的工具方法(格式化日期、计算价格)直接写普通函数就行,不需要 Hook。
- 复用的是”状态 + 副作用 + 逻辑”的组合:比如”窗口尺寸”需要
useState存值 +useEffect监听 resize,这个组合才是自定义 Hook 的领地。
二、为什么需要自定义 Hook
2.1 问题:组件间共享”有状态的逻辑”
两个组件都需要监听窗口宽度——同样的 useState + useEffect 写两遍,复制粘贴能跑,但改一处忘改另一处就是 bug。
2.2 自定义 Hook 的解法
把重复的逻辑抽成 useWindowSize(完整实现见第五章),两个组件都只需一行:
function Sidebar() {
const width = useWindowSize();
return width < 768 ? <MobileSidebar /> : <DesktopSidebar />;
}
逻辑写一次,到处复用。改逻辑只改一个地方。
2.3 比 HOC / render props 更简洁
Hook 出现前,React 有两种逻辑复用方案——HOC(高阶组件包裹,注入 props)和 render props(通过 props 传函数渲染)。它们都能解决问题,但代价不小:
| 自定义 Hook | HOC | render props | |
|---|---|---|---|
| 嵌套层级 | 扁平,无额外嵌套 | 多层包裹(Wrapper Hell) | 回调嵌套 |
| 数据来源 | 直观,就在函数里 | 隐式,不知道 props 从哪来 | 显式,但嵌套深 |
| 调试难度 | 低 | 高(组件栈一堆匿名) | 中 |
| TypeScript 支持 | 好 | 差(类型推导困难) | 中 |
结论:新项目用自定义 Hook,别再写 HOC 和 render props。它们是 Hook 出现前的妥协方案。
三、命名规则
3.1 必须以 use 开头
// ✅ 正确:以 use 开头
function useToggle() { ... }
function useLocalStorage() { ... }
function useWindowSize() { ... }
// ❌ 错误:不以 use 开头 → React 不把它当 Hook
function toggle() { ... }
function getLocalStorage() { ... }
为什么?React 靠函数名前缀识别 Hook,从而应用 Hook 规则(顶层调用、不能在条件语句中调用等)。不叫 use,React 就不管你,你里面的 useState / useEffect 就会报错。
3.2 ESLint 规则
eslint-plugin-react-hooks 提供了两条规则:
rules-of-hooks:Hook 只能在组件或自定义 Hook 里调用,不能在普通函数、条件语句、循环中调用exhaustive-deps:useEffect/useMemo/useCallback的依赖数组必须完整
// ❌ rules-of-hooks 会报错
function handleClick() {
useState(0); // 在事件处理器里调 Hook
}
// ❌ rules-of-hooks 会报错
if (condition) {
const [x, setX] = useState(0); // 在条件语句里调 Hook
}
建议:项目里一定要开这两条 ESLint 规则。它们不是建议,是铁律。
四、设计原则
4.1 一个 Hook 做一件事
// ❌ 做了两件事:管理表单 + 提交逻辑
function useFormAndSubmit() {
const [form, setForm] = useState({})
const [submitting, setSubmitting] = useState(false)
// ... 表单逻辑
// ... 提交逻辑
}
// ✅ 拆开:表单状态一个 Hook,提交逻辑另一个
function useForm(initialValues) { ... }
function useSubmit(submitFn) { ... }
单一职责不只适用于组件,也适用于 Hook。一个 Hook 管一件事,组合起来比一个大杂烩灵活得多。
4.2 返回值设计:数组 vs 对象
返回数组——适合返回值少(2-3 个)、调用时想自定义命名:
function useToggle(initial = false) {
const [value, setValue] = useState(initial);
const toggle = () => setValue(prev => !prev);
return [value, toggle]; // 数组
}
// 调用时自由命名
const [isOpen, toggleOpen] = useToggle();
const [isDark, toggleDark] = useToggle();
返回对象——适合返回值多(4+ 个)、需要语义明确:
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// ... fetch 逻辑
}, [url]);
return { data, loading, error }; // 对象
}
// 调用时按需解构,顺序无关
const { data, error } = useFetch("/api/users");
判断标准:2-3 个返回值用数组,4 个以上用对象。
useState返回数组是因为永远只有[值, setter]两个。
4.3 参数设计
接收配置对象——参数多时用对象,方便扩展和设默认值:
function useLocalStorage(key, options = {}) {
const { defaultValue = null, serializer = JSON } = options;
// ...
}
// 调用
const [name, setName] = useLocalStorage("name", { defaultValue: "yi" });
接收回调函数——让 Hook 的行为可定制:
function useFetch(url, { onSuccess, onError } = {}) {
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
onSuccess?.(data); // 成功时通知外部
})
.catch(err => {
setError(err);
onError?.(err); // 失败时通知外部
});
}, [url]);
}
4.4 条件判断放在 Hook 内部
// ❌ 在外部决定是否调用 Hook → 违反 Hook 规则
function ChatRoom({ roomId }) {
if (roomId) {
useChatConnection(roomId); // 条件调用,Hook 顺序可能变化
}
}
// ✅ 条件判断放在 Hook 内部
function useChatConnection(roomId) {
useEffect(() => {
if (!roomId) return; // Hook 内部判断,Hook 本身每次都调用
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
function ChatRoom({ roomId }) {
useChatConnection(roomId); // 无条件调用,Hook 顺序稳定
}
铁律:Hook 必须每次渲染都调用,调用顺序不能变。条件逻辑放 Hook 里面,不放外面。
五、经典实战示例
5.1 useToggle:布尔值切换
最简单的自定义 Hook,适合入门理解。使用场景:弹窗开关、折叠面板、深色模式切换。
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(prev => !prev), []);
return [value, toggle];
}
// 使用
const [isDark, toggleDark] = useToggle(false);
5.2 useLocalStorage:持久化状态
把 useState 的值同步到 localStorage,刷新页面不丢失:
function useLocalStorage(key, initialValue) {
// 惰性初始化:只在首次渲染时读 localStorage
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
// 值变化时同步写入 localStorage
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// localStorage 满了或隐私模式,静默失败
}
}, [key, value]);
return [value, setValue];
}
使用:
const [theme, setTheme] = useLocalStorage("theme", "light");
// 刷新页面,设置还在
注意:
useLocalStorage的setValue和useState的 setter 完全一样——调用方式不变,只是底层多了持久化。这就是自定义 Hook 的魅力:接口不变,能力增强。
5.3 useDebounce:防抖值
搜索框输入时,等用户停顿 300ms 再触发搜索——经典的防抖场景:
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer); // 每次 value 变化都清除旧定时器
}, [value, delay]);
return debouncedValue;
}
使用:
function SearchBox() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
原理:query 每次变化都启动新定时器并清除上一个,只有停顿 500ms 后 debouncedQuery 才更新。
5.4 useFetch:数据获取(简要版)
实际项目推荐用 TanStack Query,它处理了缓存、重试、去重等
useFetch容易遗漏的细节。这里写useFetch是为了理解自定义 Hook 的设计思路。
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // 防止竞态
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(result => {
if (!cancelled) {
setData(result);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [url]);
return { data, loading, error };
}
使用:
const { data, loading, error } = useFetch("/api/users");
5.5 useWindowSize:窗口尺寸
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
function handleResize() {
setSize({ width: window.innerWidth, height: window.innerHeight });
}
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}
使用:
const { width } = useWindowSize();
模式总结:所有这些 Hook 都遵循同一个套路——useState 存值 + useEffect 做事 + 返回结果。自定义 Hook 的原材料就是
useState和useEffect。
六、与普通函数 / HOC / render props 的对比
| 维度 | 自定义 Hook | 普通工具函数 | HOC | render props |
|---|---|---|---|---|
| 能否调 Hook | ✅ 能 | ❌ 不能 | ✅ 能 | ✅ 能 |
| 返回什么 | 数据和方法 | 计算结果 | 增强组件 | JSX |
| 嵌套层级 | 无额外嵌套 | 无 | 多层包裹 | 回调嵌套 |
| 数据流 | 显式,从 Hook 返回 | 显式,函数返回值 | 隐式,注入 props | 显式,函数参数 |
| 适用场景 | 有状态的逻辑复用 | 纯计算、格式化 | 已废弃 | 已废弃 |
一句话:纯逻辑用普通函数,有状态的逻辑用自定义 Hook,UI 复用用组件。HOC 和 render props 是历史方案,新代码别用了。
七、常见错误
7.1 在普通函数里调用 Hook
// ❌ 普通函数不是 Hook,不能调 useState/useEffect
function formatDate() {
const [locale, setLocale] = useState("zh"); // 报错!
// ...
}
// ✅ 如果需要状态,改成自定义 Hook
function useLocaleFormatter() {
const [locale, setLocale] = useState("zh");
// ...
}
// ✅ 如果不需要状态,用普通函数
function formatDate(date) {
return date.toLocaleDateString("zh");
}
7.2 返回 JSX
// ❌ Hook 返回 JSX → 它到底是 Hook 还是组件?
function useModal() {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="modal" open={isOpen}>
...
</div>
);
}
// ✅ Hook 返回数据,组件返回 UI
function useModal() {
const [isOpen, setIsOpen] = useState(false);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
return { isOpen, open, close };
}
function Modal({ isOpen, onClose }) {
if (!isOpen) return null;
return <div className="modal">...</div>;
}
原则:Hook 返回数据和方法,组件返回 UI。各司其职,不要混。
7.3 过度抽象
只有一个组件用的逻辑不需要抽 Hook——过早抽象比不抽象更糟糕,多了一个文件、多了一层间接性,但没有任何复用收益。
// ❌ 只有一个组件用,不需要抽 Hook
function UserPage() {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
}
// ✅ 至少两个组件需要同样的逻辑,才值得抽 Hook
判断标准:同样的有状态逻辑出现在两个或以上组件中 → 抽 Hook。只出现一次 → 直接写在组件里。
八、与已有笔记的串联
自定义 Hook 不是凭空出现的,它的原材料就是 useState 和 useEffect:
useState → 存值(给组件记忆)
useEffect → 做事(给组件行动力)
↓ 组合
自定义 Hook → 把"存值 + 做事"打包复用
回顾一下每个经典 Hook 的组成:
| 自定义 Hook | useState 的角色 | useEffect 的角色 |
|---|---|---|
useToggle | 存布尔值 | 无 |
useLocalStorage | 存值 + 惰性初始化 | 同步写入 localStorage |
useDebounce | 存防抖后的值 | 定时器延迟更新 |
useFetch | 存 data/loading/error | 发请求 + 竞态处理 |
useWindowSize | 存宽高 | 监听 resize 事件 |
每个 Hook 都是 useState + useEffect 的不同组合。理解了这两个基础 Hook,自定义 Hook 就是搭积木。
useRef 也经常出现在自定义 Hook 中——凡是”需要跨渲染持久但不触发渲染”的值(定时器 ID、上一次的值、是否首次渲染),都用 useRef 存。
一句话
自定义 Hook 是
useState+useEffect的组合包——把有状态的逻辑封装成以use开头的函数,让组件间共享逻辑不再复制粘贴。记住三件事:必须以 use 开头、一个 Hook 做一件事、至少两个组件需要才值得抽。