每一次渲染都是新的开始,而 useEffect 是时光中的锚点——它提醒我们:有些事需要铭记,有些则必须放手。
本文是《从0死磕全栈》系列第5篇,手把手教你使用 React 的 useEffect Hook 实现一个功能完整的购物车系统,涵盖商品展示、添加/删除、数量修改、总价计算和本地存储持久化,深入理解副作用的执行时机与最佳实践。
一、useEffect 简介
useEffect 是 React 函数组件中用于处理副作用(Side Effects) 的核心 Hook。
✅ 什么是副作用?
副作用是指在组件渲染之外发生的操作,例如:
- 数据获取(API 请求)
- 订阅事件(如 WebSocket、DOM 事件)
- 手动修改 DOM
- 设置定时器
- 写入 localStorage / sessionStorage
🚫 注意:不能在渲染过程中直接执行这些操作,否则会阻塞 UI 渲染,影响性能。
✅ useEffect 的作用
将副作用逻辑与渲染逻辑分离,确保:
- 组件优先完成渲染;
- 副作用在渲染完成后异步执行;
- 提供清理机制,避免内存泄漏。
✅ 基本语法
useEffect(() => {
// 副作用代码(如数据请求、修改DOM)
return () => {
// 清理函数(如清除定时器、取消订阅)
};
}, [依赖项数组]); // 控制执行时机
✅ 三种常见使用场景
| 依赖项数组 | 执行时机 | 适用场景 |
|---|---|---|
不传(或 undefined) | 每次渲染后都执行 | 需要实时响应状态变化(如更新 DOM) |
空数组 [] | 仅在组件挂载时执行一次 | 初始化操作(如加载数据、绑定全局事件) |
有具体依赖 [dep1, dep2] | 仅当依赖项变化时执行 | 优化性能,避免不必要的重复执行 |
二、实战目标:构建购物车系统
我们要实现以下功能:
- 商品列表展示:显示多种水果及其价格
- 加入购物车:点击“加入购物车”按钮,商品添加至购物车
- 修改数量:输入框动态调整商品数量(最小为1)
- 移除商品:点击“移除”按钮删除商品
- 自动计算总价:根据商品数量实时计算总金额
- 本地持久化:购物车数据保存到
localStorage,刷新页面不丢失
三、动手实现
1. 定义数据类型(TypeScript)
import { useState, useEffect } from 'react';
// 商品基础信息
interface Product {
id: number;
name: string;
price: number;
}
// 购物车商品(扩展 Product,增加数量字段)
interface CartItem extends Product {
quantity: number;
}
2. 创建购物车组件(ShoppingCart.tsx)
import { useState, useEffect } from 'react';
// 商品类型定义
interface Product {
id: number;
name: string;
price: number;
}
interface CartItem extends Product {
quantity: number;
}
function ShoppingCart() {
// 商品列表(模拟静态数据)
const products: Product[] = [
{ id: 1, name: '苹果', price: 5 },
{ id: 2, name: '香蕉', price: 3 },
{ id: 3, name: '橙子', price: 4 },
{ id: 4, name: '葡萄', price: 8 },
];
// 购物车状态:初始化时尝试从 localStorage 读取
const [cart, setCart] = useState<CartItem[]>(() => {
const savedCart = localStorage.getItem('shoppingCart');
return savedCart ? JSON.parse(savedCart) : [];
});
// 总价状态
const [total, setTotal] = useState<number>(0);
// ✅ 使用 useEffect:监听 cart 变化,自动计算总价并持久化
useEffect(() => {
// 计算当前购物车总价
const newTotal = cart.reduce((sum: number, item: CartItem) => {
return sum + item.price * item.quantity;
}, 0);
setTotal(newTotal);
// 将购物车数据持久化到 localStorage
localStorage.setItem('shoppingCart', JSON.stringify(cart));
}, [cart]); // 依赖项:只有 cart 发生变化时才执行
// 添加商品到购物车
const addToCart = (product: Product): void => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) {
// 已存在:数量+1
return prevCart.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
// 不存在:新增商品
return [...prevCart, { ...product, quantity: 1 }];
}
});
};
// 从购物车移除商品
const removeFromCart = (productId: number): void => {
setCart(prevCart => prevCart.filter(item => item.id !== productId));
};
// 更新商品数量
const updateQuantity = (productId: number, newQuantity: number): void => {
if (isNaN(newQuantity)) return; // 非数字输入直接忽略
if (newQuantity < 1) {
removeFromCart(productId); // 数量小于1时自动移除
return;
}
setCart(prevCart =>
prevCart.map(item =>
item.id === productId
? { ...item, quantity: newQuantity }
: item
)
);
};
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>水果商店</h1>
{/* 商品列表 */}
<div style={{ marginBottom: '40px' }}>
<h2>商品列表</h2>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '20px',
}}
>
{products.map(product => (
<div
key={product.id}
style={{
border: '1px solid #ddd',
padding: '10px',
borderRadius: '5px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<h3 style={{ margin: '0' }}>{product.name}</h3>
<p style={{ margin: '0' }}>价格: ¥{product.price}</p>
<button
onClick={() => addToCart(product)}
style={{
padding: '5px 10px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
marginTop: 'auto',
}}
>
加入购物车
</button>
</div>
))}
</div>
</div>
{/* 购物车 */}
<div>
<h2>购物车</h2>
{cart.length === 0 ? (
<p>购物车是空的</p>
) : (
<div>
<ul style={{ listStyle: 'none', padding: 0 }}>
{cart.map(item => (
<li
key={item.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '10px',
padding: '10px',
border: '1px solid #eee',
borderRadius: '4px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span>{item.name}</span>
<span>¥{item.price} × </span>
<input
type="number"
value={item.quantity}
onChange={(e) =>
updateQuantity(item.id, parseInt(e.target.value))
}
min="1"
style={{
width: '50px',
padding: '5px',
border: '1px solid #ddd',
borderRadius: '3px',
}}
/>
<span>= ¥{item.price * item.quantity}</span>
</div>
<button
onClick={() => removeFromCart(item.id)}
style={{
padding: '5px 10px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
}}
>
移除
</button>
</li>
))}
</ul>
{/* 总价展示 */}
<div
style={{
marginTop: '20px',
fontSize: '1.2em',
fontWeight: 'bold',
padding: '10px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
}}
>
总计: ¥{total}
</div>
{/* 结算按钮 */}
<button
style={{
marginTop: '10px',
padding: '10px 20px',
backgroundColor: '#2196F3',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
width: '100%',
}}
>
结算
</button>
</div>
)}
</div>
</div>
);
}
export default ShoppingCart;
🔍 关键技术点解析
✅ 为什么用 useEffect([cart])?
- 我们需要在
cart每次变化后 自动重新计算总价和保存数据。 - 如果不加依赖项,每次渲染都会执行,造成浪费;
- 如果为空数组
[],则只在首次挂载执行,无法响应后续变更; - 正确做法:依赖
cart,精准触发,高效可靠。
✅ 如何避免 parseInt 报错?
if (isNaN(newQuantity)) return;
- 用户可能输入字母或空值,需做安全校验。
✅ localStorage 持久化原理
JSON.stringify(cart)→ 将对象转为字符串存入浏览器JSON.parse(localStorage.getItem(...))→ 读取并还原为 JS 对象- 页面刷新后,依然能恢复购物车内容!
四、项目结构说明
src/
├── components/
│ └── ShoppingCart.tsx # 主组件:购物车功能
├── App.tsx # 应用入口
└── ...
五、结语:useEffect —— 连接理想与现实的桥梁
React 的函数组件本质是“纯函数”——给定相同的 props 和 state,永远返回相同的 UI。
但真实世界充满不确定性:网络延迟、用户操作、本地存储、定时任务……
useEffect 就像一座时间之桥,让我们能够在:
- 🟢 挂载后:加载数据、绑定事件
- 🟡 更新时:同步状态、发送请求
- 🔴 卸载前:清理资源、取消订阅
它让我们的组件既保持纯净,又能拥抱复杂的真实世界。
💡 记住口诀:
“渲染是主线,副作用靠边站;依赖写清楚,清理别忘记。”
掌握 useEffect,你才算真正踏入了 React 函数式编程的殿堂。
✅ 推荐阅读(系列导航)
- 从0死磕全栈第1天:从写一个React的hello world开始
- 手把手教你配置Vite:打造极速企业级前端开发环境
- 从0死磕全栈第3天:React Router (Vite + React + TS 版):构建小时站实战指南
- 从0死磕全栈第4天:使用React useState实现用户注册功能
- 从0死磕全栈第5天:React 使用zustand实现To-Do List项目
👍 欢迎点赞、收藏、转发!
👉 关注我,一起从0死磕全栈,拒绝碎片化学习,构建系统性前端能力体系!