引言
“抽象不是忽略细节,而是把细节封装在合适的地方。”
—— 软件工程箴言
在现代 React 开发中,自定义 Hook(Custom Hook) 已成为逻辑复用、状态管理和副作用封装的核心范式。它不仅让组件更轻量、更专注 UI,还极大提升了代码的可维护性与可测试性。
本文将基于完整项目文件,逐行、逐字、逐注释地深度解析两个极具代表性的自定义 Hook 实现:
useMouse:一个监听全局鼠标移动并实时返回坐标的交互型 Hook;useTodos:一个管理待办事项列表、支持增删改查并自动持久化到localStorage的业务型 Hook。
我们将从底层原理出发,结合 React 的核心机制(如状态、副作用、不可变更新、惰性初始化等),带你彻底理解“如何写出专业级的自定义 Hook”。
第一部分:useMouse —— 让鼠标无所遁形的“追踪器”
目标
创建一个可复用的 Hook,能:
- 实时获取鼠标在页面中的坐标(
pageX,pageY); - 自动绑定/解绑事件监听器;
- 避免内存泄漏;
- 对外提供简洁 API。
文件路径:hooks/useMouse.js
我们先看完整代码:
import {
useState, // 用于声明鼠标坐标的 x、y 状态
useEffect // 用于添加和清理鼠标移动事件监听器
} from 'react';
// 封装响应式mouse业务
// UI 组件更简单 HTML + CSS 好维护
// 复用 和组件一样,是前度团队的核心资产
export const useMouse = () => {
const [x, setX] = useState(0); // 声明 x 坐标状态,初始为 0
const [y, setY] = useState(0); // 声明 y 坐标状态,初始为 0
// 在组件挂载时添加全局 mousemove 监听,在卸载时移除(避免内存泄漏)
useEffect(() => {
const update = (event) => { // 事件处理函数:接收原生 MouseEvent
setX(event.pageX); // 更新 x 为鼠标相对于整个页面的横坐标
setY(event.pageY); // 更新 y 为鼠标相对于整个页面的纵坐标
}
window.addEventListener('mousemove', update); // 注册事件监听器
console.log(' 挂载'); // 调试日志:表示监听器已添加
return () => { // 清理函数:在组件卸载时执行
console.log(' 清除'); // 调试日志:表示正在清理
window.removeEventListener('mousemove', update); // 移除事件监听器,防止内存泄漏
}
}, []) // 空依赖数组:仅在组件挂载/卸载时执行一次
return { x, y } // 返回当前鼠标坐标
}
⚠️ 注意:原始文件中没有换行和缩进,但为了可读性,我们在解析时会适当格式化(但保留所有原始注释和代码内容)。
逐行深度解析
第 1 行:导入核心 Hooks
import { useState, useEffect } from 'react';
useState:用于声明组件内部的状态变量。在这里,我们用它来存储鼠标的x和y坐标。useEffect:用于处理副作用(side effects),比如订阅事件、发送网络请求、手动操作 DOM 等。这里用来注册/注销mousemove事件监听器。
💡 为什么不用
useRef?
虽然useRef也可存坐标,但useState能触发组件 re-render,确保 UI 实时更新。而useRef不会触发更新,适合不需要渲染的场景。
第 2–4 行:函数声明与注释
// 封装响应式mouse业务
// UI 组件更简单 HTML + CSS 好维护
// 复用 和组件一样,是前度团队的核心资产
export const useMouse = () => {
- 函数名以
use开头,这是 React 的命名约定。React 会据此识别这是一个自定义 Hook,并启用相关规则(如只能在顶层调用)。 - 注释强调了设计哲学:将复杂逻辑封装,让 UI 组件只关注展示。
export使得其他模块可以导入使用。
第 5–6 行:声明坐标状态
const [x, setX] = useState(0);
const [y, setY] = useState(0);
-
初始化两个独立的状态:
x:鼠标横坐标,默认0;y:鼠标纵坐标,默认0。
-
每次调用
setX或setY,都会触发使用该 Hook 的组件重新渲染。
📌 技术细节:
useState返回一个数组[state, setState],通过解构赋值获取。这是 React 的标准模式。
第 7–18 行:使用 useEffect 管理事件监听
useEffect(() => {
const update = (event) => {
setX(event.pageX);
setY(event.pageY);
}
window.addEventListener('mousemove', update);
console.log(' 挂载');
return () => {
console.log(' 清除');
window.removeEventListener('mousemove', update);
}
}, [])
这是整个 Hook 的核心逻辑,我们拆解如下:
1. 定义事件处理器 update
const update = (event) => {
setX(event.pageX);
setY(event.pageY);
}
event是原生MouseEvent对象。event.pageX/event.pageY:返回鼠标相对于整个文档(包括滚动部分)的坐标,比clientX/Y更适合全局定位。- 每次鼠标移动,都会调用
setX和setY,触发状态更新 → 组件 re-render → UI 显示新坐标。
2. 添加事件监听器
window.addEventListener('mousemove', update);
console.log(' 挂载');
- 在
window上监听mousemove事件(而非某个 DOM 元素),确保全页面有效。 - 打印
' 挂载'(注意前面有空格),用于调试:确认监听器是否成功注册。
❗ 为什么是
window而不是document?
两者均可,但window是事件冒泡的最终目标,更稳定。且mousemove在window上监听无兼容性问题。
3. 返回清理函数(Cleanup Function)
return () => {
console.log(' 清除');
window.removeEventListener('mousemove', update);
}
- 这是防止内存泄漏的关键!
- 当组件卸载(unmount)时,React 会自动调用此函数。
removeEventListener必须传入同一个函数引用(即update),否则无法正确移除。- 打印
' 清除'用于验证清理是否发生。
🧠 React 如何知道要清理?
useEffect的返回值如果是函数,React 就将其视为 cleanup 函数,并在下次 effect 执行前(或组件卸载时)调用。
4. 依赖数组 []
}, [])
- 空数组表示:该 effect 仅在组件挂载时执行一次,卸载时清理一次。
- 如果不加依赖数组,每次 re-render 都会重复绑定事件,导致性能问题和多个监听器同时触发!
第 19 行:返回坐标对象
return { x, y }
- 返回一个包含当前坐标的对象。
- 使用者可通过解构获取:
const { x, y } = useMouse(); - 这是自定义 Hook 的标准输出方式:返回状态 + 操作方法(此处只有状态)。
使用示例
虽然被注释掉了,但原代码展示了如何使用:
function MouseMove() {
const { x, y } = useMouse();
return (
<>
<div> 鼠标位置:{x} {y} </div>
</>
)
}
// 条件渲染
{count % 2 === 0 && <MouseMove />}
MouseMove组件完全无状态,只负责展示。- 当组件挂载时,
useMouse自动开始监听;卸载时自动清理。 - 完美解耦!
第二部分:useTodos —— 构建一个“永不丢失”的待办事项系统
如果说 useMouse 是“工具型” Hook,那么 useTodos 就是“业务型” Hook 的典范——它封装了完整的状态管理、持久化逻辑和 CRUD 操作。
目标
实现一个待办事项管理器,支持:
- 添加新任务;
- 切换完成状态;
- 删除任务;
- 自动保存到
localStorage; - 页面刷新后恢复数据。
文件路径:hooks/useTodos.js
完整代码:
// 封装响应式todos业务
import {
useState, // 用于声明和更新 todos 状态
useEffect // 用于在 todos 变化时同步到 localStorage
} from 'react';
const STORAGE_KEY = 'todos'; // 定义 localStorage 中存储数据的键名,便于统一维护
// 从 localStorage 加载 todos 数据的工具函数
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY); // 读取 localStorage 中的字符串
return storedTodos ? JSON.parse(storedTodos) : []; // 若存在则解析为数组,否则返回空数组
}
// 将 todos 数据保存到 localStorage 的工具函数
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); // 序列化 todos 并存入 localStorage
}
// 自定义 Hook:封装 todos 的完整状态逻辑
export const useTodos = () => {
// 使用惰性初始化:只在组件首次渲染时调用 loadFromStorage
const [todos, setTodos] = useState(loadFromStorage);
// 当 todos 状态变化时,自动将其持久化到 localStorage
useEffect(() => {
saveToStorage(todos);
}, [todos]) // 依赖项为 todos,确保每次 todos 更新都触发保存
// 添加新待办事项
const addTodo = (text) => {
setTodos(
[
...todos, // 保留原有 todos
{
id: Date.now(), // 使用时间戳作为简易唯一 ID(注意:高频率添加可能冲突)
text, // 待办文本
completed: false // 初始状态为未完成
}
]
)
}
// 切换指定待办事项的完成状态
const toggleTodo = (id) => {
setTodos(
todos.map(todo => { // 遍历所有 todos
if (todo.id === id) { // 找到匹配 id 的项
return {
...todo, // 保留其他属性
completed: !todo.completed // 反转 completed 状态
}
}
return todo; // 其他项不变
})
)
}
// 删除指定 id 的待办事项
const deleteTodo = (id) => {
setTodos(
todos.filter(todo => todo.id !== id) // 过滤掉指定 id 的项,返回新数组
)
}
// 返回状态和操作方法,供组件使用
return {
todos, // 当前 todos 列表(响应式)
addTodo, // 添加函数
toggleTodo, // 切换完成状态函数
deleteTodo // 删除函数
}
}
同样,我们进行逐行深度解析。
逐行深度解析
第 1 行:注释与导入
// 封装响应式todos业务
import { useState, useEffect } from 'react';
- 注释点明目的:封装“响应式” todos 业务(即状态变化自动触发 UI 更新)。
- 导入
useState(管理状态)和useEffect(持久化数据)。
第 2 行:定义存储键名
const STORAGE_KEY = 'todos';
- 将 localStorage 的 key 提取为常量,避免魔法字符串(magic string),提升可维护性。
- 若将来要改名,只需改这一处。
第 3–6 行:加载数据函数
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
localStorage.getItem(key)返回字符串,若不存在则返回null。- 使用三元运算符:若存在则
JSON.parse成数组,否则返回空数组[]。 - 注意:未处理
JSON.parse异常(如存储的数据被篡改)。生产环境应加try/catch。
第 7–9 行:保存数据函数
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
JSON.stringify将数组序列化为字符串,存入 localStorage。- 简洁高效,但同样缺乏错误处理。
第 10 行:Hook 主体声明
export const useTodos = () => {
- 标准自定义 Hook 声明。
第 11 行:惰性初始化状态
const [todos, setTodos] = useState(loadFromStorage);
- 关键技巧:惰性初始化(Lazy Initialization)
useState接收一个函数时,React 只在首次渲染时调用它。- 避免每次 re-render 都读取 localStorage(I/O 操作较慢)。
- 对比:
useState(loadFromStorage())会在每次 render 时调用函数,效率低下。
第 12–14 行:自动持久化
useEffect(() => {
saveToStorage(todos);
}, [todos])
- 依赖数组为
[todos],表示:只要todos引用发生变化,就执行 effect。 setTodos总是返回新数组(不可变更新),所以每次操作都会触发保存。- 实现“状态即持久化”的无缝体验。
💡 为什么不用防抖?
对于待办事项这种低频操作,直接保存即可。高频场景(如搜索框)才需防抖。
第 15–25 行:添加待办事项
const addTodo = (text) => {
setTodos([
...todos,
{
id: Date.now(),
text,
completed: false
}
])
}
-
使用展开运算符
...todos保留原数组。 -
新 todo 对象包含:
id: Date.now():时间戳作为 ID(⚠️ 缺陷:1ms 内多次点击会冲突);text:用户输入内容;completed: false:初始未完成。
-
setTodos触发状态更新 → 组件 re-render → 自动保存到 localStorage。
🛠️ 改进建议:
使用crypto.randomUUID()(现代浏览器)或nanoid库生成真正唯一的 ID。
第 26–38 行:切换完成状态
const toggleTodo = (id) => {
setTodos(
todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed }
}
return todo;
})
)
}
- 遍历
todos数组,找到id匹配的项。 - 使用
...todo保留原对象所有属性,仅翻转completed字段。 - 严格遵守不可变更新原则:不修改原数组,而是返回新数组。
📌 为什么不用
for循环?
map是函数式编程风格,更安全、更易测试,且天然返回新数组。
第 39–42 行:删除待办事项
const deleteTodo = (id) => {
setTodos(
todos.filter(todo => todo.id !== id)
)
}
filter返回一个新数组,排除id匹配的项。- 同样符合不可变更新。
第 43–48 行:返回 API
return {
todos,
addTodo,
toggleTodo,
deleteTodo
}
-
返回一个对象,包含:
- 状态:
todos(当前列表); - 操作方法:
addTodo,toggleTodo,deleteTodo。
- 状态:
-
使用者可解构获取所需功能。
组件如何消费?—— 全链路解析
1. App.jsx:主入口
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
return (
<>
<TodoInput onAddTodo={addTodo} />
{todos.length > 0 ? (
<TodoList onDelete={deleteTodo} onToggle={toggleTodo} todos={todos} />
) : (
<div>暂无待办事项</div>
)}
</>
)
- 主组件无任何业务逻辑,只做组合。
TodoInput负责输入,TodoList负责展示。
2. TodoInput.jsx:输入组件
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
onAddTodo(text.trim());
setText("");
}
- 阻止表单默认提交(防止刷新);
- 过滤空输入;
- 调用
onAddTodo(即useTodos的addTodo); - 清空输入框。
3. TodoList.jsx:列表容器
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
- 使用
todo.id作为key,确保列表高效更新。 - 将
todo对象和回调函数传递给子项。
4. TodoItem.jsx:单个待办项
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>删除</button>
- 复选框状态由
todo.completed控制; - 点击触发
onToggle(id); - 删除按钮触发
onDelete(id); - 完成状态通过 CSS 类名控制样式(如划线)。
总结:自定义 Hook 的设计哲学
通过这两个例子,我们可以提炼出自定义 Hook 的黄金法则:
| 原则 | useMouse | useTodos |
|---|---|---|
| 单一职责 | 只管鼠标坐标 | 只管待办事项 |
| 状态封装 | x, y | todos |
| 副作用隔离 | 事件监听/清理 | localStorage 同步 |
| 不可变更新 | N/A | 所有操作返回新数组 |
| 惰性初始化 | N/A | useState(loadFromStorage) |
| 清晰 API | { x, y } | { todos, addTodo, ... } |
| 自动清理 | useEffect cleanup | 无需清理(localStorage 无副作用) |
结语:你也可以成为 Hook 魔法师!
自定义 Hook 不是高级技巧,而是每个 React 开发者都应该掌握的基础能力。它让你:
- 告别重复代码;
- 提升组件可读性;
- 降低 bug 概率;
- 加速团队协作。
下次当你写完一段逻辑,发现“这段代码好像别的地方也能用”,就大胆地把它抽成 useXXX 吧!
记住:好的抽象,不是让代码变少,而是让复杂消失。
📅 写于 2025 年 12 月 31 日,跨年夜的最后一行代码,献给热爱前端的你。
✨ Happy Coding, and Happy New Year!完整项目链接:lesson_zp/react/hand_hooks/hooks-demo: AI + 全栈学习仓库 - Gitee.com
完整项目结构: