在 React 函数组件时代,Hooks 无疑是提升代码复用性和可读性的“神器”。除了使用内置的
useState、useEffect,我们还能亲手封装属于自己的 Hook。本文将通过一个基于 Vite 构建的待办事项项目,逐行剖析useMouse和useTodos两个自定义 Hook 的实现细节,带你掌握自定义 Hook 的设计思想与最佳实践。
1. 为什么需要自定义 Hook?
React 官方提供了 useState、useEffect、useContext 等基础 Hook,但它们通常解决的是单一维度的状态逻辑。当多个组件需要共享带副作用的状态逻辑(比如监听鼠标位置、读写 localStorage、订阅 WebSocket)时,把逻辑提取成一个自定义 Hook 就成了最优解。
自定义 Hook 的本质:一个以 use 开头的普通函数,内部可以调用其他 Hook,用于封装有状态、有副作用的逻辑,并返回组件需要的数据或方法。
下面我们通过项目中的两个经典案例来深入理解。
完整项目链接:gitee.com/hong-strong…
2. 项目概览(Vite + React)
项目结构如下(仅展示关键文件):
src/
├── hooks/
│ ├── useMouse.js # 监听鼠标位置
│ └── useTodos.js # 待办事项的增删改查 + localStorage 持久化
├── components/
│ ├── TodoInput.jsx # 输入框组件
│ ├── TodoList.jsx # 列表组件
│ └── TodoItem.jsx # 单个待办项组件
├── App.jsx # 根组件,使用 useTodos
├── App.css # 组件样式(含奇特的 3D 翻转效果)
├── index.css # 全局样式 + 亮色/暗色主题
├── main.jsx # 入口文件
└── readme.md # 项目说明(仅作背景,本文不展开)
项目通过 useTodos 管理待办数据,并自动同步到 localStorage;通过 useMouse 实时获取鼠标坐标(虽然 App.jsx 中注释掉了,但仍是完美的教学示例)。
3. 手写 useMouse:监听全局鼠标事件
3.1 完整代码逐行解析
// 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) => {
console.log('//////////////////////////');
setX(event.pageX);
setY(event.pageY);
};
window.addEventListener('mousemove', update);
console.log('||||||');
return () => {
console.log('|||||| 清除');
window.removeEventListener('mousemove', update);
};
}, []); // 空依赖数组 → 只在组件挂载时注册,卸载时移除
return { x, y };
};
逐行讲解
import { useState, useEffect } from 'react':自定义 Hook 必须依赖 React 提供的基础 Hook。export const useMouse = () => { ... }:函数名以use开头,这是 React 的约定,方便 lint 工具检查规则(比如不能在条件语句中调用 Hook)。const [x, setX] = useState(0):声明响应式变量x,初始值为0。鼠标的横坐标。const [y, setY] = useState(0):纵坐标。useEffect(() => { ... }, []):- 第一个参数是副作用函数,在组件渲染后执行。空依赖数组
[]表示该副作用只会在组件挂载时执行一次,不会因为重新渲染而重复注册事件。 - 内部定义了
update函数,接收原生mousemove事件对象,调用setX和setY更新状态。 window.addEventListener('mousemove', update):全局监听鼠标移动。- 返回值是一个清理函数:
return () => { window.removeEventListener(...) }。这个清理函数会在组件卸载时自动执行,移除事件监听,防止内存泄漏。
- 第一个参数是副作用函数,在组件渲染后执行。空依赖数组
return { x, y }:将坐标暴露给组件使用。
重要扩展:为什么必须清理?
如果忘记移除事件监听,当组件被卸载后,
update函数仍然会绑定在window上。一旦鼠标移动,就会调用已卸载组件的setX,React 会发出警告(“Can't perform a React state update on an unmounted component”),并且造成内存泄漏——事件回调持有的闭包无法释放。
图表:useMouse 的生命周期流程
graph TD
A[组件挂载] --> B[执行 useEffect 副作用]
B --> C[注册 mousemove 监听]
C --> D[用户移动鼠标]
D --> E[触发 update 函数]
E --> F[调用 setX/setY]
F --> G[组件重新渲染,显示新坐标]
H[组件卸载] --> I[执行 useEffect 返回的清理函数]
I --> J[移除 mousemove 监听]
J --> K[内存安全]
4. 手写 useTodos:管理待办 + localStorage 持久化
这个 Hook 更复杂,体现了状态逻辑与存储副作用的完美封装。
4.1 完整代码逐行解析
// src/hooks/useTodos.js
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos'; // 常量,好维护
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
export const useTodos = () => {
const [todos, setTodos] = useState(loadFromStorage);
useEffect(() => {
saveToStorage(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 };
};
4.1.1 初始化状态:惰性加载
const STORAGE_KEY = 'todos':定义存储键名,避免魔法字符串。loadFromStorage:从localStorage读取数据,如果不存在则返回空数组。注意JSON.parse可能抛出异常(比如存储被破坏),生产环境应加try-catch,本文简化。useState(loadFromStorage):这里传递的是函数本身(而不是loadFromStorage()),这是useState的惰性初始化特性。React 只会在组件首次渲染时执行该函数,读取一次localStorage;后续重渲染不会再调用,节省性能。
4.1.2 同步副作用:useEffect 依赖 todos
useEffect(() => { saveToStorage(todos); }, [todos]):- 每当
todos发生变化(增、删、改),都会自动调用saveToStorage,将最新数组序列化后存入localStorage。 - 因为依赖数组是
[todos],所以首次渲染后也会执行一次(保存初始值)。 - 这里不需要清理函数,因为没有任何全局订阅或定时器。
- 每当
4.1.3 增删改查逻辑
addTodo:使用不可变数据模式,生成新数组([...todos, newTodo])。id使用Date.now()简单生成,实际项目可用uuid。toggleTodo:遍历todos,匹配id后返回新对象{ ...todo, completed: !todo.completed },不匹配的保持不变。最终生成新数组触发更新。deleteTodo:用filter过滤掉待删除项。- 返回值:提供
todos状态和三个操作函数,组件直接解构使用。
4.2 图表:useTodos 数据流
flowchart LR
A[组件调用 useTodos] --> B[初始化:从 localStorage 读取]
B --> C[todos 状态]
D[用户触发 add/toggle/delete] --> E[调用对应的 setTodos]
E --> F[生成新的 todos 数组]
F --> G[触发 useEffect]
G --> H[保存到 localStorage]
F --> I[组件重新渲染]
5. 组件中使用自定义 Hook
5.1 根组件 App.jsx(核心部分)
import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';
export default function App() {
const {
todos,
addTodo,
deleteTodo,
toggleTodo
} = useTodos();
return (
<>
<TodoInput onAddTodo={addTodo}/>
{
todos.length > 0 ?
<TodoList
onDelete={deleteTodo}
onToggle={toggleTodo}
todos={todos}
/> :
<div>暂无待办事项</div>
}
</>
);
}
- 解构 Hook 返回值:
useTodos返回的对象被解构,直接获得状态和操作函数。 - 条件渲染:当没有待办时显示提示信息,否则渲染
TodoList。 TodoInput:接收onAddTodo回调,内部维护输入框的局部状态(见下文)。TodoList与TodoItem:通过 props 传递数据和操作函数,实现“状态提升”的另一种形式——逻辑全在 Hook 里,组件只负责展示。
5.2 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)}
/>
</form>
);
}
- 局部状态:
text只属于输入框组件,不需要提升到useTodos中。 - 表单提交:阻止默认刷新,判断非空后调用父组件传入的
onAddTodo,然后清空输入框。
5.3 TodoItem.jsx 与 TodoList.jsx(列表渲染)
// 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)}>Delete</button>
</li>
);
}
- 受控复选框:
checked绑定todo.completed,onChange触发onToggle并传递id。 - 删除按钮:直接调用
onDelete。
// TodoList.jsx
export default function TodoList({ todos, onDelete, onToggle }) {
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onDelete={onDelete}
onToggle={onToggle}
/>
))}
</ul>
);
}
key属性:React 列表渲染必须提供稳定且唯一的key,这里使用todo.id。
6. 自定义 Hook 的核心规则与常见误区
编写自定义 Hook 时必须遵守 React 的两条铁律,否则会导致难以调试的错误。
6.1 只在顶层调用 Hook
规则:不要在循环、条件判断或嵌套函数中调用 useState、useEffect 或任何自定义 Hook。
原因:React 依赖 Hook 的调用顺序来正确关联 state 和副作用。如果顺序发生变化,React 无法知道哪个 state 对应哪个 useState。
错误示例:
function MyComponent({ condition }) {
if (condition) {
const [data, setData] = useState(null); // ❌ 条件调用
}
// ...
}
正确做法:将条件逻辑放在 Hook 内部。
function useCustomHook(condition) {
const [data, setData] = useState(null);
useEffect(() => {
if (condition) {
// 执行逻辑
}
}, [condition]);
}
6.2 只在 React 函数组件或自定义 Hook 中调用 Hook
规则:不要在普通的 JavaScript 函数中调用 Hook。
原因:Hook 依赖 React 的调度机制,只有组件或 Hook 内部才能保证正确的上下文。
6.3 依赖数组必须诚实
当使用 useEffect、useCallback、useMemo 时,依赖数组必须包含所有在副作用中使用的外部变量(包括 state、props、函数等)。如果遗漏依赖,会导致闭包捕获旧值,产生“过期状态”问题。
错误示例:
useEffect(() => {
console.log(count); // 使用了 count
}, []); // ❌ 缺少 count 依赖
正确做法:
useEffect(() => {
console.log(count);
}, [count]); // ✅ 包含所有依赖
如果某些依赖频繁变化但你不希望重新执行副作用,可以考虑:
- 使用
useRef保存最新值。 - 重构逻辑,将副作用拆分为多个
useEffect。 - 使用
useReducer代替。
6.4 图表:Hook 调用规则示意
graph TD
A[函数组件 / 自定义 Hook] -->|允许调用| B[useState]
A -->|允许调用| C[useEffect]
A -->|允许调用| D[自定义 Hook]
E[普通函数 / 循环 / 条件] -->|禁止调用| B
E -->|禁止调用| C
E -->|禁止调用| D
style E fill:#f9f,stroke:#333,stroke-width:2px
style A fill:#9f9,stroke:#333,stroke-width:2px
7. 自定义 Hook 的设计原则
7.1 何时抽取 Hook?
- 两个或以上组件使用相同的有状态逻辑(包含副作用)。
- 逻辑本身复杂度高,需要独立测试或维护。
- 逻辑中使用了多个内置 Hook(
useState、useEffect、useRef等),抽取后能提高可读性。
7.2 Hook 的命名规范
- 必须以
use开头,后跟大写字母(如useMouse)。 - 返回值可以是任意类型:对象、数组、基本类型。
7.3 Hook 的依赖管理
useEffect、useCallback、useMemo的依赖数组必须完整声明内部使用的所有外部变量(包括 state 和 props)。- 如果依赖频繁变化,考虑重构逻辑或使用
useReducer。
7.4 图表:内置 Hook 与自定义 Hook 的关系
graph TD
BuiltIn[React 内置 Hook] --> useState
BuiltIn --> useEffect
BuiltIn --> useContext
BuiltIn --> useReducer
Custom[自定义 Hook] -->|可调用任意内置 Hook| useState
Custom -->|也可调用其他自定义 Hook| useMouse
Custom -->|组合逻辑| useTodos
Component[函数组件] -->|使用| useState
Component -->|使用| Custom
8. 总结
通过亲手实现 useMouse 和 useTodos,我们深入理解了:
- 自定义 Hook 的本质:封装可复用的状态逻辑函数。
- 生命周期管理:
useEffect的依赖数组和清理函数是避免内存泄漏的关键。 - localStorage 持久化:结合
useState惰性初始化 +useEffect自动同步。 - 组件组合:父组件调用自定义 Hook,子组件通过 props 接收数据和回调,形成清晰的数据流。
- 铁律:只在顶层调用 Hook,只在 React 函数中调用 Hook,依赖数组要诚实。
最后提醒:自定义 Hook 虽然强大,但要遵循 React 的规则。当你发现项目中重复出现相似的逻辑时,勇敢地抽成一个 Hook 吧!