🌟 从零彻底掌握 React 自定义 Hook:深度解析 useMouse 与 useTodos 的完整开发思维与逐行代码详解
这不是一篇“速成教程”,而是一次完整的、贴近真实工程实践的代码构建之旅。
我们将从用户需求出发,一步步思考、设计、实现、优化,并深入解释每一行代码背后的原理、权衡与最佳实践。
🧭 引言:为什么你需要理解“写代码的思路”?
很多初学者学 React 时,只是机械地复制 useState、useEffect 的用法,却不知道:
- 为什么状态要这样初始化?
- 为什么副作用必须清理?
- 为什么不能直接修改数组?
- 为什么要把逻辑抽成 Hook?
真正的编程能力,不在于记住语法,而在于“如何思考问题”。
本文将以两个经典场景——鼠标位置追踪和待办事项管理为例,带你完整走一遍专业前端工程师的开发流程:
- 明确需求
- 拆解问题
- 设计状态结构
- 处理副作用与生命周期
- 封装可复用逻辑
- 防御性编程与错误处理
- 性能与用户体验优化
每一步都配有详细注释 + 原理解析 + 常见陷阱,确保你不仅会写,更知道“为什么这样写”。
🖱️ 第一部分:useMouse —— 安全监听鼠标位置(含内存泄漏防范)
🔍 第一步:明确需求
- 实时获取鼠标的 X 和 Y 坐标;
- 坐标变化时,UI 自动更新;
- 组件卸载后,不能留下任何监听器(防止内存泄漏);
- 代码可复用,多个组件能同时使用。
🧱 第二步:设计状态与副作用
| 要素 | 决策 |
|---|---|
| 状态 | 需要两个值:x 和 y → 用两个 useState |
| 副作用来源 | 全局事件 window.mousemove |
| 副作用时机 | 组件挂载时添加,卸载时移除 |
| 依赖关系 | 无需依赖外部变量 → 依赖数组为 [] |
✍️ 第三步:编写代码(逐行深度解析)
// 文件路径: src/hooks/useMouse.js
import { useState, useEffect } from 'react';
/**
* 自定义 Hook:安全地追踪鼠标在页面中的实时位置
*
* 返回值:
* { x: number, y: number }
*
* 特性:
* - 自动绑定/解绑 mousemove 事件
* - 防止内存泄漏
* - 支持多实例共存(每个组件独立)
*/
export const useMouse = () => {
// ▼▼▼ 状态声明 ▼▼▼
/**
* 使用 useState 声明两个响应式状态
* 初始值设为 0,表示页面左上角
*
* 注意:这里不需要函数式初始化,
* 因为初始值是常量,不涉及计算或 I/O
*/
const [x, setX] = useState(0);
const [y, setY] = useState(0);
// ▼▼▼ 副作用管理 ▼▼▼
/**
* useEffect 用于处理“副作用”(side effect)
* 这里指:添加全局事件监听器
*
* 为什么放在这里?
* - 函数组件本身没有生命周期方法(如 componentDidMount)
* - useEffect 是 React 提供的“做事情”的入口
*/
useEffect(() => {
/**
* 定义事件处理函数
*
* 为什么不在 useEffect 外面定义?
* - 如果在外面定义,它无法访问当前的 setX/setY(闭包问题)
* - 如果在里面定义,每次 effect 重新运行都会创建新函数(但这里依赖是 [],只运行一次)
*/
const handleMouseMove = (event) => {
/**
* event.pageX / pageY 是相对于整个 HTML 文档的坐标
* 包含滚动偏移,适合大多数 UI 场景
*
* 对比:
* clientX/clientY → 相对于视口(不含滚动)
* screenX/screenY → 相对于屏幕
*/
setX(event.pageX);
setY(event.pageY);
};
/**
* 添加事件监听器到 window
*
* 为什么是 window 而不是 document 或 body?
* - mousemove 在 window 上能捕获整个页面的移动
* - 更可靠,尤其在全屏或复杂布局中
*/
window.addEventListener('mousemove', handleMouseMove);
/**
* 返回一个“清理函数”(cleanup function)
*
* React 会在以下时机调用它:
* 1. 组件卸载(unmount)时
* 2. 下一次 useEffect 执行前(如果依赖变化)
*
* 本例中依赖是 [],所以只在卸载时调用
*
* 清理目的:
* - 移除事件监听器,避免内存泄漏
* - 防止对已卸载组件调用 setState(React 会警告)
*/
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
/**
* 依赖数组:[]
* 表示该 effect 只在组件首次挂载时运行一次
* 不会因为 x/y 变化而重复绑定监听器
*
* 如果忘记写 [],会导致:
* - 每次渲染都添加新监听器
* - 越绑越多,性能下降,内存暴涨
*/
}, []);
// ▼▼▼ 暴露 API ▼▼▼
/**
* 返回一个对象,包含当前坐标
*
* 为什么返回对象而不是数组?
* - 对象支持命名解构,语义更清晰:
* const { x, y } = useMouse();
* - 比 const [a, b] = useMouse() 更易读
*/
return { x, y };
};
⚠️ 常见错误与陷阱分析
| 错误写法 | 后果 | 正确做法 |
|---|---|---|
忘记 return () => removeEventListener | 内存泄漏 + React 警告 | 必须清理 |
依赖数组写成 [x, y] | 每次坐标变就重新绑定监听器 | 依赖应为 [] |
在组件外定义 handleMouseMove | 无法访问最新 setX/setY | 在 useEffect 内定义 |
使用 clientX 而非 pageX | 滚动后坐标不准 | 根据需求选 pageX/Y |
📋 第二部分:useTodos —— 带持久化的待办事项管理(含错误处理与不可变更新)
🔍 第一步:明确需求
- 支持添加、删除、切换任务完成状态;
- 任务数据刷新后不丢失;
- 所有操作通过函数暴露,UI 组件只负责渲染;
- 代码健壮,能处理
localStorage异常; - 符合 React 不可变更新原则。
🧱 第二步:状态与数据流设计
| 数据 | 类型 | 存储方式 | 初始化来源 |
|---|---|---|---|
todos | { id: number, text: string, completed: boolean }[] | React 状态 (useState) | localStorage 或空数组 |
| 持久化 | 字符串 | localStorage | 每次 todos 变化时自动保存 |
💡 关键原则:
- 状态单一来源:所有数据以
todos状态为准;- 单向数据流:UI → 调用操作函数 → 更新状态 → 自动保存 → UI 重绘。
✍️ 第三步:编写代码(超详细逐行解析)
// 文件路径: src/hooks/useTodos.js
import { useState, useEffect } from 'react';
/**
* 定义 localStorage 的 key
* 使用常量避免“魔法字符串”(magic string)
* 便于维护和避免拼写错误
*/
const STORAGE_KEY = 'react_todos_v1';
/**
* 从 localStorage 加载待办事项
*
* 封装为独立函数的原因:
* - 逻辑复用(初始化 + 错误处理)
* - 单一职责:只负责“读”
* - 易于测试
*/
function loadFromStorage() {
try {
/**
* localStorage.getItem 返回字符串或 null
* 如果从未保存过,返回 null
*/
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === null) {
return []; // 首次使用,返回空数组
}
/**
* JSON.parse 将字符串转为 JS 对象
* 注意:可能抛出 SyntaxError(如数据被篡改)
*/
const parsed = JSON.parse(saved);
/**
* 额外校验:确保是数组
* 防止旧版本数据格式不兼容
*/
if (!Array.isArray(parsed)) {
console.warn('Invalid todos data in localStorage, resetting to empty array');
return [];
}
return parsed;
} catch (error) {
/**
* 捕获所有异常:
* - JSON 解析失败
* - localStorage 被禁用(如无痕模式)
* - 存储空间不足
*
* 不让错误中断程序,而是降级处理
*/
console.error('Failed to load todos from localStorage:', error);
return []; // 安全兜底
}
}
/**
* 保存待办事项到 localStorage
*
* 同样封装为函数,便于复用和错误处理
*/
function saveToStorage(todos) {
try {
/**
* JSON.stringify 将数组转为字符串
* 注意:函数、undefined、Symbol 会被忽略
* 我们的 todo 对象只有基本类型,安全
*/
const serialized = JSON.stringify(todos);
localStorage.setItem(STORAGE_KEY, serialized);
} catch (error) {
/**
* 可能原因:
* - 超出存储配额(QuotaExceededError)
* - 用户禁用 localStorage
*
* 此处仅记录日志,不影响主流程
* (高级做法:可弹出提示“本地存储已满”)
*/
console.error('Failed to save todos to localStorage:', error);
}
}
/**
* 自定义 Hook:管理待办事项列表
*
* 返回值:
* {
* todos: Array,
* addTodo: (text: string) => void,
* toggleTodo: (id: number) => void,
* deleteTodo: (id: number) => void
* }
*/
export const useTodos = () => {
/**
* 初始化状态:使用函数式初始化
*
* 为什么用函数?
* - useState(initialValue) 中,如果 initialValue 是函数,
* React 会在首次渲染时调用它,并缓存结果
* - 避免每次渲染都执行 loadFromStorage(性能优化)
* - 首屏直接显示数据,无闪烁
*/
const [todos, setTodos] = useState(loadFromStorage);
/**
* 自动持久化:监听 todos 变化,同步到 localStorage
*
* 依赖数组 [todos]:
* - 当 todos 引用变化时,触发保存
* - 因为我们总是创建新数组(不可变更新),引用必然变化
*/
useEffect(() => {
saveToStorage(todos);
}, [todos]);
/**
* 添加新任务
*
* 参数 text: 用户输入的文本
*/
const addTodo = (text) => {
/**
* 防御性编程:
* - 检查 text 是否存在(防止传入 undefined)
* - trim() 去除首尾空格
* - 空字符串不添加
*/
if (!text || !text.trim()) {
return;
}
/**
* 创建新任务对象
*
* ID 生成策略:
* - Date.now() 简单有效,但极端情况下可能重复(如快速连点)
* - 生产环境建议用 nanoid、uuid 等库
*/
const newTodo = {
id: Date.now(),
text: text.trim(),
completed: false,
// 可扩展字段:
// createdAt: new Date().toISOString(),
// updatedAt: new Date().toISOString()
};
/**
* 不可变更新:创建新数组
*
* 为什么不用 setTodos([...todos, newTodo])?
* - 如果 addTodo 被缓存(如 useCallback),todos 可能是旧闭包
* - 使用函数式更新(prev => [...])确保拿到最新状态
*/
setTodos(prev => [...prev, newTodo]);
};
/**
* 切换任务完成状态
*/
const toggleTodo = (id) => {
/**
* 使用函数式更新 + map
*
* map 遍历每个任务:
* - 如果 id 匹配,返回新对象(翻转 completed)
* - 否则原样返回
*
* {...todo} 确保不修改原对象(不可变)
*/
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
};
/**
* 删除任务
*/
const deleteTodo = (id) => {
/**
* filter 创建新数组,排除匹配 id 的任务
* 同样使用函数式更新保证状态最新
*/
setTodos(prev =>
prev.filter(todo => todo.id !== id)
);
};
/**
* 返回 API
*
* 注意顺序:状态在前,操作函数在后(常见约定)
*/
return {
todos,
addTodo,
toggleTodo,
deleteTodo
};
};
🔍 关键概念深度解析
1. 为什么必须“不可变更新”?
React 使用 Object.is() 比较状态是否变化。
如果你直接修改原数组:
// ❌ 错误!
todos[0].completed = true;
setTodos(todos); // 引用没变,React 认为“没更新”,不重绘!
✅ 正确做法:创建新数组/对象,让引用变化:
// ✅ 正确
setTodos(prev => prev.map(...)); // 新数组,引用不同
2. 函数式更新(setTodos(prev => ...))的优势
假设你有如下代码:
const add1 = () => setCount(count + 1);
const add2 = () => setCount(count + 1);
如果快速点击两次,可能两次都基于同一个 count 值(比如 0),结果只加了 1。
而用函数式更新:
const add1 = () => setCount(prev => prev + 1);
const add2 = () => setCount(prev => prev + 1);
React 会按顺序传递最新值,确保结果正确(+2)。
3. localStorage 的局限性
- 容量限制:通常 5~10MB,超限会报错;
- 同步阻塞:大量数据读写会卡主线程;
- 隐私模式:某些浏览器会禁用;
- 跨设备不同步:只在当前设备生效。
✅ 适用于小型应用(如待办事项、主题设置)。
❌ 不适用于大型数据或需要同步的场景(应使用后端 API)。
🧩 第四部分:在组件中使用 —— UI 与逻辑彻底分离
App.jsx 示例
// src/App.jsx
import { useState } from 'react';
import { useTodos } from './hooks/useTodos';
import { useMouse } from './hooks/useMouse';
export default function App() {
const [inputValue, setInputValue] = useState('');
// 获取待办事项逻辑
const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
// 获取鼠标位置
const { x, y } = useMouse();
const handleSubmit = (e) => {
e.preventDefault();
if (inputValue.trim()) {
addTodo(inputValue);
setInputValue('');
}
};
return (
<div style={{ padding: '20px', fontFamily: 'Arial' }}>
{/* 输入区域 */}
<form onSubmit={handleSubmit} style={{ marginBottom: '20px' }}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入新任务..."
style={{ padding: '8px', marginRight: '8px' }}
/>
<button type="submit" style={{ padding: '8px 16px' }}>
添加
</button>
</form>
{/* 任务列表 */}
{todos.length > 0 ? (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<li
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
borderBottom: '1px solid #eee'
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
style={{ marginRight: '12px' }}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#888' : '#000'
}}
>
{todo.text}
</span>
<button
onClick={() => deleteTodo(todo.id)}
style={{
background: '#ff4d4d',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer'
}}
>
删除
</button>
</li>
))}
</ul>
) : (
<p style={{ color: '#888' }}>暂无待办事项</p>
)}
{/* 鼠标位置显示 */}
<div
style={{
position: 'fixed',
top: '10px',
right: '10px',
background: '#f0f0f0',
padding: '8px',
borderRadius: '4px',
fontSize: '14px'
}}
>
鼠标位置: ({x}, {y})
</div>
</div>
);
}
🧠 总结:专业开发者的完整思维链
| 阶段 | 关键动作 | 工具/原则 |
|---|---|---|
| 1. 需求分析 | 列出功能点、数据流、边界情况 | 用户故事、用例图 |
| 2. 状态设计 | 确定状态结构、初始值、更新方式 | 单一数据源、不可变 |
| 3. 副作用处理 | 识别 I/O、监听、定时器 | useEffect + 清理 |
| 4. 封装抽象 | 提取通用逻辑为自定义 Hook | 复用、关注点分离 |
| 5. 健壮性 | 处理异常、输入校验、兜底 | 防御性编程 |
| 6. 性能优化 | 避免重复计算、减少重渲染 | 函数式更新、依赖数组 |
| 7. 可维护性 | 命名清晰、注释完整、模块化 | Clean Code 原则 |
💡 最后建议:如何真正掌握?
- 亲手敲一遍代码,不要复制粘贴;
- 故意制造错误:删掉清理函数、改错依赖数组,看控制台报什么;
- 扩展功能:给
useTodos加“编辑任务”、“清空已完成”; - 阅读源码:看看
react-use、ahooks等库是怎么写的; - 教给别人:试着向朋友解释
useMouse为什么需要清理。
编程不是记忆,而是理解 + 实践。
你现在拥有的,不只是两段代码,而是一套可迁移的工程思维。
用它去构建更复杂的 Hook 吧!🚀