阅读建议:适合已经会用
useState和useEffect的 React 初中级开发者。本文通过一个完整的 Todo 应用案例,带你一步步理解 自定义 Hook 是什么、为什么需要它、以及如何写出可复用、易维护的状态逻辑。
一、我们遇到的问题:组件越来越重
在写 React 函数组件时,你是否经历过这样的场景?
- 一个组件既要处理表单输入,又要监听键盘事件,还要同步数据到本地存储;
- 另一个页面也需要同样的数据加载逻辑,但你只能复制粘贴代码;
- 组件卸载后,某些事件监听还在运行,导致内存占用上升甚至行为异常;
这些问题的本质是:UI 和业务逻辑耦合太深。
React 提供了函数组件 + Hook 的模型,让我们可以用更简洁的方式管理状态。而其中最强大的能力之一,就是------自定义 Hook。
二、什么是自定义 Hook?先看一个简单例子
假设我们要实现在页面上实时显示鼠标位置的功能。
最原始的做法
jsx
function App() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const handleMove = (e) => {
setX(e.pageX);
setY(e.pageY);
};
window.addEventListener('mousemove', handleMove);
// 卸载时清除,防止内存泄漏
return () => {
window.removeEventListener('mousemove', handleMove);
};
}, []);
return <div>鼠标位置:{x}, {y}</div>;
}
这段代码没有问题,但它有一个隐患:如果其他组件也需要这个功能,就得再写一遍 useEffect 和状态声明。一旦将来要加防抖或坐标转换,所有地方都得改。
这显然不是我们想要的开发方式。
抽象出 useMouse
我们可以把这部分逻辑单独提取出来:
js
// hooks/useMouse.js
import { useState, useEffect } from 'react';
export default function useMouse() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
const updatePosition = (e) => {
setX(e.pageX);
setY(e.pageY);
};
window.addEventListener('mousemove', updatePosition);
return () => {
window.removeEventListener('mousemove', updatePosition);
};
}, []);
return { x, y };
}
然后在任意组件中直接使用:
jsx
function MouseDisplay() {
const { x, y } = useMouse();
return <div>当前鼠标坐标:{x}, {y}</div>;
}
看起来只是换了个写法,但背后的意义完全不同。
三、关键点剖析:为什么这样设计?
1. 命名规范:以 use 开头
这是 React 的约定。只有以 use 开头的函数,React 才会认为它可能调用了其他 Hook(如 useState),从而启用相应的执行规则。虽然只是一个命名习惯,但它统一了团队协作的认知成本。
2. 状态是独立的
每次调用 useMouse(),都会创建一组新的 x 和 y 状态。也就是说:
jsx
function ComponentA() {
const { x } = useMouse(); // 属于 A 的状态
}
function ComponentB() {
const { x } = useMouse(); // 属于 B 的状态,互不影响
}
这一点非常重要:自定义 Hook 不是全局状态管理工具,它是逻辑的复用单元,而不是状态共享机制。
3. 必须清理副作用
很多人忽略的一点是:window.addEventListener 是全局操作,不会因为组件卸载而自动消失。
如果不写 return () => removeEventListener(...),那么即使组件被销毁,事件监听依然存在。当下次触发 mousemove 时,旧的回调函数仍会被执行,可能导致:
- 设置已卸载组件的状态(React 警告)
- 内存无法释放(长期运行下性能下降)
所以,只要是涉及全局资源的操作(定时器、事件监听、WebSocket 连接等),就必须在 useEffect 中返回清理函数。
四、进阶实战:封装一套带持久化的待办事项逻辑
接下来我们做一个更有实用价值的例子:将 Todo 的增删改查 + 本地存储封装成一个自定义 Hook。
目标很明确:让任何组件都能轻松接入完整的 Todo 功能,而不需要关心数据从哪来、怎么存。
第一步:定义需求
我们需要实现:
- 初始化时从
localStorage加载数据; - 添加、修改、删除时自动保存;
- 对外暴露清晰的方法:
addTodo,toggleTodo,deleteTodo。
第二步:编写 useTodos.js
js
// hooks/useTodos.js
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos'; // 抽离常量,便于后期维护或迁移
// 读取本地数据
function loadFromStorage() {
const raw = localStorage.getItem(STORAGE_KEY);
try {
return raw ? JSON.parse(raw) : [];
} catch (e) {
console.warn('Failed to parse todos from localStorage', e);
return [];
}
}
// 保存到本地
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
// 主 Hook
export const useTodos = () => {
const [todos, setTodos] = useState(loadFromStorage);
// 数据变化时自动持久化
useEffect(() => {
saveToStorage(todos);
}, [todos]); // 注意依赖项是 todos
const addTodo = (text) => {
if (!text || !text.trim()) return;
const newTodo = {
id: Date.now(), // 简单唯一 ID
text: text.trim(),
completed: false,
};
setTodos([...todos, newTodo]);
};
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,
};
};
关键细节说明
✅ 使用函数初始化 state
js
useState(loadFromStorage)
而不是:
js
useState(loadFromStorage())
区别在于:前者只会在首次渲染时执行 loadFromStorage 函数;后者每次组件渲染都会调用该函数(尽管结果不用),造成不必要的性能浪费。
✅ 持久化放在 useEffect 中
我们将 saveToStorage(todos) 放在 useEffect 内,并监听 todos 变化。这样做的好处是:
- 数据变更逻辑集中;
- 不会在每次
add/toggle/delete中重复写保存逻辑; - 符合"状态驱动副作用"的设计理念。
✅ 保持不可变性(Immutability)
所有操作都没有直接修改原数组,而是返回新数组:
map返回新数组用于更新状态;filter同样如此;- 对象展开
{...todo}创建副本;
这是确保 React 正确触发重渲染的关键。
五、结合组件使用:UI 与逻辑彻底分离
有了 useTodos,我们的主组件变得非常干净:
jsx
// App.jsx
import { useTodos } from './hooks/useTodos';
import TodoInput from './components/TodoInput';
import TodoList from './components/TodoList';
export default function App() {
const { todos, addTodo, toggleTodo, deleteTodo } = useTodos();
return (
<>
<TodoInput addTodo={addTodo} />
{todos.length > 0 ? (
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
) : (
<div>暂无待办事项</div>
)}
</>
);
}
你会发现,App 组件不再关心:
- 数据从哪里来?
- 是否需要保存?
- 如何生成 ID?
它只负责协调各个子组件之间的交互,成为一个"胶水层"。
这种分层结构极大提升了项目的可维护性。
六、组件拆解:各司其职
TodoInput:专注输入
jsx
// components/TodoInput.jsx
import { useState } from 'react';
export default function TodoInput({ addTodo }) {
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!text.trim()) return;
addTodo(text);
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="请输入待办事项"
/>
</form>
);
}
职责单一:接收用户输入并提交。
TodoItem:单项展示与交互
jsx
// components/TodoItem.jsx
export default function TodoItem({ todo, onToggle, onDelete }) {
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)}>删除</button>
</li>
);
}
注意这里没有直接操作 todos 数组,而是通过回调通知父级处理。这也是典型的"数据下行,事件上行"模式。
TodoList:列表容器
jsx
// components/TodoList.jsx
import TodoItem from './TodoItem';
export default function TodoList({ todos, onToggle, onDelete }) {
return (
<ul className="todo-list">
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
它只做一件事:遍历渲染每一项。
七、深入本质:自定义 Hook 到底是什么?
很多初学者会误以为自定义 Hook 是某种魔法语法,其实它的本质非常朴素:
自定义 Hook 就是一个普通的 JavaScript 函数,只要它内部调用了 React 的内置 Hook(如 useState、useEffect),并且名字以
use开头。
它不能在条件语句中调用,也不能在普通函数中随意使用 ------ 因为 React 依赖调用顺序来追踪状态,这就是所谓的 "Hook 规则"。
但正因为这些限制,才保证了它的可靠性和可预测性。
八、常见误区与最佳实践
❌ 错误做法
-
在循环或判断中调用 Hook
jsif (condition) { useState(); // ❌ 不允许! } -
忘记清理副作用
jsuseEffect(() => { const timer = setInterval(() => {}, 1000); // 没有 return 清理函数 → 定时器永远存在 }, []); -
过度抽象
并非所有逻辑都要抽成 Hook。比如简单的
const [open, setOpen]就没必要封装成useModal。
✅ 推荐做法
| 实践 | 说明 |
|---|---|
| 提前组织好输入输出 | 明确参数和返回值结构 |
| 使用稳定引用 | 避免在每次渲染中创建新函数传给子组件 |
| 增加错误处理 | 如 JSON.parse 失败时降级为空数组 |
| 写注释文档 | 特别是复杂逻辑,方便他人接手 |
九、总结:自定义 Hook 的真正价值
学完这一整套流程,我们可以总结出自定义 Hook 的核心价值:
-
逻辑复用
把通用的能力(如鼠标监听、本地存储、请求封装)提炼出来,一处维护,多处使用。
-
关注点分离
UI 负责展示,Hook 负责逻辑,各归其位,降低认知负担。
-
提升可测试性
你可以单独测试
useTodos的行为,而不必挂载整个组件树。 -
沉淀团队资产
当项目积累了一批高质量的自定义 Hook(如
useRequest,useLocalStorage,useUndo),它们就会成为团队的核心生产力工具。
结语
React 自定义 Hook 并不是一个高级技巧,而是一种思维方式的进化。
它告诉我们:组件不只是视图,也可以是可组合的状态逻辑单元。
当你开始思考"这段逻辑能不能抽成一个 useXxx"的时候,你就已经迈出了成为优秀 React 开发者的重要一步。
希望这篇文章能帮你真正理解自定义 Hook 的意义,而不仅仅是学会怎么写。
如果你觉得有收获,欢迎点赞、收藏、分享。也欢迎在评论区交流你在项目中封装过的实用 Hook!
示例代码完整结构如下:
css
src/
├── App.jsx
├── components/
│ ├── TodoInput.jsx
│ ├── TodoItem.jsx
│ └── TodoList.jsx
├── hooks/
│ ├── useMouse.js
│ └── useTodos.js
└── index.js
你可以基于此结构继续扩展,比如加入过滤、搜索、分类等功能,进一步体会逻辑与 UI 分离带来的便利。
共勉。