前言
最近在重温 React,写到 TodoList 这种经典案例时,忽然对 Hooks 有了新的体会。
以前写组件,恨不得把 state、useEffect、业务逻辑全塞进 App.jsx 里,代码写得像一碗拌不开的面条。直到我开始尝试自定义 Hooks,才发现:原来 React 组件也可以写得只剩“皮囊”(UI),而把“灵魂”(逻辑)优雅地抽离出去。
这篇文章分享一下我的学习心得,从一个简单的 useMouse 到完整的 useTodos,聊聊怎么把业务逻辑“拎”出来。
一、 为什么我们需要自定义 Hooks?
在 Hooks 出现之前(或者说在 Vue/React 的早期开发模式中),我们复用UI很容易(写个组件就行),但复用带有状态的逻辑却很麻烦。
你可能遇到过这种情况:
- 组件越来越胖,useEffect 里塞满了各种监听器和定时器。
- 两个毫不相关的组件,需要用到同一套“倒计时”或者“请求数据”的逻辑,结果只能复制粘贴。
自定义 Hooks 的本质,就是一种函数式编程思想:
它允许我们将 状态(State) 和 生命周期(Lifecycle) 封装进一个函数里。这个函数以 use 开头,呼之即来,挥之即去,互不干扰。
二、 热身:从 useMouse 看生命周期管理
我们先看一个简单的需求:实时显示鼠标的坐标。
如果不封装,我们通常会在组件里写 useEffect 监听 mousemove。但如果有 10 个组件都要用这个坐标呢?
1. 封装 useMouse
我们将逻辑剥离到 hooks/useMouse.js 中:
// hooks/useMouse.js
import { useState, useEffect } from "react";
// 这是一个纯粹的逻辑复用,不涉及任何 UI
export const useMouse = function () {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const update = (event) => {
setX(event.pageX);
setY(event.pageY);
};
// 1. 挂载时监听
window.addEventListener("mousemove", update);
// 2. 关键点:组件卸载时的清理!
// 如果不清理,组件销毁了监听器还在,会导致严重的内存泄漏
return () => {
console.log("|||||| 清除监听器");
window.removeEventListener("mousemove", update);
};
}, []); // 空数组表示只在挂载和卸载时执行
// 向外暴露只读的状态
return { x, y };
};
2. 思考点:关于内存泄漏
在 React 中,SPA(单页应用)不会自动清理事件监听器。如果你的组件频繁切换(比如 Tab 切换),而你没有在 useEffect 的返回值里解绑事件,浏览器的内存就会被这些无用的监听器占满,导致页面卡顿。
Hook 的哲学: 谁申请,谁治理。
三、 进阶:重构 TodoList —— 逻辑与视图的分离
鼠标位置只是单纯的“读”数据,而 TodoList 涉及到“增删改查”和“数据持久化”。
让我们看看如何把一个复杂的业务逻辑从 App.jsx 中彻底剥离。
1. 抽离业务内核:useTodos
我们在 hooks/useTodos.js 中通过 useState 和 useEffect 组合出一个功能完备的“待办事项管理器”。
这里有两个亮点值得注意:
- 惰性初始化:useState(loadFromStorage) 传入的是函数引用,而不是函数调用。这样只会在组件初次渲染时读取 LocalStorage,避免每次 render 都去读 IO,提升性能。
- 数据持久化闭环:利用 useEffect 监听 todos 的变化,一旦数据变了,自动同步到本地存储。
// hooks/useTodos.js
import { useState, useEffect } from "react";
const STORAGE_KEY = "todos";
export const useTodos = () => {
// 1. 状态定义(包含初始化逻辑)
const [todos, setTodos] = useState(loadFromStorage);
// 辅助函数:从本地读取
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
// 2. 副作用:数据变化自动保存
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}, [todos]); // 依赖项是 todos,数据变了就存
// 3. 业务动作(Action)
// 新增
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));
};
// 4. 返回接口(Interface)
return {
todos,
addTodo,
toggleTodo,
deleteTodo,
};
};
2. 只有 UI 的 Dumb Components(笨组件)
我们的 UI 组件(TodoInput, TodoList)现在变得非常纯粹,它们不包含任何业务逻辑,只负责:
- 接收数据 (props) 渲染页面。
- 接收事件,并通知父组件。
TodoInput.jsx (表单处理):
import { useState } from "react";
export default function TodoInput({ onAddTodo }) {
const [text, setText] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
onAddTodo(text.trim()); // 把动作抛出去,不关心具体怎么添加
setText("");
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请输入待办事项..."
/>
</form>
);
}
TodoList.jsx (列表展示):
import TodoItem from "./TodoItem";
export default function TodoList({ todos, onDeleteTodo, onToggleTodo }) {
return (
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onDeleteTodo={onDeleteTodo}
onToggleTodo={onToggleTodo}
/>
))}
</ul>
);
}
3. 组装:清爽的 App.jsx
最后来看看 App.jsx。你会发现它非常干净,它不再是一个处理逻辑的“大杂烩”,而变成了一个指挥官。
它只做两件事:
- 调用 useTodos 获取数据和方法。
- 把这些数据和方法分发给子组件。
// App.jsx
import { useTodos } from "./hooks/useTodos.js";
import TodoList from "./components/TodoList.jsx";
import TodoInput from "./components/TodoInput.jsx";
import "./App.css"; // 假设有简单的样式
export default function App() {
// 核心逻辑一行代码搞定!
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
return (
<div className="app-container">
<h1>My Todo List</h1>
<TodoInput onAddTodo={addTodo} />
{todos.length > 0 ? (
<TodoList
todos={todos}
onDeleteTodo={deleteTodo}
onToggleTodo={toggleTodo}
/>
) : (
<div className="empty-tip">暂无待办事项,喝杯咖啡吧 ☕️</div>
)}
</div>
);
}
四、 总结与深度思考
通过这次重构,我们实际上是在实践前端工程化中非常重要的关注点分离(Separation of Concerns) 。
- 逻辑复用性:如果明天老板让你做一个“回收站”功能,也需要管理一堆列表数据,你的 useTodos 里的增删改查逻辑稍作修改就可以直接复用,甚至可以把 useTodos 改名为更通用的 useList。
- 可测试性:测试 UI 很麻烦,但测试逻辑很简单。我们可以单独针对 useTodos.js 写单元测试,而不需要去点击页面上的按钮。
- 团队资产:像 useMouse、useLocalStorage、useRequest 这种 Hook,写好一次,整个团队都能用。这就是前端团队的核心资产。