React(十六)自定义 Hooks 完全指南
目录
- 为什么需要自定义 Hooks?
- 自定义 Hooks 的定义规范
- 基础使用实战:三个递进式示例
- 3.1
useToggle:开关状态切换 - 3.2
useLocalStorageState:持久化存储状态 - 3.3
useFetch:通用数据请求
- 3.1
- React 19 专属注意事项
- 通用核心注意事项
- 常见误区解析:10 个高频问题
- 课堂练习
- 总结
1. 为什么需要自定义 Hooks?
1.1 生活中类比:通用充电头
想象你买了一部手机、一部平板、一个蓝牙耳机。它们都需要充电,但每款的充电线接口可能不同。于是厂家发明了通用充电头:Type-C、Lightning、Micro-USB 都能插,你不需要为每个设备单独设计一套充电逻辑。
在 React 里,自定义 Hooks 就像这个通用充电头。
每个组件都可能需要:
- 管理开关状态(显示/隐藏弹窗、展开/折叠菜单);
- 把状态保存到
localStorage; - 发起接口请求并处理 loading 和 error;
- 监听窗口大小变化;
- 监听鼠标位置、键盘事件。
如果每个组件都自己写一遍,代码会大量重复。自定义 Hooks 就是把这些通用的状态逻辑抽出来,做成可以复用的函数。
1.2 Class 组件时代的逻辑复用痛点
在 React 16 之前,Class 组件复用状态逻辑主要有三种方式:
| 方案 | 优点 | 缺点 |
|---|---|---|
| mixin | 简单直接 | 命名冲突、来源不清晰、难以维护 |
| 高阶组件(HOC) | 可以包裹组件 | 嵌套地狱、props 名称冲突、调试困难 |
| render props | 灵活性高 | 代码嵌套层数多、可读性差 |
这些方案都有同一个问题:逻辑复用不是真正地把逻辑抽出来,而是在组件结构上层层包装。
1.3 自定义 Hooks 是 React 19 推荐的标准方案
React Hooks 出现之后,React 官方推荐用自定义 Hooks来复用状态逻辑:
- 它是真正抽离逻辑,不改变组件结构;
- 它是函数式、可组合的,多个 Hook 可以像搭积木一样组合;
- 它是React 19 官方认可的唯一标准复用方案。
一句话:自定义 Hooks 让你把"组件里重复的状态逻辑"变成一个可复用的函数,谁想用就调用它。
2. 自定义 Hooks 的定义规范
2.1 命名规则:必须以 use 开头
React 19 对自定义 Hooks 的命名有强制要求:
- 必须以
use开头; - 后面接大驼峰命名的功能名;
- React 会凭借这个前缀识别它是不是 Hook,从而启用相关规则检查。
合法命名示例
useToggle;
useLocalStorageState;
useFetch;
useWindowResize;
useMousePosition;
非法命名示例
| 命名 | 问题 |
|---|---|
localStorageState |
缺少 use 前缀,React 不会识别为 Hook |
useLocalstorageState |
第二个单词首字母小写,不符合大驼峰规范 |
use_local_storage |
使用下划线,不符合 JavaScript 命名习惯 |
useToggle |
✅ 正确 |
如果你的自定义 Hook 不叫
use开头,React 的 ESLint 插件不会把它当作 Hook 检查,Hook 调用规则(比如不能放在 if 里)就可能失效。
2.2 核心定义原则:只能在 Hook 或组件里调用其他 Hook
自定义 Hook 内部可以调用:
- React 原生 Hook(如
useState、useEffect、useContext、useRef、useMemo、useCallback); - React 19 新 Hook(如
use、useTransition、useActionState、useOptimistic); - 其他自定义 Hook。
禁止在普通函数、类组件、事件处理函数、循环、条件判断中调用 Hook。
2.3 为什么调用顺序必须固定?
React 内部维护了一个调用顺序链表。每次组件渲染时,React 会按顺序记录每个 Hook 的状态。
如果你在 if 或 for 里调用 Hook,React 就无法保证每次渲染的 Hook 顺序一致,状态就会错位。
2.4 标准语法结构模板
// 自定义 Hook 标准模板
import { useState, useEffect } from "react";
// 1. 函数名以 use 开头,后面大驼峰
function useCustomHook(initialValue) {
// 2. 内部可以调用其他 Hook
const [state, setState] = useState(initialValue);
useEffect(() => {
// 副作用逻辑
}, []);
// 3. 返回组件需要的状态、函数、计算值
return [state, setState];
}
export default useCustomHook;
和普通函数的本质区别
| 特性 | 普通函数 | 自定义 Hook |
|---|---|---|
| 能否调用 React Hook | ❌ 不能 | ✅ 能 |
| 命名要求 | 无强制要求 | 必须以 use 开头 |
| 调用位置限制 | 任意位置 | 只能在组件顶层或 Hook 顶层 |
| 是否有内部状态 | 没有 | 通过 Hook 拥有独立状态 |
3. 基础使用实战:三个递进式示例
3.1 useToggle:开关状态切换 Hook
场景
很多组件都需要一个布尔值开关:
- 弹窗的显示/隐藏;
- 菜单的展开/折叠;
- 主题的深色/浅色切换。
自定义 Hook 代码
import { useState, useCallback } from "react";
/**
* useToggle:通用的布尔值开关 Hook
* @param {boolean} defaultValue - 默认值,默认为 false
* @returns {[boolean, { toggle, setTrue, setFalse }]} - 当前状态 + 操作函数
*/
function useToggle(defaultValue = false) {
// 1. 内部使用 useState 管理布尔值
const [value, setValue] = useState(defaultValue);
// 2. 用 useCallback 缓存函数,避免每次渲染都创建新函数
const toggle = useCallback(() => {
setValue((prev) => !prev);
}, []);
const setTrue = useCallback(() => {
setValue(true);
}, []);
const setFalse = useCallback(() => {
setValue(false);
}, []);
// 3. 返回状态和操作函数
return [value, { toggle, setTrue, setFalse }];
}
export default useToggle;
在两个组件中复用
import useToggle from "./useToggle";
function ModalDemo() {
// 组件 A 使用 useToggle,拥有自己的状态
const [isVisible, { toggle, setTrue, setFalse }] = useToggle(false);
return (
<div>
<button onClick={setTrue}>打开弹窗</button>
{isVisible && (
<div className="modal">
<p>这是一个弹窗</p>
<button onClick={setFalse}>关闭</button>
<button onClick={toggle}>切换</button>
</div>
)}
</div>
);
}
function MenuDemo() {
// 组件 B 也使用 useToggle,状态完全独立
const [isOpen, { toggle }] = useToggle(true);
return (
<div>
<button onClick={toggle}>{isOpen ? "折叠菜单" : "展开菜单"}</button>
{isOpen && (
<ul>
<li>菜单项</li>
</ul>
)}
</div>
);
}
function App() {
return (
<div>
<ModalDemo />
<MenuDemo />
</div>
);
}
export default App;
代码运行效果
- 点击"打开弹窗",弹窗显示;点击"关闭",弹窗消失。
- 点击"折叠菜单",菜单收起;再次点击,菜单展开。
- 两个组件互不影响,各自管理自己的开关状态。
抽离前后代码量对比
| 方式 | 每个组件需要写的代码 |
|---|---|
| 不抽离 | useState(false) + 自己写 toggle / setTrue / setFalse |
| 抽离成 useToggle | 一行 useToggle(false) 即可 |
3.2 useLocalStorageState:持久化存储状态 Hook
场景
用户偏好设置(比如主题、语言、字号)需要在页面刷新后依然保留。localStorage 是浏览器提供的本地存储方案,自定义 Hook 可以把它和 React 状态整合起来。
自定义 Hook 代码
import { useState, useEffect, useCallback } from "react";
/**
* useLocalStorageState:状态与 localStorage 双向同步的 Hook
* @param {string} key - 存储在 localStorage 中的键名
* @param {*} defaultValue - 默认值
* @returns {[any, Function]} - 当前值 + 设置值函数
*/
function useLocalStorageState(key, defaultValue) {
// 1. 初始化状态:尝试从 localStorage 读取,读取失败则使用默认值
const [value, setValue] = useState(() => {
try {
const storedValue = window.localStorage.getItem(key);
// 如果有存储值,解析 JSON;否则返回默认值
return storedValue ? JSON.parse(storedValue) : defaultValue;
} catch (error) {
// 捕获解析异常(比如存储值被破坏)
console.error(`读取 localStorage 失败,key: ${key}`, error);
return defaultValue;
}
});
// 2. 设置值时同步更新 localStorage
const setStoredValue = useCallback(
(newValue) => {
try {
// 如果传入的是函数,先执行函数拿到新值
const valueToStore =
newValue instanceof Function ? newValue(value) : newValue;
setValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`写入 localStorage 失败,key: ${key}`, error);
}
},
[key, value],
);
// 3. 监听其他标签页或脚本修改 localStorage 时同步更新状态
useEffect(() => {
const handleStorageChange = (event) => {
if (event.key === key) {
try {
setValue(event.newValue ? JSON.parse(event.newValue) : defaultValue);
} catch (error) {
console.error(`同步 localStorage 失败,key: ${key}`, error);
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key, defaultValue]);
return [value, setStoredValue];
}
export default useLocalStorageState;
在用户偏好设置组件中使用
import useLocalStorageState from "./useLocalStorageState";
function ThemeSettings() {
// 使用自定义 Hook,页面刷新后主题偏好自动恢复
const [theme, setTheme] = useLocalStorageState("theme", "light");
return (
<div
style={{
background: theme === "dark" ? "#333" : "#fff",
color: theme === "dark" ? "#fff" : "#333",
}}
>
<p>当前主题:{theme}</p>
<button onClick={() => setTheme("light")}>浅色</button>
<button onClick={() => setTheme("dark")}>深色</button>
</div>
);
}
function LanguageSettings() {
// 另一个组件也复用同一个 Hook,key 不同,互不干扰
const [language, setLanguage] = useLocalStorageState("language", "zh");
return (
<div>
<p>当前语言:{language}</p>
<button onClick={() => setLanguage("zh")}>中文</button>
<button onClick={() => setLanguage("en")}>英文</button>
</div>
);
}
function App() {
return (
<div>
<ThemeSettings />
<LanguageSettings />
</div>
);
}
export default App;
代码运行效果
- 选择"深色"主题,刷新页面后仍然是深色;
- 切换语言为英文,刷新页面后仍然是英文;
- 打开多个浏览器标签页,在一个标签页修改主题,另一个标签页也会同步(通过
storage事件)。
JSON 序列化异常处理说明
localStorage 只能存储字符串,所以复杂数据需要用 JSON.stringify 转换。但实际使用中可能遇到:
- 存储值被用户手动修改成非法 JSON;
- 数据本身包含循环引用,无法序列化。
上面的代码通过 try/catch 捕获这些异常,确保应用不会因为 localStorage 出错而崩溃。
3.3 useFetch:通用数据请求 Hook
场景
几乎每个页面都需要请求接口:
- 列表页获取数据列表;
- 详情页获取单条数据;
- 需要处理 loading、error、手动刷新。
自定义 Hook 代码(React 19 兼容)
import { useState, useEffect, useCallback, useTransition } from "react";
/**
* useFetch:通用数据请求 Hook
* @param {string} url - 请求地址
* @param {object} options - fetch 配置(method、headers、body 等)
* @returns {object} - { data, loading, error, refresh }
*/
function useFetch(url, options = {}) {
// 1. 定义状态
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// 2. React 19 的 useTransition:让 loading 过渡更平滑,避免阻塞 UI
const [isPending, startTransition] = useTransition();
// 3. 请求逻辑封装在 useCallback 中,方便复用和刷新
const fetchData = useCallback(
async (signal) => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, { ...options, signal });
if (!response.ok) {
throw new Error(`请求失败,状态码:${response.status}`);
}
const result = await response.json();
// 使用 startTransition 让状态更新有过渡效果
startTransition(() => {
setData(result);
});
} catch (err) {
// 如果是主动取消请求,不设置为错误
if (err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
},
[url, options],
);
// 4. 手动刷新函数
const refresh = useCallback(() => {
fetchData();
}, [fetchData]);
// 5. 组件挂载或 url/options 变化时发起请求
useEffect(() => {
// 创建 AbortController,用于取消请求
const controller = new AbortController();
fetchData(controller.signal);
// 组件卸载或依赖变化时取消请求,避免竞态问题
return () => {
controller.abort();
};
}, [fetchData]);
return {
data,
loading,
error,
refresh,
isPending, // React 19 过渡状态
};
}
export default useFetch;
在列表页和详情页中复用
import useFetch from './useFetch';
// 列表页
function UserList() {
const { data, loading, error, refresh } = useFetch(
'https://jsonplaceholder.typicode.com/users'
);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了:{error}</button>;
return (
<div>
<button onClick={refresh}>刷新列表</button>
<ul>
{data?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
// 详情页
function UserDetail({ userId }) {
const { data, loading, error } = useFetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了:{error}</p>;
return (
<div>
<h1>{data?.name}</h1>
<p>邮箱:{data?.email}</p>
</div>
);
}
function App() {
return (
<div>
<UserList />
<UserDetail userId={1} />
</div>
);
}
export default App;
代码运行效果
- 页面加载时自动请求数据;
- 请求中显示"加载中...";
- 请求失败显示错误信息;
- 点击"刷新列表"重新请求;
- 组件卸载或地址变化时,旧请求会被取消,避免竞态。
竞态问题说明
什么是竞态?
比如你快速切换两个用户详情,A 请求发出后,B 请求也发出。如果 A 响应慢、B 响应快,最后 A 的响应可能会覆盖 B 的结果,导致页面显示错误。
解决方案:使用 AbortController。
- 每次发起请求时创建新的
AbortController; - 组件卸载或依赖变化时,调用
controller.abort()取消旧请求; - 被取消的请求会抛出
AbortError,我们忽略这种错误。
4. React 19 专属注意事项
4.1 React 19 中自定义 Hook 的自动批处理
在 React 18 中,多个 setState 同时调用会被自动批处理,只触发一次渲染。React 19 进一步强化了这一点,在更多场景下(包括事件处理函数、setTimeout、Promise 等)都会自动批处理。
自定义 Hook 内部如果有多个
setState,你不用担心重复渲染,React 19 会自动帮你合并。
4.2 React 19 的 use 新 Hook 对自定义 Hook 的影响
React 19 引入了新的 use Hook,用于读取 Promise、Context 等值。在自定义 Hook 中,你仍然可以沿用 useState + useEffect 的方式,但也可以结合 use 来简化异步逻辑。
// React 19 新特性:用 use 读取 Promise
import { use } from "react";
function useUserData(promise) {
// use 会阻塞渲染,直到 Promise 完成
const data = use(promise);
return data;
}
注意:
use和await不同,它只能在组件或 Hook 中调用,且要求传入 Promise。
4.3 useTransition 在自定义 Hook 中的使用
React 19 中 useTransition 更稳定,适合在自定义 Hook 中处理非紧急状态更新:
import { useState, useTransition } from "react";
function useSlowData() {
const [data, setData] = useState(null);
const [isPending, startTransition] = useTransition();
const updateData = (newData) => {
startTransition(() => {
setData(newData);
});
};
return { data, isPending, updateData };
}
5. 通用核心注意事项
5.1 调用规则红线:只能在顶层调用
❌ 错误:在 if 中调用 Hook
function useWrongHook(value) {
if (value) {
// ❌ 错误:Hook 在条件中调用
const [state, setState] = useState(0);
}
return state;
}
❌ 错误:在循环中调用 Hook
function useWrongHook(items) {
// ❌ 错误:Hook 在循环中调用
for (let i = 0; i < items.length; i++) {
useEffect(() => {}, []);
}
}
✅ 正确:始终在最顶层调用
function useRightHook(value) {
// ✅ 正确:在 Hook 的最顶层调用
const [state, setState] = useState(0);
useEffect(() => {
if (value) {
setState(value);
}
}, [value]);
return state;
}
5.2 状态隔离原则:每个组件独立
不同组件调用同一个自定义 Hook 时,它们的 state 是完全独立的。
import useToggle from "./useToggle";
function ComponentA() {
const [on] = useToggle(false);
return <p>A: {on ? "开" : "关"}</p>;
}
function ComponentB() {
const [on] = useToggle(false);
return <p>B: {on ? "开" : "关"}</p>;
}
A 和 B 的状态互不影响。React 把每个 Hook 调用和具体组件实例绑定,不会因为用了同一个 Hook 就共享状态。
5.3 依赖项处理:必须完整
自定义 Hook 内部使用 useEffect / useMemo / useCallback 时,依赖项必须写完整。
❌ 错误:依赖项遗漏
function useCountMessage(count) {
useEffect(() => {
console.log("count:", count);
}, []); // ❌ 漏了 count
}
✅ 正确:补全依赖项
function useCountMessage(count) {
useEffect(() => {
console.log("count:", count);
}, [count]); // ✅ 依赖项完整
}
推荐安装
eslint-plugin-react-hooks并开启react-hooks/exhaustive-deps规则,自动检查依赖项是否遗漏。
5.4 命名规范严谨性
如果你写了一个函数不叫 use 开头,但内部调用了 Hook,React 不会报错,但 ESLint 不会按 Hook 规则检查它,容易导致错误。
// ❌ 不推荐:虽然能运行,但失去了 Hook 规则保护
function localStorageState(key) {
const [value, setValue] = useState(() => {
return localStorage.getItem(key);
});
return [value, setValue];
}
5.5 竞态问题处理:必须用 AbortController
数据请求类自定义 Hook 必须使用 AbortController 处理竞态。详细实现参考上面的 useFetch 示例。
5.6 TypeScript 类型安全
如果项目使用 TypeScript,自定义 Hook 应该加泛型约束。
useFetch 的 TypeScript 版本
import { useState, useEffect, useCallback } from "react";
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refresh: () => void;
}
function useFetch<T = any>(
url: string,
options?: RequestInit,
): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(
async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, { ...options, signal });
if (!response.ok) {
throw new Error(`请求失败:${response.status}`);
}
const result = (await response.json()) as T;
setData(result);
} catch (err: any) {
if (err.name !== "AbortError") {
setError(err.message);
}
} finally {
setLoading(false);
}
},
[url, options],
);
const refresh = useCallback(() => fetchData(), [fetchData]);
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => controller.abort();
}, [fetchData]);
return { data, loading, error, refresh };
}
export default useFetch;
使用方式:
interface User {
id: number;
name: string;
email: string;
}
const { data } = useFetch<User>("/api/user/1");
// data 的类型是 User | null
6. 常见误区解析:10 个高频问题
Q1:自定义 Hook 和普通函数到底有什么区别?
答:
- 自定义 Hook 可以调用 React 的 Hook;普通函数不能;
- 自定义 Hook 必须以
use开头;普通函数没有命名限制; - 自定义 Hook 内部有独立的状态(通过
useState等实现);普通函数没有。
Q2:能不能在自定义 Hook 里调用第三方库的 Hook?
答:可以。只要第三方库的 Hook 是合法的 React Hook(比如 useQuery from React Query),就可以在自定义 Hook 中调用。这也是常见的组合方式。
Q3:多个自定义 Hook 之间怎么传递状态?
答:一个自定义 Hook 可以调用另一个自定义 Hook。状态在每个调用它的组件中独立存在。如果要跨组件共享状态,需要结合 useContext 或全局状态管理库。
Q4:自定义 Hook 会让不同组件共享状态吗?
答:不会。每个组件调用自定义 Hook 时,React 都会为该组件创建独立的 Hook 状态。就像每个手机都插同一个充电头,但电池是各自独立的。
Q5:自定义 Hook 里面可以写 UI 吗?
答:不可以。自定义 Hook 只能返回数据、函数、计算结果,不能直接返回 JSX。UI 应该放在组件里。
Q6:自定义 Hook 的参数可以是什么?
答:任意类型。可以是初始值、配置对象、回调函数、Promise 等。但要确保参数在组件渲染过程中保持稳定,避免不必要的副作用执行。
Q7:为什么自定义 Hook 必须以 use 开头?
答:React 和 ESLint 通过 use 前缀识别一个函数是不是 Hook,从而启用 Hook 规则检查。如果不用 use 开头,React 不会报错,但 lint 不会检查调用位置是否正确。
Q8:自定义 Hook 内部可以调用 useEffect 吗?
答:可以,而且非常常见。但需要注意依赖项的完整性,以及副作用的清理函数(比如取消订阅、取消请求)。
Q9:自定义 Hook 有性能问题吗?
答:本身不会带来性能问题。但如果内部大量使用 useEffect 且依赖项设置不当,可能导致无限渲染或重复请求。需要合理使用 useMemo、useCallback。
Q10:React 19 和 React 18 的自定义 Hook 有什么主要区别?
答:自定义 Hook 的基本写法一致。React 19 增强了自动批处理、引入了 use 和 useActionState 等新 Hook,可以在自定义 Hook 中组合使用。
7. 课堂练习
练习 1:实现 useCounter 计数器 Hook
要求:
- 支持加、减、重置功能;
- 支持设置步长;
- 支持最小值和最大值限制。
参考答案核心框架
import { useState, useCallback } from "react";
function useCounter(initialValue = 0, options = {}) {
const { step = 1, min = -Infinity, max = Infinity } = options;
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => {
setCount((prev) => Math.min(prev + step, max));
}, [step, max]);
const decrement = useCallback(() => {
setCount((prev) => Math.max(prev - step, min));
}, [step, min]);
const reset = useCallback(() => {
setCount(initialValue);
}, [initialValue]);
return { count, increment, decrement, reset };
}
export default useCounter;
练习 2:实现 useWindowResize 监听窗口尺寸 Hook
要求:
- 监听浏览器窗口的
resize事件; - 返回当前窗口的宽度和高度;
- 组件卸载时自动移除事件监听。
参考答案核心框架
import { useState, useEffect } from "react";
function useWindowResize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
return size;
}
export default useWindowResize;
8. 总结
| 要点 | 内容 |
|---|---|
| 命名 | 必须以 use 开头,后接大驼峰功能名 |
| 调用位置 | 只能在组件或自定义 Hook 的最顶层调用 |
| 内部可调用 | React 原生 Hook、其他自定义 Hook、第三方 Hook |
| 状态隔离 | 不同组件调用同一个 Hook,状态相互独立 |
| 依赖项 | 使用 useEffect/useMemo/useCallback 时必须完整 |
| 竞态处理 | 数据请求类 Hook 使用 AbortController |
| React 19 | 自动批处理更强,可结合 use、useTransition 等新 Hook |
💡 核心原则:自定义 Hooks 是把组件中重复的状态逻辑抽出来,变成可复用的函数。它不改组件结构,只提供逻辑。用好自定义 Hooks,能让你的代码像搭积木一样清晰、可维护。