从入门到进阶:手写React自定义Hooks,让你的组件更简洁
大家好,今天我们来聊聊React中非常实用的自定义Hooks。通过两个实际例子(鼠标位置追踪和Todo待办应用),带你从零开始封装自己的Hooks,彻底理解“逻辑复用”的魅力,并掌握如何避免常见的内存泄漏问题。
什么是Hooks?
Hooks是React 16.8引入的一种函数式编程思想,它让我们在函数组件中使用状态和生命周期等特性。React内置了useState、useEffect等基础Hooks,而自定义Hooks则是将组件逻辑提取到可复用的函数中,以use开头,内部可以调用其他Hooks。
自定义Hooks的好处:
- 复用状态逻辑,避免重复代码
- 让UI组件更纯粹,只关注渲染
- 便于团队维护和共享核心逻辑
第一部分:不使用自定义Hooks实现鼠标追踪(并理解内存泄漏)
我们先从一个简单的需求开始:实时显示鼠标在页面上的位置。直接在App组件里写逻辑。
直接在组件内实现
新建App.jsx,代码如下:
import { useState, useEffect } from 'react';
function MouseMove() {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
// 鼠标移动时的更新函数
const updateMouse = (e) => {
setX(e.clientX);
setY(e.clientY);
};
// 监听 mousemove 事件
window.addEventListener('mousemove', updateMouse);
console.log('添加事件监听');
// 清理函数:组件卸载时移除监听,防止内存泄漏
return () => {
window.removeEventListener('mousemove', updateMouse);
console.log('移除事件监听');
};
}, []); // 空依赖数组,只在挂载时执行一次
return (
<div>
鼠标位置:{x} , {y}
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
return (
<>
{count}
<button onClick={() => setCount(count => count + 1)}>
// 点击重新挂载函数
增加
</button>
{count % 2 === 0 && <MouseMove />}
</>
);
}
代码解析:
useState用来存储鼠标坐标,初始值为0。useEffect接受两个参数:第一个是副作用函数,第二个是依赖数组。这里依赖数组为[],表示副作用只在组件挂载时执行一次,不会在每次渲染后重复执行。- 在副作用函数中,我们定义了
updateMouse,并通过addEventListener注册了mousemove事件。 - 重点:
useEffect可以返回一个清理函数,React 会在组件卸载时调用它。我们在清理函数中移除了事件监听,这是避免内存泄漏的关键。
⚠ 如果不清理会发生什么?
想象一下:如果组件卸载时没有移除 mousemove 监听,那么 updateMouse 函数仍然存在于内存中,并且每次鼠标移动都会尝试调用 setX 和 setY。但此时组件已经被销毁,这些 setState 调用是无意义的,并且会导致内存泄漏——事件处理函数持有对组件作用域的引用,垃圾回收无法释放相关内存。长时间运行的应用可能会因此变得卡顿甚至崩溃。
验证方法:注释掉 return () => {...} 这一部分,然后反复点击按钮让 MouseMove 组件挂载/卸载,观察控制台。你会发现即使组件卸载了,鼠标移动时控制台依然打印“添加事件监听”(实际上并没有重新添加,但之前添加的监听还在),说明事件处理函数依然存活。这就是内存泄漏的表现。
没清理的效果图
我们看到就算函数已经卸载,事件依然会执行
清理后的效果图
可以看到,函数卸载后,事件不会执行了
第二部分:提取自定义Hook useMouse
我们把鼠标追踪的逻辑封装成一个独立的Hook,放在 hooks/useMouse.js 中。
创建 useMouse.js
import { useState, useEffect } from 'react';
/**
* 自定义 Hook:追踪鼠标在页面上的位置
* @returns {{ x: number, y: number }} 包含当前鼠标坐标的对象
*/
export const useMouse = () => {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useEffect(() => {
/** @param {MouseEvent} e 原生鼠标事件对象 */
const update = (e) => {
setX(e.clientX);
setY(e.clientY);
};
window.addEventListener('mousemove', update);
console.log('useMouse: 添加监听');
// 清理函数:组件卸载时移除监听
return () => {
window.removeEventListener('mousemove', update);
console.log('useMouse: 移除监听');
};
}, []); // 依赖数组为空,保证只在挂载时执行一次
return { x, y };
};
API 详细解释:
useMouse是一个自定义 Hook,它内部使用了 React 的useState和useEffect。- 返回值:一个包含
x和y的对象,类型均为number,表示当前鼠标坐标。 - 副作用:在组件挂载时添加
mousemove监听,卸载时移除。这里的清理逻辑与前面相同,但被封装在 Hook 内部,任何使用useMouse的组件都会自动获得正确的生命周期管理,无需重复编写清理代码。
在组件中使用 useMouse
现在改造 App.jsx,引入 useMouse:
import { useState } from 'react';
import { useMouse } from './hooks/useMouse';
function MouseMove() {
const { x, y } = useMouse(); // 一行代码搞定!
return (
<div>
鼠标位置:{x} , {y}
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(c => c + 1)}>
点击重新挂载 MouseMove 组件
</button>
{count % 2 === 0 && <MouseMove />}
</>
);
}
现在 MouseMove 组件变得非常简洁,只负责渲染,逻辑全在 useMouse 中。如果其他地方也需要鼠标位置,直接调用 useMouse 即可,真正做到了“一次封装,多处复用”。
第三部分:使用自定义Hooks实现Todo应用(带本地存储)
接下来,我们实现一个更复杂的例子:带本地存储的Todo待办应用。我们将创建一个 useTodos Hook,封装所有todos的状态管理和持久化。
1. 编写 useTodos.js
这个Hook负责:
- 管理todos数组(增、删、改完成状态)
- 自动同步到localStorage,实现数据持久化
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos'; // 本地存储的键名,统一管理便于维护
/**
* 从 localStorage 加载待办数据
* @returns {Array} 存储的待办数组,如果没有则返回空数组
*/
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
/**
* 将待办数据保存到 localStorage
* @param {Array} todos - 待办数组
*/
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
/**
* 自定义 Hook:管理待办事项的所有逻辑(增删改、本地存储同步)
* @returns {{
* todos: Array,
* addTodo: (text: string) => void,
* toggleTodo: (id: number|string) => void,
* deleteTodo: (id: number|string) => void
* }}
*/
export const useTodos = () => {
// 使用函数形式初始化,避免每次渲染都重新读取 localStorage
const [todos, setTodos] = useState(loadFromStorage);
// 每当 todos 变化,自动保存到 localStorage
useEffect(() => {
saveToStorage(todos);
}, [todos]); // 依赖 todos,只有 todos 变化时才执行
/**
* 添加新待办
* @param {string} text - 待办内容
*/
const addTodo = (text) => {
setTodos([
...todos,
{
id: Date.now(), // 简单用时间戳作为临时唯一 ID(仅用于演示)
text,
completed: false
}
]);
};
/**
* 切换指定待办的完成状态
* @param {number|string} id - 待办项的 ID
*/
const toggleTodo = (id) => {
setTodos(
todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed } // 切换状态,保持其他属性不变
: todo
)
);
};
/**
* 删除指定待办
* @param {number|string} id - 待办项的 ID
*/
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 返回所有状态和操作
return {
todos,
addTodo,
toggleTodo,
deleteTodo
};
};
API 详细解释:
loadFromStorage:内部辅助函数,用于读取本地存储,返回待办数组或空数组。saveToStorage:内部辅助函数,将待办数组序列化后存入本地存储。useTodos返回值:todos:当前待办列表,每个待办对象包含id(number/string)、text(string)、completed(boolean)。addTodo(text):接收待办文本,创建一个新待办(id为当前时间戳,completed为false),并更新状态。toggleTodo(id):接收待办id,遍历todos,找到对应项并反转其completed属性。deleteTodo(id):接收待办id,过滤掉该项,更新状态。
关于内存泄漏的再次提醒:虽然本Hook中没有显式的事件监听或定时器,但 useEffect 依赖 [todos],会在每次 todos 变化后执行保存操作。这里没有清理函数的必要,因为保存操作是安全的。但如果我们在 useEffect 中启动了定时器或订阅了外部事件,就必须返回清理函数。
2. 编写UI组件(每个组件都带有详细API注释)
TodoInput.jsx - 输入框
import { useState } from 'react';
/**
* 待办输入表单组件
* @param {Object} props
* @param {Function} props.onAddTodo - 添加待办的回调函数,接收待办文本作为参数
*/
export default function TodoInput({ onAddTodo }) {
const [text, setText] = useState('');
/**
* 表单提交处理函数
* @param {Event} e - 表单提交事件对象
*/
const handleSubmit = (e) => {
e.preventDefault(); // 阻止页面刷新
const trimmedText = text.trim();
if (!trimmedText) return; // 输入为空时不添加
onAddTodo(trimmedText); // 调用父组件传递的添加函数
setText(''); // 清空输入框
};
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="输入待办事项..."
/>
</form>
);
}
TodoItem.jsx - 单个待办项
/**
* 单个待办项组件
* @param {Object} props
* @param {Object} props.todo - 待办对象 { id, text, completed }
* @param {Function} props.onDeleteTodo - 删除待办的回调,接收待办 id
* @param {Function} props.onToggleTodo - 切换完成状态的回调,接收待办 id
*/
export default function TodoItem({ todo, onDeleteTodo, onToggleTodo }) {
return (
<li className="todo-item">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggleTodo(todo.id)}
/>
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
<button onClick={() => onDeleteTodo(todo.id)}>删除</button>
</li>
);
}
TodoList.jsx - 待办列表
import TodoItem from './TodoItem';
/**
* 待办列表组件,渲染所有待办项
* @param {Object} props
* @param {Array} props.todos - 待办数组
* @param {Function} props.onDeleteTodo - 删除待办的回调
* @param {Function} props.onToggleTodo - 切换完成状态的回调
*/
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 中组装
import { useTodos } from './hooks/useTodos';
import TodoList from './components/TodoList';
import TodoInput from './components/TodoInput';
export default function App() {
// 直接使用自定义Hook,获取所有状态和方法
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
return (
<>
<h1>Todo 待办清单</h1>
<TodoInput onAddTodo={addTodo} />
{todos.length > 0 ? (
<TodoList
todos={todos}
onDeleteTodo={deleteTodo}
onToggleTodo={toggleTodo}
/>
) : (
<div>暂无待办事项,添加一条吧~</div>
)}
</>
);
}
效果:
- 添加待办:输入文本,回车提交。
- 勾选复选框切换完成状态,文字样式变化(可自行添加CSS,比如加删除线)。
- 点击删除按钮移除该项。
- 刷新页面,数据依然存在,因为已经同步到localStorage。
效果图
第四部分:深入理解内存泄漏与清理的必要性
在React函数组件中,useEffect 是处理副作用的主要场所。常见的副作用包括:
- 订阅外部事件(如
mousemove、resize、WebSocket) - 设置定时器(
setInterval、setTimeout) - 手动修改DOM
- 数据请求(虽然请求本身不需要清理,但需要处理竞态)
所有这些副作用,如果在组件卸载后没有正确清理,都会导致内存泄漏。例如:
- 事件监听:组件卸载后,事件处理函数仍然被全局对象(如
window)引用,导致组件内部的状态变量和函数无法被垃圾回收。 - 定时器:即使组件卸载,定时器仍然会周期性触发回调,如果回调中使用了
setState,会报“在未挂载的组件上调用setState”的警告,并且造成内存泄漏。 - 订阅:类似于事件监听,必须取消订阅。
如何避免?
在 useEffect 中返回一个清理函数,React 会在组件卸载前和执行下一次副作用前调用它。这个清理函数应该:
- 移除事件监听
- 清除定时器
- 取消订阅
- 中止请求(如果支持)
示例:错误的写法(导致内存泄漏)
useEffect(() => {
const timer = setInterval(() => {
console.log('定时器执行');
}, 1000);
// 没有返回清理函数!
}, []);
组件卸载后,定时器依然运行,回调中的代码可能访问已经不存在的组件状态。
正确的写法
useEffect(() => {
const timer = setInterval(() => {
console.log('定时器执行');
}, 1000);
return () => clearInterval(timer); // 清理定时器
}, []);
在我们的 useMouse 例子中,我们正是通过返回清理函数来移除事件监听,确保了无论组件如何挂载/卸载,都不会留下残留的监听器。
总结
通过两个例子,我们见证了自定义Hooks的强大:
- useMouse:将副作用(事件监听)和状态封装起来,组件只需调用并渲染,同时自动处理了内存泄漏的清理逻辑。
- useTodos:不仅管理状态,还集成了本地存储持久化,让UI组件完全无感。
自定义Hooks让我们能够像搭积木一样组合逻辑,保持组件简洁,提升代码复用性。在实际项目中,你可以根据自己的业务封装更多通用Hooks,比如 useLocalStorage、useFetch、useWindowSize 等。
最后请记住:每当你在 useEffect 中引入持续性的副作用(事件、定时器、订阅),务必返回一个清理函数,这是React函数组件中防止内存泄漏的基本准则。
希望这篇文章能帮你打开自定义Hooks的大门,快去动手试试吧!