💡 写在前面 :你是否还在为 React 组件里那一堆乱糟糟的
useState和useEffect感到头秃?是否觉得业务逻辑和 UI 代码像缠在一起的耳机线一样难解难分?别慌,今天咱们不聊虚的。我们要化身"代码外科医生",拿起 Custom Hooks(自定义 Hooks) 这把手术刀,把业务逻辑从组件里漂亮地剥离出来。
本文将通过两个实战案例------"鼠标追踪器" 和 "硬核 TodoList",带你从零开始领悟 Hooks 的设计哲学。准备好了吗?发车!🚗💨
🧐 第一章:Hooks 到底是个啥?
在 React 16.8 之前,函数组件就是个"花瓶",只负责渲染 UI,没有状态(State),也没有生命周期。如果你想搞点复杂的逻辑,就得写那个笨重的 Class 组件,this 指针指来指去,指到你怀疑人生。
Hooks 的出现,就是为了给函数组件注入灵魂。
它是一种函数式编程思想的体现。简单来说,Hooks 就是一堆以 use 开头的魔法函数,它们让函数组件也能拥有状态管理和生命周期处理的能力。
常用"双子星"
在我们开始自定义 Hooks 之前,必须先复习一下两个最基础的 Hooks,因为自定义 Hooks 本质上就是对它们的封装 和复用。
-
useState:状态的容器。- 它让函数组件有了"记忆"。
const [state, setState] = useState(initialValue);- 记住:React 的状态更新是**不可变(Immutable)**的,不要直接修改
state,要用setState传入新值。
-
useEffect:副作用的管家。- 什么是副作用?数据获取、订阅事件、修改 DOM... 凡是跟渲染 UI 没直接关系的事儿,都叫副作用。
- 它相当于 Class 组件里的
componentDidMount、componentDidUpdate和componentWillUnmount的合体。 useEffect(() => { ... return () => cleanup }, [dependencies])。
好,基础复习完毕。现在我们要搞点高级的------自定义 Hooks。
🌟 核心概念:自定义 Hooks 就是一个普通的 JavaScript 函数,但它遵循两个规则:
- 名字必须以
use开头(这是给 React 插件看的,也是给队友看的)。- 它可以调用其他的 Hooks(这是它强大的根本原因)。
🐭 第二章:初试牛刀------打造"如影随形"的鼠标追踪器
想象一下,你接到了一个需求:在页面的任何地方,都要实时显示当前鼠标的坐标。
如果你把逻辑直接写在组件里,你的组件很快就会变得臃肿。如果好几个组件都需要这个功能呢?难道要复制粘贴代码?No! DRY (Don't Repeat Yourself)!
我们要封装一个 Hook。
2.1 顺藤摸瓜:从 App.jsx 看起
先来看看我们在 App.jsx 里是想怎么使用它的:
javascript
// 引入我们即将编写的神器
import { useMouse } from './hooks/useMouse.js';
// ... 其他引入
function MouseMove() {
const { X, Y } = useMouse();
return (
<>
<div>
鼠标位置:{X}, {Y}
</div>
</>
)
}
😲 看!这里多么清爽!
我们不需要关心鼠标怎么监听,不需要关心事件怎么销毁
我们只管"拿"数据,这就是"声明式"编程的美妙
组件变得极其纯粹,它只负责渲染 。所有的脏活累活,都扔给了
useMouse。
2.2 核心解密:useMouse.js
接下来,我们潜入 useMouse.js,看看这个 Hook 内部到底长什么样。
🎯 封装响应式的 mouse 业务
- 为什么要封装?
- 因为 UI 组件应该更简单,只负责 HTML + CSS。 逻辑复用,是前端团队的核心资产!
javascript
import {
useState,
useEffect
} from 'react';
export const useMouse = () => {
1️⃣ 定义状态:我们需要记录 X 和 Y 坐标
scss
const [X, setX] = useState(0);
const [Y, setY] = useState(0);
2️⃣ 定义事件处理函数
- 这个函数会在每次鼠标移动时被调用
scss
useEffect(() => {
const update = (event) => {
// 更新状态,这将触发使用了该 Hook 的组件重新渲染
setX(event.clientX);
setY(event.clientY);
}
3️⃣ 绑定事件监听
javascript
// 相当于 componentDidMount
window.addEventListener('mousemove', update);
4️⃣ ⚠️ 极其重要:清理副作用!
- 如果不写这个 return 函数,当组件卸载时,事件监听器依然存在。
- 这会导致严重的【内存泄漏】,控制台会疯狂报错,浏览器会变卡。
javascript
// 相当于 componentWillUnmount
return () => {
window.removeEventListener('mousemove', update);
}
}, []); // 👈 注意这个空数组
// 依赖项为空数组 [],意味着这个 effect 只在组件挂载时执行一次,
// 并且在组件卸载时执行清理函数。
5️⃣ 返回数据
kotlin
// 把组件需要的状态暴露出去
return {
X,
Y
}
}
🔍 深度解析:
- 状态驱动 :我们要追踪鼠标,本质上就是追踪
x和y两个数字的变化。所以用了两个useState。 - 副作用管理 :监听
window的mousemove事件是一个典型的副作用。 - 依赖项陷阱 :
- 如果
useEffect的第二个参数不传,它会在每次渲染后都执行。如果你在这里绑定事件,那完了,你会绑定几千个监听器。 - 传入
[],告诉 React:"嘿,这事儿只在组件出生和死亡时做一次,中间别烦我。"
- 如果
- 内存泄漏(Memory Leak) :
- 这是 React 面试必考题。
- 在
readme.md中也提到了这一点:"组件卸载时需要清除事件监听/定时器,否则会导致内存泄漏"。 - React 的
useEffect允许返回一个函数,这个函数就是专门用来擦屁股的。一定要记得removeEventListener!
📝 第三章:进阶实战------企业级 TodoList 逻辑分离
鼠标追踪只是热身,现在我们要搞点真家伙。我们要写一个 TodoList(待办事项清单)。
你可能会说:"切,TodoList 我闭着眼都能写。"
别急,这次我们不写面条代码。我们要把所有的业务逻辑 (增、删、改、查、持久化)全部抽离到一个 useTodos Hook 中。这就叫 Headless UI(无头组件) 设计思想------逻辑与视图分离。
3.1 顶层设计:App.jsx 的视角
先看 App.jsx,它是怎么组织这个应用的。
javascript
import { useTodos } from './hooks/useTodos.js';
// ... 引入组件
export default function App() {
// ...
scss
const {
todos, // 数据列表
addTodo, // 添加方法
toggleTodo, // 切换状态方法
deleteTodo // 删除方法
} = useTodos();
✨ 魔法时刻 ✨
- 一行代码,获取了整个 Todo 应用所需的所有数据和方法!
- 就像去超市买了一个"Todo大礼包",回家拆开就能用。
javascript
return (
<>
{/* 把方法传给输入组件 */}
<TodoInput addTodo={addTodo} />
{/* 条件渲染:有数据才显示列表 */}
{
todos.length > 0 ?
(<TodoList
todos={todos}
toggleTodo={toggleTodo}
deleteTodo={deleteTodo}
/>) :
(<div>暂无待办事项</div>)
}
</>
)
}
妙啊! App.jsx 变得极其干净。它根本不知道 Todo 是怎么存的,也不知道删除逻辑是 filter 还是 splice,它只负责传递。
3.2 核心引擎:useTodos.js
这是本篇文章的重头戏。我们深入 useTodos.js 看看它是怎么运作的。
javascript
import {
useState,
useEffect
} from 'react';
⭐常量提取,好维护
ini
const STORAGE_KEY = 'todos';
🛠️ 辅助函数:从 LocalStorage 读取数据
- 放在组件外面,因为它不依赖组件内的任何状态,纯函数
javascript
function loadFromStorage() {
const storedTodos = localStorage.getItem(STORAGE_KEY);
return storedTodos ? JSON.parse(storedTodos) : [];
}
🛠️ 辅助函数:保存数据到 LocalStorage
javascript
function saveToStorage(todos) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
}
1️⃣. 惰性初始化 (Lazy Initialization)
这里的 useState 接收了一个函数 loadFromStorage,而不是直接通过 loadFromStorage() 调用。
为什么?
- 因为 localStorage 读取是昂贵的 IO 操作。
- 如果直接写 loadFromStorage(),每次组件渲染都会读一次 Storage。
- 传函数引用,React 只会在组件【首次渲染】时调用它。 这是一个非常高级且实用的优化技巧!🚀
ini
export const useTodos = () => {
const [todos, setTodos] = useState(loadFromStorage);
2️⃣ 数据持久化
每当 todos 状态变化时,自动同步到 localStorage。 这样用户刷新页面,数据也不会丢。
scss
useEffect(() => {
saveToStorage(todos);
}, [todos]); // 依赖项是 todos
3️⃣ 业务逻辑:
多使用es6新特性如结构,map,filter
javascript
// 添加 Todo~~~~~~~~~
const addTodo = (text) => {
// ⚠️ 永远不要直接修改 state,比如 todos.push(...) 是绝对禁止的!
// 必须创建一个新数组。
setTodos([
...todos, // 展开旧数据
{
id: Date.now(), // 用时间戳做 ID
text,
completed: false
}
])
}
// 切换完成状态~~~~~~~~~~
const toggleTodo = (id) => {
setTodos(
todos.map(todo => {
if(todo.id === id) {
// 同样,不要直接修改 todo.completed = !todo.completed
// 要返回一个新的对象
return {
...todo,
completed: !todo.completed
}
}
return todo;
})
)
}
// 删除 Todo~~~~~~~~~
const deleteTodo = (id) => {
// filter 返回的是新数组,完美符合 React 的不可变性要求
setTodos(
todos.filter(todo => todo.id !== id)
);
}
4️⃣ 暴露接口
kotlin
// 返回一个对象,方便使用者解构
return {
todos,
addTodo,
toggleTodo,
deleteTodo
}
}
💡 知识点总结:
- Lazy Initialization(惰性初始化) :
useState(() => heavyComputation())。这招在处理大数据初始化时非常有用,能显著提升性能。 - Immutability(不可变性) :你看
addTodo用了[...todos],toggleTodo用了map,deleteTodo用了filter。这些都是生成新数组的方法,而不是修改原数组。这是 React 状态更新的金科玉律。 - 关注点分离 :
useTodos只管数据怎么变 ,不管数据怎么展示。
3.3 组件落地:三剑客的配合
逻辑有了,现在看看 UI 组件怎么消费这些逻辑。
1. 也是"大脑"的延伸:TodoInput.jsx
TodoInput.jsx 负责收集用户输入。
javascript
import { useState } from 'react';
export default function TodoInput ({addTodo}) {
// 这个 state 是 UI 状态(输入框里的字),属于组件私有
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault(); // 阻止表单默认提交刷新页面
if(!text.trim()) return; // 空校验
// 调用父组件(其实是 Hook)传来的方法
addTodo(text.trim());
// 清空输入框
setText('');
}
return (
<form className="todo-input" onSubmit={handleSubmit}>
<input
type="text"
value={text} // 受控组件:value 绑定 state
onChange={e => setText(e.target.value)} // onChange 更新 state
/>
<button type="submit">添加</button>
</form>
)
}
这里体现了 React 的**受控组件(Controlled Components)**思想:Input 的值由 React 的 State 掌控,而不是 DOM 自身。
2. 中转站:TodoList.jsx
TodoList.jsx 其实是个"傻瓜组件"(Dumb Component),它只负责遍历。
javascript
import TodoItem from './TodoItem';
export default function TodoList({
todos,
toggleTodo,
deleteTodo
}) {
return (
<ul className="todo-list">
{
todos.map(todo => (
// 🔑 key 属性至关重要!
// 它帮助 React 识别哪些元素改变了、添加了或删除了。
// 这里的 key={todo.id} 是最佳实践,千万别用 key={index}!
<TodoItem
key={todo.id}
todo={todo}
toggleTodo={toggleTodo}
deleteTodo={deleteTodo}
/>
))
}
</ul>
)
}
3. 最终呈现:TodoItem.jsx
TodoItem.jsx 负责渲染单条数据。
javascript
export default function TodoItem({
todo,
toggleTodo,
deleteTodo
}) {
return (
<li className="todo-item">
{/* 复选框:控制完成状态 */}
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{/* 动态类名:控制样式 */}
<span className={todo.completed ? 'completed' : ''}>
{todo.text}
</span>
{/* 删除按钮 */}
<button onClick={() => deleteTodo(todo.id)}>删除</button>
</li>
)
}
🎩 总结:自定义 Hooks 的"心法"
通过这两个例子,我们其实只做了一件事:把逻辑从 View 层剥离。
为什么要这么做?
- 复用性(Reusability) :如果明天老板让你在侧边栏也做一个 Todo 列表,你只需要在侧边栏组件里
const { ... } = useTodos(),一秒搞定。 - 可测试性(Testability) :测试
useTodos里的纯逻辑,比测试一个混合了 DOM 操作的组件要简单得多。 - 清晰度(Readability):你的组件代码量减少了,逻辑更清晰了,不管是自己看还是同事看,都更舒服。
Hooks 是一种心智模型。当你看到一段复杂的逻辑时,下意识地想:"能不能把它抽成一个 Hook?" 恭喜你,你已经从 React 萌新进阶了!
希望这篇文章能帮你打通 Hooks 的任督二脉!我们下期见!👋