自定义 Hooks 🚀
在 React 的世界里,Hooks 就像一把神奇的钥匙,为函数组件打开了状态管理和生命周期的大门。今天我们就来深入探索自定义 Hooks 的奥秘,看看它如何让我们的代码更优雅、更可复用!
一、hooks 详解 🧐
1. 什么是 hooks ?
Hooks 是 React 16.8 引入的新特性,它是一种函数编程思想的实践,允许我们在不编写类组件的情况下,在函数组件中使用状态(State)和其他 React 特性(如生命周期)。简单来说,Hooks 就是 "钩子",能让我们轻松 "钩入" React 的内部特性,让函数组件拥有更强大的能力。
2. hooks 分类
Hooks 主要分为两类:
- React 内置 Hooks :如
useState(管理状态)、useEffect(处理副作用)、useContext(共享上下文)等,这些是 React 官方提供的基础工具。 - 自定义 Hooks :由开发者根据业务需求封装的 Hooks,命名以
use开头,本质是对内置 Hooks 和业务逻辑的组合封装,方便复用。
(前面我们讲解过 React 内置的 hooks 函数,感兴趣的小伙伴可以去翻翻我前面的文章看看)
3. hooks 有什么作用?
- 让函数组件具备状态管理能力,摆脱类组件的繁琐语法(如
this绑定、生命周期函数嵌套)。 - 将组件中的相关逻辑聚合在一起,而非分散在不同的生命周期函数中(比如类组件中
componentDidMount和componentDidUpdate可能写重复逻辑)。 - 实现逻辑复用,通过自定义 Hooks 将相同的状态逻辑抽离,供多个组件使用。
4. 为什么需要自定义 hooks ?
在开发中,我们经常会遇到多个组件需要共享相同状态逻辑的场景。比如:多个组件都需要监听鼠标位置、都需要处理本地存储数据、都需要发起相同的 API 请求等。
如果没有自定义 Hooks,我们可能会通过 "复制粘贴代码" 或 "高阶组件""render props" 等方式复用逻辑,但这些方式要么导致代码冗余,要么增加组件层级复杂度。
而自定义 Hooks 就像一个 "逻辑容器",能将这些重复逻辑抽离成独立函数,让组件只关注 UI 渲染,极大提升代码的复用性和可维护性!
二、先来看一个简单案例:响应式显示鼠标的位置 🖱️
1. 需求分析
我们要实现一个包含两个核心功能的小应用:
(1)计数功能:一个计数器,点击按钮可以增加数字。
(2)条件显示鼠标位置:当计数器的值为偶数时,显示鼠标在页面上的实时坐标;为奇数时,不显示。
2. 核心实现(两个文件)
(1)App2.jsx:主组件,负责管理计数器状态和根据计数奇偶性条件渲染鼠标位置组件。
(2)useMouse.js:自定义 Hooks,封装鼠标位置监听的逻辑,提供x、y坐标供组件使用(向外暴露的状态和方法放在 return 中返回)。
3. 代码展示
(1)App2.jsx
jsx
javascript
import { useState } from 'react';
import { useMouse } from './hooks/useMouse.js';
// 鼠标位置展示组件(纯UI组件)
function MouseMove() {
// 调用自定义Hook获取鼠标坐标
const { x, y } = useMouse();
return (
<>
<div>
鼠标位置:{x}, {y}
</div>
</>
);
}
export default function App() {
// 定义计数器状态,初始值为0
const [count, setCount] = useState(0);
return (
<>
{/* 显示当前计数 */}
{count}
{/* 点击按钮增加计数(使用函数式更新确保获取最新count) */}
<button onClick={() => setCount((count) => count + 1)}>
点击增加
</button>
{/* 当count为偶数时,渲染MouseMove组件显示鼠标位置 */}
{count % 2 === 0 && <MouseMove />}
</>
)
}
代码逻辑详解:
App组件通过useState管理计数器count的状态,点击按钮时通过setCount更新值。- 定义了
MouseMove组件,它不包含任何业务逻辑,仅通过调用useMouse()获取鼠标坐标并渲染,是一个纯 UI 组件。 - 通过条件渲染
{count % 2 === 0 && <MouseMove />}实现 "偶数显示鼠标位置,奇数不显示" 的需求。
(2)useMouse.js
js
javascript
import { useState, useEffect } from 'react';
// 自定义Hook:封装鼠标位置监听逻辑(命名以use开头)
export function useMouse() {
// 定义状态存储鼠标x、y坐标,初始值为0
const [x, setX] = useState(0);
const [y, setY] = useState(0);
// 副作用:监听鼠标移动事件
useEffect(() => {
// 事件处理函数:更新x、y坐标
const update = (event) => {
console.log('/////');
setX(event.pageX); // 更新x坐标为鼠标相对于文档的水平位置
setY(event.pageY); // 更新y坐标为鼠标相对于文档的垂直位置
}
// 绑定mousemove事件,触发时调用update更新坐标
window.addEventListener('mousemove', update);
console.log('||||| 挂载'); // 组件挂载时打印
// 清理函数:组件卸载时移除事件监听,避免内存泄漏
return () => {
window.removeEventListener('mousemove', update); // 移除相同的事件处理函数
console.log('===== 清除'); // 清理时打印
}
}, []); // 空依赖数组:仅在组件挂载时执行一次,卸载时执行清理
// 返回鼠标坐标,供组件使用
return {
x,
y,
}
}
代码逻辑详解:
-
useMouse遵循命名规范(以use开头),内部可以调用内置 Hooks(useState、useEffect)。 -
用
useState定义x和y状态,分别存储鼠标的水平和垂直坐标。 -
用
useEffect处理鼠标监听的副作用:- 挂载时:绑定
mousemove事件,鼠标移动时触发update函数更新x、y。 - 卸载时:通过
useEffect的返回函数移除mousemove事件监听,避免组件已卸载但事件仍触发的内存泄漏(比如当count为奇数时,MouseMove组件卸载,此时会执行清理函数)。
- 挂载时:绑定
-
最后返回包含
x和y的对象,让组件可以获取鼠标坐标。

