在 React 的开发旅程中,我们经常会遇到一个痛点:组件越来越臃肿。一个简单的组件里,既要写 HTML 结构,又要处理复杂的状态逻辑,还要监听浏览器事件或管理本地存储。久而久之,代码变成了难以维护的“面条代码”。
今天,我们通过两个经典的实战案例——鼠标位置追踪和待办事项清单(Todo List) ,来聊聊如何利用 自定义 Hooks 将业务逻辑从 UI 中“抽离”出来,让代码变得清爽、可复用且易于维护。
什么是自定义 Hook?
简单来说,Hook 是一种函数式编程思想。在 React 中,凡是以 use 开头的函数都可以被称为 Hook。
如果说 useState 和 useEffect 是 React 给我们的“乐高积木”,那么自定义 Hook 就是我们用这些积木搭建好的“精密马达”。你只需要把马达装进组件里,组件就能动起来,而不需要关心马达内部是如何绕线圈的。
核心优势在于:
- 逻辑复用:一次编写,到处运行。
- 关注点分离:UI 负责“长什么样”,Hooks 负责“怎么运作”。
案例一:鼠标位置追踪 —— 封装浏览器行为
我们先看一个通过监听 mousemove 事件来显示鼠标坐标的例子。
❌ 传统写法(痛点)
通常我们会在组件里写一个 useEffect,里面添加 window.addEventListener,还要记得在组件卸载时 removeEventListener 以防止内存泄漏。如果你有三个组件都需要这个功能,你就得把这段代码复制粘贴三次。
✅ 自定义 Hook 写法
我们可以将这部分逻辑封装成一个名为 useMouse 的 Hook。它的职责非常单一:响应式地返回鼠标的 (x, y) 坐标。
import { useState, useEffect } from 'react';
// 封装响应式的 mouse 业务
export const useMouse = () => {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const update = (e) => {
setX(e.pageX);
setY(e.pageY);
}
// 挂载监听
window.addEventListener('mousemove', update);
// 清理函数:非常重要!
// 组件卸载时自动清除事件监听,防止内存泄漏
return () => {
window.removeEventListener('mousemove', update);
}
}, [])
// 只返回 UI 需要的数据
return { x, y }
}
在组件中使用
有了这个 Hook,UI 组件的代码变得异常简单。它完全不需要知道什么是 addEventListener,它只需要通过 useMouse() 获取数据并渲染即可。
function MouseTracker() {
const { x, y } = useMouse(); // 呼之即来
return (
<div>
鼠标当前位置: {x}, {y}
</div>
)
}
案例二:Todo List —— 封装复杂的业务逻辑
接下来我们将难度升级。一个完善的待办事项应用通常包含:
- CRUD 操作:增加、删除、切换完成状态。
- 数据持久化:刷新页面数据不丢失(同步 LocalStorage)。
如果把这些全写在组件里,代码量会瞬间爆炸。让我们来看看如何用 useTodos 来接管这一切。
1. 抽离数据逻辑 (useTodos)
我们把所有的状态管理和数据持久化逻辑都藏在 Hook 内部:
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos';
export const useTodos = () => {
// 初始化时从 LocalStorage 读取数据
const [todos, setTodos] = useState(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
});
// 监听 todos 变化,自动同步到 LocalStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(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
}
}
2. 极简的 UI 组装
现在,我们的主组件只需要做一件事:组装。
- 输入框组件:负责接收用户输入。
- 列表组件:负责渲染数据。
- 逻辑层:由
useTodos提供。
export default function TodoApp() {
// 一行代码引入所有业务逻辑
const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
return (
<>
{/* 传递添加方法给输入组件 */}
<TodoInput onAddTodo={addTodo} />
{/* 根据数据状态决定渲染内容 */}
{todos.length > 0 ? (
<TodoList
todos={todos}
onDelete={deleteTodo}
onToggle={toggleTodo}
/>
) : (
<div>暂无待办事项</div>
)}
</>
)
}
UI 组件(如 TodoList 和 TodoItem)变成了纯粹的展示组件。它们不持有状态,只通过 props 接收数据和回调函数。
// TodoItem 示例:只负责渲染和触发回调
export default function TodoItem({todo, onDelete, onToggle}) {
return (
<li className="todo-item">
<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>
)
}
总结
通过上面两个案例,我们可以看到自定义 Hooks 是前端团队的核心资产。
- 代码更清晰:UI 代码只管渲染,Hook 代码只管逻辑,阅读起来一目了然。
- 易于维护:如果哪天想把 LocalStorage 换成后端 API 存储,你只需要修改
useTodos内部的代码,所有使用该 Hook 的组件完全不需要改动。 - 防止内存泄漏:像
useMouse这样涉及事件监听的逻辑,封装在 Hook 内部可以确保清理逻辑(return函数)被正确执行,不会因为组件卸载而遗漏。
下次当你发现自己在复制粘贴 useEffect,或者组件代码超过几百行时,不妨停下来想一想: “这一段逻辑,是不是可以封装成一个 Hook?”