在现代前端开发中,React 的自定义 Hook 已成为提升代码复用性、逻辑抽象能力和可维护性的核心手段。本文将通过两个典型示例------鼠标位置追踪 与本地存储的待办事项管理,深入剖析如何利用自定义 Hook 将复杂业务逻辑封装为可复用、可测试、高内聚的模块,并探讨其背后的 React 响应式编程思想。
一、Demo 1:使用 useMouse 封装鼠标位置追踪
1.1 初始实现的问题
最初,开发者可能直接在组件内部使用 useState 和 useEffect 来监听鼠标移动事件:
scss
function MouseMove() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const update = (event) => {
setX(event.pageX);
setY(event.pageY);
};
window.addEventListener('mousemove', update);
return () => {
window.removeEventListener('mousemove', update);
};
}, []);
return <div>鼠标位置: {x} {y}</div>;
}
这种写法虽然功能完整,但存在明显缺陷:
- 逻辑耦合:UI 渲染与事件监听混杂,组件职责不清;
- 难以复用:若其他组件也需要获取鼠标坐标,需重复编写相同逻辑;
- 潜在内存泄漏风险 :若未正确清理事件监听器(如忘记返回清理函数),组件卸载后仍会执行回调,事件监听/定时器 不会因为函数组件卸载而自动销毁,当卸载组件后又开启组件,相当于是又进行了一次事件监听,多次重复导致内存泄漏。
1.2 提炼为自定义 Hook:useMouse
为解决上述问题,我们将鼠标追踪逻辑提取至独立的 useMouse Hook:
javascript
// src/hooks/useMouse.js
import { useState, useEffect } from "react";
export const useMouse = () => {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const update = (event) => {
setX(event.pageX);
setY(event.pageY);
};
window.addEventListener('mousemove', update);
return () => {
window.removeEventListener('mousemove', update);
};
}, []);
return { x, y };
// 把要向外暴露的状态和方法返回
};
关键设计点解析:
- 状态封装
使用useState管理x和y坐标,对外仅暴露只读值,避免外部直接修改状态。 - 副作用隔离
useEffect负责添加/移除全局事件监听器。依赖数组为空([]),确保仅在组件挂载时注册一次监听器,并在卸载时自动清理,彻底规避内存泄漏。 - 单一职责原则
useMouse只关注"获取鼠标位置"这一核心能力,不涉及任何 UI 渲染或业务判断,高度内聚。 - 可组合性
返回对象{ x, y },便于在任意组件中解构使用,符合 React 的声明式风格。
1.3 在组件中使用
在 App.jsx 中,只需一行代码即可接入鼠标位置数据:
javascript
function MouseMove() {
const { x, y } = useMouse();
return <div>鼠标位置: {x} {y}</div>;
}
此时 MouseMove 组件完全退化为纯展示层,逻辑与视图分离,极大提升了可读性和可维护性。
二、Demo 2:构建完整的待办事项系统 ------ useTodos
相比鼠标追踪,待办事项管理涉及状态管理、持久化存储等多个维度,是检验自定义 Hook 能力的绝佳场景。
2.1 整体架构拆解
整个系统由以下部分组成:
-
Hook 层 :
useTodos------ 核心逻辑容器 -
组件层:
TodoInput:输入新任务TodoList:渲染任务列表TodoItem:单个任务项(含完成状态切换与删除)
这种分层结构体现了典型的"逻辑下沉,UI 上浮"原则:复杂状态流转由 Hook 处理,组件仅负责调用方法与展示数据。
2.2 useTodos Hook 深度解析
ini
// src/hooks/useTodos.js
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos';
function loadFromStorage() {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
}
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
export const useTodos = () => {
const [todos, setTodos] = useState(loadFromStorage);
useEffect(() => {
saveToStorage(todos);
}, [todos]);
// todos改变 进行本地存储
const addTodo = (text) => {
setTodos([
...todos,
{ id: Date.now(), text, completed: false }
]);
};
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
};
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
return { todos, addTodo, toggleTodo, deleteTodo };
};
核心机制详解:
(1)初始化与持久化同步
- 延迟初始化 :
useState(loadFromStorage)利用函数式初始化,避免每次渲染都读取 localStorage,提升性能。 - 自动持久化 :
useEffect监听todos变化,一旦状态更新立即写入 localStorage,实现"状态即存储"的无缝体验。
注意:此处使用
Date.now()作为 ID 虽简便,但在高频操作下可能冲突。
(2)不可变更新原则
所有状态变更均通过创建新数组实现:
addTodo:使用展开运算符[...todos, newTodo]toggleTodo:map返回新数组,仅修改目标项deleteTodo:filter排除指定 ID 项
这保证了 React 能正确触发重渲染,同时避免意外修改原始状态。
(3)API 设计清晰
返回对象包含:
- 状态 :
todos(当前任务列表) - 行为 :
addTodo,toggleTodo,deleteTodo(纯函数,无副作用)
调用者无需关心内部实现,只需按约定传参即可操作状态。
2.3 组件层协作流程
TodoInput:任务创建入口
javascript
// src/components/TodoInput.jsx
export default function TodoInput({ onAddTodo }) {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
onAddTodo(text.trim());
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={e => setText(e.target.value)}
/>
</form>
);
}
- 通过
onAddTodo回调将新任务文本传递给父组件(即useTodos.addTodo) - 表单提交后清空输入框,提供良好 UX
TodoList 与 TodoItem:状态展示与交互
javascript
// TodoList.jsx
export default function TodoList({ todos, onDelete, onToggle }) {
return (
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</ul>
);
}
// TodoItem.jsx
export default function TodoItem({ todo, onDelete, onToggle }) {
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
}
- 单向数据流 :
todos由App传入,TodoItem仅消费数据 - 事件委托 :点击复选框或删除按钮时,调用
onToggle/onDelete,最终触发useTodos内部状态更新 - Key 唯一性 :使用
todo.id作为key,确保 React Diff 算法高效更新列表
2.4 App 组件:胶水层整合
javascript
// App.jsx
export default function App() {
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
return (
<>
<TodoInput onAddTodo={addTodo} />
{todos.length > 0 ? (
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
) : (
<div>暂无待办事项</div>
)}
</>
);
}
App 组件几乎不包含业务逻辑,仅负责:
- 调用
useTodos获取状态与方法 - 将方法作为 props 传递给子组件
- 根据
todos.length控制空状态显示
这种"瘦容器"模式使应用结构清晰,易于扩展(例如未来加入筛选、编辑等功能)。
三、自定义 Hook 的价值与最佳实践
3.1 为什么需要自定义 Hook?
- 逻辑复用:跨组件共享状态逻辑
- 关注点分离:将副作用、数据获取、状态管理从 UI 组件中剥离
- 测试友好:Hook 可独立于组件进行单元测试
- 团队协作:形成可沉淀的"前端资产库",新人可快速接入
3.2 编写高质量 Hook 的准则
- 命名规范 :以
use开头(如useTodos),这是 React 的约定,也是 ESLint 规则的要求。 - 返回结构清晰:通常返回对象,便于按需解构;避免返回数组导致顺序依赖。
- 避免副作用外泄:Hook 内部处理所有订阅/清理,调用者无需关心生命周期。
- 考虑性能优化 :对返回的函数使用
useCallback包裹(本例因简单省略,复杂场景需注意)。
结语
通过 useMouse 与 useTodos 两个案例,我们见证了自定义 Hook 如何将"面条式代码"转化为模块化、可维护的现代 React 应用。它不仅是语法糖,更是一种架构思维------鼓励开发者将复杂问题分解为独立、可组合的逻辑单元。
在实际项目中,你可以继续延伸这一模式:封装网络请求(useFetch)、表单验证(useForm)、主题切换(useTheme)等通用能力。当你的 Hook 库逐渐丰富,你会发现:优秀的前端工程,始于对逻辑的敬畏,成于对复用的追求。
本文所有代码均可直接运行,建议读者动手实践,尝试为
useTodos添加"编辑任务"或"按状态筛选"功能,进一步巩固自定义 Hook 的设计能力。