4. 效果展示:
- 当点击按钮使
count为 0、2、4 等偶数时,页面会显示 "鼠标位置:x, y",移动鼠标时坐标会实时更新,控制台打印 "||||| 挂载"。 - 当点击按钮使
count为 0、2、4 等偶数时,移动鼠标,控制台打印"/////" - 当
count为 1、3、5 等奇数时,鼠标位置不显示,控制台打印 "===== 清除"且不打印"/////"(表示事件监听已移除)。

三、详解自定义 hooks 📚
通过上面的简单案例,我们可以总结出关于自定义 Hooks 的必备知识:
1. 什么时候需要自定义 hooks ?
(1)逻辑复用场景 当多个组件需要共享相同的状态逻辑时,通过自定义 Hook 封装可避免代码重复。上面案例中,useMouse封装了鼠标位置监听的完整逻辑(状态管理、事件绑定 / 解绑),使得MouseMove组件无需重复编写该逻辑。若其他组件(如 "鼠标轨迹绘制组件")也需要获取鼠标位置,可直接复用useMouse。
(2)分离 UI 与业务逻辑 当组件中同时包含 UI 渲染和复杂业务逻辑(如事件监听、数据处理)时,自定义 Hooks 可将业务逻辑抽离,让组件只专注于 UI 展示。上面案例中,MouseMove组件仅负责渲染鼠标坐标(纯 UI 逻辑),而鼠标监听的业务逻辑被封装在useMouse中,使组件代码更简洁易维护。
(3)抽象复杂副作用逻辑 当逻辑涉及useState、useEffect等 Hook 的组合使用(如状态管理 + 副作用处理)时,自定义 Hook 可将其抽象为独立单元,提高可读性。上面案例中useMouse整合了useState(管理 x、y 坐标)和useEffect(鼠标事件监听 / 清理),将分散的逻辑聚合为可复用的单元。
2. 如何自定义 hooks?
(1)遵循命名规范 函数名必须以use开头(如useMouse、useTodos),这是 React 的强制约定,确保 React 能识别 Hook 的调用规则(如只能在组件或其他 Hook 中使用)。
(2)封装核心逻辑 在函数内部可调用其他 Hook(如useState、useEffect),并通过return暴露需要的状态或方法。案例中useMouse的实现步骤:
- 用
useState定义x和y状态,存储鼠标坐标; - 用
useEffect绑定mousemove事件,实时更新坐标; - 通过
return { x, y }将坐标暴露给组件使用。
(3)设计返回值 根据需求返回状态、方法或对象,方便组件灵活使用。案例中返回包含x和y的对象,组件通过const { x, y } = useMouse()直接获取坐标;若逻辑复杂(如待办事项管理),可返回状态和操作方法的集合(如{ todos, addTodo, deleteTodo })。
3. 自定义 hooks 有什么注意事项?
(1)严格遵循 Hook 调用规则
- 只能在组件函数或其他自定义 Hook 中调用(上面案例中
useMouse在MouseMove组件中调用,符合规则); - 不能在循环、条件判断或普通函数中调用(避免 React 无法保证 Hook 调用顺序的一致性)。
(2)清理副作用,避免内存泄漏 若 Hook 包含副作用(如事件监听、定时器、API 请求),必须在useEffect的清理函数中移除副作用。上面案例中,useMouse在useEffect的返回函数中调用removeEventListener,确保MouseMove组件卸载时(count为奇数时)移除鼠标监听,避免组件已卸载但事件仍触发的内存泄漏(控制台会打印 "===== 清除" 验证)。
(3)保持单一职责 一个自定义 Hook 应专注于解决一个特定问题。上面案例中useMouse仅负责鼠标位置监听,职责单一;若强行加入其他逻辑(如键盘监听)会导致 Hook 臃肿,降低复用性。
四、复杂案例实战:待办事项(Todo List)应用 📝
1. 需求分析:
本案例是一个基于 React 的待办事项应用,核心需求围绕待办事项的全生命周期管理,具体包括:
- 输入框输入待办内容,点击添加按钮创建新待办;
- 勾选复选框标记待办事项为 "已完成" 或 "未完成";
- 点击删除按钮移除特定待办事项;
- 页面刷新后,已添加的待办数据不丢失(本地持久化);
- 无待办事项时显示空状态提示。
核心痛点:如何将数据管理逻辑与 UI 渲染逻辑分离,实现代码复用和维护性提升 ------ 这正是自定义 Hook 的核心应用场景。
2. 实现架构设计:
(1)整体架构采用 "UI 组件 + 自定义 Hook" 的分离模式:
- UI 组件:负责渲染界面和处理用户交互(输入、点击等);
- 自定义 Hooks(
useTodos):封装数据状态、业务逻辑和持久化操作。
(2)核心设计:自定义 hooks 的职责 useTodos作为数据和逻辑的 "中央处理器",承担以下职责:
- 管理待办事项的响应式状态(
todos数组); - 提供操作待办的方法(添加、切换状态、删除);
- 实现数据本地持久化(
localStorage读写)。
3. 代码展示(核心代码)
(1)自定义 Hook:useTodos.js
js
javascript
// 封装响应式的todos业务逻辑
import { useState, useEffect } from 'react';
// 定义localStorage的键名,用于存储待办数据
const STORAGE_KEY = 'todos';
// 从localStorage加载待办数据
function loadFromStorge() {
const StoredTodos = localStorage.getItem(STORAGE_KEY);
// 若有数据则解析为JSON,否则返回空数组
return StoredTodos ? JSON.parse(StoredTodos) : [];
}
// 将待办数据保存到localStorage
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
export const useTodos = () => {
// 初始化todos状态:从localStorage加载(useState接收函数,确保只执行一次)
const [todos, setTodos] = useState(loadFromStorge);
// 监听todos变化,实时保存到localStorage(持久化)
useEffect(() => {
saveToStorage(todos);
}, [todos]); // 依赖todos:只有todos变化时才执行
// 添加待办事项
const addTodo = (text) => {
setTodos([
...todos, // 复制现有待办
{
id: Date.now(), // 用时间戳作为唯一ID
text, // 待办内容
completed: false // 初始状态为未完成
}
]);
}
// 切换待办事项的完成状态
const toggleTodo = (id) => {
setTodos(
todos.map((todo) => {
// 找到目标待办,反转completed状态
if (todo.id === id) {
return { ...todo, completed: !todo.completed };
}
return todo; // 非目标待办不变
})
);
}
// 删除待办事项
const deleteTodo = (id) => {
setTodos(
todos.filter((todo) => todo.id !== id) // 过滤掉要删除的待办
);
}
// 暴露状态和方法给组件使用
return {
todos,
addTodo,
toggleTodo,
deleteTodo,
}
}
逻辑详解:
loadFromStorge和saveToStorage:封装localStorage的读写逻辑,实现数据持久化(页面刷新后数据不丢失)。useTodos内部用useState管理todos状态,初始化时从本地存储加载数据。- 用
useEffect监听todos变化,每次变化都调用saveToStorage保存到本地,确保数据实时同步。 - 提供
addTodo(添加)、toggleTodo(切换状态)、deleteTodo(删除)三个方法,通过setTodos更新状态,遵循 "不可变数据" 原则(用扩展运算符、map、filter创建新数组)。 - 最后返回
todos状态和操作方法,供 UI 组件使用。
(2)UI 组件:App.jsx(主组件)
jsx
javascript
import { useState } from 'react';
import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';
export default function App() {
// 调用自定义Hook获取待办数据和操作方法
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
return (
<>
{/* 输入组件:传递addTodo方法用于添加待办 */}
<TodoInput addTodo={addTodo} />
{/* 条件渲染:有待办时显示列表,否则显示空状态 */}
{
todos.length > 0 ?
<TodoList
todos={todos}
deleteTodo={deleteTodo}
toggleTodo={toggleTodo}
/> :
(<div>暂无待办事项</div>)
}
</>
)
}
逻辑详解:
App组件作为入口,通过useTodos()获取待办数据(todos)和操作方法(addTodo等)。- 渲染
TodoInput组件(负责输入待办)并传递addTodo方法,让输入组件能触发添加操作。 - 渲染
TodoList组件(负责展示待办列表)并传递todos、deleteTodo、toggleTodo,让列表组件能展示数据和处理删除 / 切换操作。 - 通过条件渲染实现 "无待办时显示空提示" 的需求。
(3)UI 组件:TodoInput.jsx(输入组件)
jsx
javascript
import { useState } from 'react';
export default function TodoInput({ addTodo }) {
// 管理输入框文本状态(受控组件)
const [text, setText] = useState('');
// 输入框变化时更新text状态
const handleChange = (e) => {
setText(e.target.value);
}
// 表单提交时添加待办
const handleSubmit = (e) => {
e.preventDefault(); // 阻止表单默认提交行为
if (text.trim() === '') { // 过滤空输入
return;
}
addTodo(text); // 调用父组件传递的addTodo方法添加待办
setText(''); // 清空输入框
}
return (
<form className='todo-input' onSubmit={handleSubmit}>
{/* 受控组件:value绑定text,变化触发handleChange */}
<input type='text' value={text} onChange={handleChange} />
<button type='submit'>添加</button>
</form>
)
}
逻辑详解:
- 纯 UI 组件,仅负责输入框的状态管理和提交逻辑,不关心数据如何存储或处理。
- 通过
useState管理输入框的text状态,实现 "受控组件"(输入框值由 React 状态控制)。 - 表单提交时调用
addTodo(从App组件传递的方法)添加待办,并清空输入框。
(4)UI 组件:TodoList.jsx(列表组件)
jsx
javascript
import TodoItem from './TodoItem.jsx';
export default function TodoList({ todos, deleteTodo, toggleTodo }) {
return (
<ul className="todo-list">
{/* 遍历todos数组,为每个待办渲染TodoItem组件 */}
{todos.map((todo) => (
<TodoItem
key={todo.id} // 唯一key
todo={todo} // 传递单个待办数据
deleteTodo={deleteTodo} // 传递删除方法
toggleTodo={toggleTodo} // 传递切换状态方法
/>
))}
</ul>
)
}
逻辑详解:
- 接收
todos数组,通过map遍历渲染每个待办项(TodoItem)。 - 仅负责列表渲染,将单个待办的数据和操作方法传递给
TodoItem,自身不处理业务逻辑。
(5)UI 组件:TodoItem.jsx(单个待办项)
jsx
javascript
export default function TodoItem({ todo, deleteTodo, toggleTodo }) {
return (
<li className="todo-item">
{/* 复选框:状态绑定todo.completed,点击触发toggleTodo */}
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{/* 待办文本:已完成时添加completed类(可用于样式区分) */}
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
{/* 删除按钮:点击触发deleteTodo */}
<button onClick={() => deleteTodo(todo.id)}>删除</button>
</li>
)
}
逻辑详解:
- 接收单个待办数据(
todo)和操作方法,渲染复选框、文本和删除按钮。 - 复选框状态与
todo.completed绑定,点击时调用toggleTodo切换状态。 - 文本根据
completed状态添加样式类(如划线效果),删除按钮点击时调用deleteTodo删除当前待办。

