前言
React Hooks 自 16.8 版本引入以来,彻底改变了我们编写组件的方式。它让函数组件拥有了状态和生命周期管理能力,代码更简洁、逻辑更复用。很多同学刚接触 Hooks 时,可能会对 useState、useEffect 以及自定义 Hook 的封装感到困惑。今天,我将通过一个完整的待办事项(Todo)应用案例,逐行解析每一段代码,带你深入理解 Hooks 的核心思想与实践技巧。
本文案例代码包含:鼠标位置追踪、待办事项增删改查、本地存储持久化。所有代码都来自你提供的文件,我会逐行讲解,确保你不仅知道怎么写,更知道为什么这么写。
一、Hooks 是什么?为什么需要它?
官方定义:Hook 是 React 16.8 新增的特性,它让你可以在不编写 class 的情况下使用 state 以及其他 React 特性。
简单理解:Hooks 是一种函数式编程思想 ,以 use 开头(如 useState、useEffect),用来封装组件的状态和生命周期逻辑。你可以"呼之即来,用起来很方便"。
Hooks 带来的好处:
- 逻辑复用:自定义 Hook 可以将组件间重复的状态逻辑抽离,替代了早期的 HOC(高阶组件)和 Render Props。
- 更清晰的代码组织 :相关逻辑放在一起,而不是分散在
componentDidMount、componentDidUpdate等生命周期中。 - 函数组件更友好:函数组件轻量、简单,配合 Hooks 后能力完全不输类组件。
接下来,我们通过实际代码,从最简单的 useState 开始,一步步构建一个完整的应用。
项目完整链接:gitee.com/hong-strong...
二、项目结构与文件概览
bash
src/
├── main.jsx # 入口文件
├── App.jsx # 根组件
├── components/
│ ├── TodoInput.jsx # 新增待办输入框
│ ├── TodoList.jsx # 待办列表容器
│ └── TodoItem.jsx # 单个待办项
├── hooks/
│ ├── useTodos.js # 待办事项逻辑(自定义 Hook)
│ └── useMouse.js # 鼠标位置追踪(自定义 Hook)
└── index.css # 样式(略)
我们将从入口开始,逐行解析每个文件,重点讲解 Hooks 的使用。
三、入口文件 main.jsx --- 渲染根组件
jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<App />,
)
逐行解析:
-
import { StrictMode } from 'react'
StrictMode是一个工具组件,用于检测项目中潜在的问题(如不安全的生命周期、过时 API 等)。它不会渲染任何 UI,仅在开发模式下运行额外检查。 -
import { createRoot } from 'react-dom/client'React 18 引入的新 API,用于创建根节点并渲染 React 组件。相比之前的
ReactDOM.render,createRoot支持并发特性。 -
import './index.css'--- 引入全局样式。 -
import App from './App.jsx'--- 引入根组件。 -
createRoot(document.getElementById('root')).render(<App />)- 获取 HTML 中
<div id="root"></div>的 DOM 元素。 - 调用
createRoot创建 React 根节点。 - 调用
.render()方法将<App />组件渲染到页面上。
- 获取 HTML 中
注意: 原代码中
render(<App />)后面多了一个逗号,但语法上没问题。另外,StrictMode虽然导入了但没有使用,可以忽略或加上。
四、自定义 Hook:useMouse.js --- 响应式鼠标位置
这个文件演示了如何使用 useState 和 useEffect 封装一个带清理功能的副作用。
jsx
import { useState, useEffect } from 'react';
- 从 React 中导入两个核心 Hook。
jsx
export const useMouse = () => {
const [x, setX] = useState(0);
const [y, setY] = useState(0);
useState(0):定义一个状态变量x,初始值为 0,setX是更新函数。同理y。- 返回值是一个数组(或者对象),这里返回对象便于解构。
jsx
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);
}
}, [])
重点讲解 useEffect:
useEffect接收两个参数:一个函数(副作用函数)和一个依赖数组。- 副作用函数会在组件挂载后 、以及依赖项变化后 执行。这里依赖数组是空数组
[],表示只在组件挂载时执行一次(类似componentDidMount)。 - 副作用函数内部:定义
update函数,它根据鼠标事件更新x和y;然后添加全局mousemove事件监听。 - 返回值(清理函数) :在组件卸载时执行,用于移除事件监听,防止内存泄漏。 内存泄漏是很多初学者容易忽略的问题。如果忘记清理,组件卸载后事件监听依然存在,导致 setState 在已卸载组件上执行,引发警告甚至性能问题。
- 控制台打印可以帮你观察副作用和清理的执行时机。
jsx
return {
x,
y
}
}
- 返回一个包含
x和y的对象,供组件使用。
知识点总结:
useState管理局部状态。useEffect处理副作用(事件监听、定时器、网络请求等)。- 清理函数的重要性(避免内存泄漏)。
五、自定义 Hook:useTodos.js --- 待办事项业务逻辑
这是核心业务 Hook,封装了待办事项的增删改查以及本地存储持久化。
jsx
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'todos'; // 好维护
- 定义存储键名常量,方便维护和修改。
jsx
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
- 辅助函数:从
localStorage读取已保存的待办列表,如果没有则返回空数组。
jsx
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
- 辅助函数:将待办列表保存到
localStorage。
jsx
export const useTodos = () => {
const [todos, setTodos] = useState(loadFromStorage);
- 这里有一个重要技巧 :
useState的参数可以是函数(惰性初始化)。
loadFromStorage只会在组件初次渲染时执行一次,避免每次都调用localStorage读取。如果直接写useState(loadFromStorage()),那么每次渲染都会执行该函数(虽然只在初次使用值,但不推荐)。
jsx
useEffect(() => {
saveToStorage(todos);
}, [todos])
- 每当
todos发生变化时,自动将最新列表保存到本地存储。
依赖数组[todos]表示仅在todos改变时执行副作用。
jsx
const addTodo = (text) => {
setTodos([
...todos,
{
id: Date.now(),
text,
completed: false
}
])
}
addTodo:接收文本,创建一个新待办对象(id 用时间戳,简单但不唯一,演示够用),然后更新todos状态。
注意:使用展开运算符...todos创建新数组,符合 React 不可变数据原则。
jsx
const toggleTodo = (id) => {
setTodos(
todos.map(todo => {
if (todo.id === id) {
return { ...todo, completed: !todo.completed }
}
return todo;
})
)
}
toggleTodo:切换指定 id 的待办完成状态。通过map生成新数组,并翻转目标项的completed属性。
jsx
const deleteTodo = (id) => {
setTodos(
todos.filter(todo => todo.id !== id)
)
}
deleteTodo:通过filter过滤掉指定 id 的项,得到新数组。
jsx
return {
todos,
addTodo,
toggleTodo,
deleteTodo
}
}
- 向外暴露状态和操作方法。
知识点总结:
- 自定义 Hook 就是一个普通函数,名字以
use开头,内部可以调用其他 Hook(如useState、useEffect)。 - 通过自定义 Hook,将多个组件的共享逻辑抽离,让组件本身保持简洁。
localStorage的使用配合useEffect实现自动持久化。
六、展示组件:TodoItem.jsx --- 单个待办项
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>
)
}
- 这是一个纯展示组件(也叫无状态组件),没有内部状态,只依赖外部传入的 props。
todo对象包含id、text、completed。- 复选框的
checked属性绑定todo.completed,onChange触发父组件传入的onToggle。 - 文本根据
completed状态动态添加completed类名(样式可设为删除线)。 - 删除按钮调用
onDelete。
组件通信:数据自上而下(父传子),事件自下而上(子调父)。这种模式让组件可复用、易测试。
七、展示组件:TodoList.jsx --- 待办列表
jsx
import TodoItem from './TodoItem.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>
)
}
- 接收
todos数组以及两个回调函数。 - 使用
map遍历todos,为每个待办生成一个TodoItem。 - 必须添加
key属性,帮助 React 识别列表中哪些项发生了变化(提高 diff 性能)。通常用唯一且稳定的 id,不要用数组索引。 onDelete和onToggle直接透传给子组件。
八、展示组件:TodoInput.jsx --- 新增待办输入框
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>
)
}
逐行解析:
import { useState } from 'react'--- 导入 useState。const [text, setText] = useState('')--- 管理输入框的文本状态,初始为空字符串。handleSubmit:表单提交时的处理函数。e.preventDefault()阻止页面刷新。- 如果去除首尾空格后为空,则直接返回,不添加空待办。
- 调用父组件传入的
onAddTodo,将文本传出去。 - 清空输入框
setText("")。
- 返回的表单中,
input的value绑定text,onChange更新状态。这是一个受控组件,React 控制输入框的值。
为什么要用受控组件?
因为需要实时获取用户输入并验证(如 trim 后是否为空),同时方便提交后清空输入框。受控组件让数据流更清晰。
九、根组件 App.jsx --- 组装一切
jsx
import { useMouse } from './hooks/useMouse.js';
import { useTodos } from './hooks/useTodos.js';
import TodoList from './components/TodoList.jsx';
import TodoInput from './components/TodoInput.jsx';
- 导入两个自定义 Hook 和两个展示组件。
jsx
function MouseMove() {
const { x, y } = useMouse();
return (
<>
<div>
鼠标位置:{x} {y}
</div>
</>
)
}
- 这是一个额外的小组件,演示
useMouse的用法。它会实时显示鼠标坐标。
注意:原 App 中这个组件被注释掉了,稍后我们会放开它来展示效果。
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>
)
}
{/* 下面是被注释的示例代码,后面会解释 */}
</>
)
}
逐行解析:
- 调用
useTodos()获取待办状态和操作方法。 - 渲染
TodoInput组件,传入addTodo方法。 - 三元表达式:如果
todos长度大于 0,渲染TodoList并传递必要的 props;否则显示"暂无待办事项"。 - 下方注释的部分原本包含
count状态和一个按钮,以及条件渲染的<MouseMove />。这些不影响主功能,但可以作为扩展演示。
注意:原
App.jsx中并没有导入useState和MouseMove未使用,为了代码整洁,我们可以保持主逻辑清晰。
十、组合与运行:所有片段整合
现在,把 App.jsx 中注释的 MouseMove 功能打开,看看 useMouse 如何独立工作:
jsx
// 在 App 组件中添加
import { useState } from 'react'; // 需要额外导入
// ... 其他导入
export default function App() {
const { todos, addTodo, deleteTodo, toggleTodo } = useTodos();
const [count, setCount] = useState(0); // 添加计数示例
return (
<>
<TodoInput onAddTodo={addTodo}/>
{todos.length > 0 ? (
<TodoList onDelete={deleteTodo} onToggle={toggleTodo} todos={todos}/>
) : (
<div>暂无待办事项</div>
)}
<hr />
<div>计数:{count}</div>
<button onClick={() => setCount(count + 1)}>增加</button>
{count % 2 === 0 && <MouseMove />}
</>
)
}
- 当
count为偶数时,MouseMove组件会渲染并开始追踪鼠标;奇数时组件卸载,控制台会打印"|||||| 清除",证明清理函数执行。 - 这个示例展示了条件渲染与 Hooks 生命周期的配合:组件挂载时添加事件监听,卸载时移除,避免内存泄漏。
十一、深入理解 Hooks 的核心规则
通过上面的代码,我们实际上已经用到了几条 Hooks 的重要规则,总结如下:
1. 只在顶层调用 Hooks
不要在循环、条件或嵌套函数中调用 Hooks 。
✅ 正确:
jsx
function MyComponent() {
const [a, setA] = useState(0);
if (a > 0) {
// 但这里不能调用新的 Hook
}
}
❌ 错误:
jsx
function MyComponent() {
if (condition) {
const [a, setA] = useState(0); // 禁止
}
}
React 依赖 Hooks 的调用顺序来正确管理状态,条件调用会破坏顺序。
2. 只在 React 函数组件或自定义 Hook 中调用 Hooks
普通 JavaScript 函数中不能调用 useState、useEffect 等。
3. 使用 eslint-plugin-react-hooks
官方提供了 ESLint 插件,可以自动检查规则违反情况,强烈推荐开启。
十二、自定义 Hook 的最佳实践
从 useTodos 和 useMouse 中,我们可以总结出自定义 Hook 的几个设计原则:
- 单一职责 :一个 Hook 只做一件事。
useMouse只负责鼠标位置;useTodos只负责待办逻辑。 - 返回值清晰:返回对象(或数组)便于解构,属性名要有意义。
- 内部状态和副作用完全封装 :外部不需要知道
localStorage的 key 或事件监听细节。 - 可测试性 :自定义 Hook 可以通过
@testing-library/react-hooks进行单元测试。
十三、内存泄漏与清理函数的必要性
在 useMouse.js 中,如果忘记在 useEffect 中返回清理函数,那么每次组件挂载都会添加一个新的 mousemove 监听,而组件卸载时不会移除。多次挂载/卸载会导致多个监听同时存在,引发:
- 性能下降
- 在已卸载的组件上调用
setX、setY,React 会警告 - 严重时导致内存泄漏
正确的清理模式:
jsx
useEffect(() => {
const handler = () => { ... };
window.addEventListener('event', handler);
return () => {
window.removeEventListener('event', handler);
};
}, []);
对于定时器(setInterval),同样需要 clearInterval。
十四、性能优化小贴士
-
惰性初始 state
当初始状态计算开销大时,使用函数形式:
useState(() => expensiveCompute())。 -
依赖数组要准确
useEffect、useCallback、useMemo的依赖数组中必须包含所有外部作用域中变化的值,否则会出现闭包陈旧值的问题。 -
避免在
useEffect中频繁创建对象/函数比如事件处理函数可以定义在
useEffect外部,或者使用useCallback包裹。 -
拆分 Hook
如果一个组件逻辑复杂,可以拆分成多个自定义 Hook,提高可读性和可维护性。
十五、总结
我们从最简单的 useState 计数,到鼠标追踪的 useEffect 与清理,再到完整的待办应用与本地存储持久化,逐行解析了所有提供的代码。现在你应该能清楚地看到:
useState:管理组件内部的状态。useEffect:处理副作用,并且通过返回函数来做清理。- 自定义 Hook:将组件逻辑抽离成可复用的函数,提高代码的组织性和复用性。
React Hooks 带来的不仅仅是写法上的改变,更是一种 函数式、声明式 的编程思维。掌握了它们,你就能写出更优雅、更健壮的 React 应用。