4. 效果展示:
- 输入框输入内容,点击 "添加" 按钮,待办列表会新增一项。
- 勾选复选框,待办文本会显示为 "已完成" 样式。
- 点击 "删除" 按钮,对应待办项会从列表中移除。
- 刷新页面后,所有待办数据依然存在(本地存储生效)。
- 当列表为空时,页面显示 "暂无待办事项"。

5. 案例总结:
- 逻辑复用最大化 :
useTodos封装了待办事项的所有核心逻辑(状态管理、CRUD 操作、本地持久化),若其他组件(如 "待办统计组件")需要使用待办数据,可直接调用useTodos,无需重复编写逻辑。 - UI 与业务彻底分离 :所有 UI 组件(
TodoInput、TodoList等)仅负责渲染和传递交互,不包含任何数据处理逻辑,代码结构清晰,维护成本低。 - 副作用集中管理 :本地存储的读写逻辑被封装在
useTodos的useEffect中,避免副作用分散在多个组件中,便于统一维护。
五、面试官会问 🤔
- 自定义 Hook 和普通函数有什么区别? 自定义 Hook 以
use开头,内部可以调用其他 Hook(如useState、useEffect),且必须遵循 Hook 调用规则(只能在组件或其他 Hook 中调用);普通函数不能调用 Hook,也没有命名限制。 - 为什么自定义 Hook 必须以 use 开头? 这是 React 的约定,确保 React 能通过命名识别 Hook,从而验证 Hook 的调用规则(如避免在条件语句中调用),防止出现逻辑错误。
- 如何避免自定义 Hook 中的内存泄漏? 若 Hook 包含副作用(如事件监听、定时器),必须在
useEffect的清理函数中移除副作用(如removeEventListener、clearTimeout),确保组件卸载时副作用被清除。 - 自定义 Hook 如何实现状态隔离? 每个组件调用自定义 Hook 时,React 都会为其创建独立的状态实例,不同组件之间的状态互不干扰(如两个组件调用
useMouse,会分别维护自己的x、y状态)。 - 什么时候应该抽离自定义 Hook? 当多个组件需要共享相同的状态逻辑,或组件中业务逻辑过于复杂(导致 UI 与逻辑混杂)时,就应该抽离为自定义 Hook。
六、结语 🌟
自定义 Hooks 是 React 中 "逻辑复用" 的最佳实践,它让我们的代码从 "重复冗余" 走向 "简洁高效",从 "UI 与逻辑混杂" 走向 "职责清晰分离"。
通过本文的两个案例(鼠标位置监听和待办事项应用),我们可以看到:一个设计良好的自定义 Hook,就像一个 "功能模块",能让组件专注于 UI 渲染,让逻辑专注于业务处理。
希望大家在实际开发中多思考、多实践,将自定义 Hooks 运用到项目中,让代码更优雅、更可维护!🎉