在电商类前端项目中,购物车是核心功能之一:用户需要在不离开列表或详情页的情况下将商品加入购物车,并在统一页面中查看、修改数量或删除。使用 React 实现购物车时,需要解决“状态在哪里存”、“谁可以读写”以及“如何与页面联动”三个问题。本文通过一个简单的React 商城示例来说明如何用 “Context API”管理购物车状态,并完成列表页、详情页的「加入购物车」与购物车列表页的展示与操作。
项目结构
首先我们应该考虑的问题就是状态放在哪里?购物车数据会被多个页面使用:商品列表、商品详情、购物车页、顶栏角标等。如果通过层层 props 传递,会非常繁琐且容易出错。因此我们采用 **React Context** 在应用顶层提供「购物车上下文」,任何子组件只要调用 `useCart()` 就能读写购物车,无需逐层传参。
/**
* 根组件
* - 用 CartProvider 包裹整站,使任意子组件都能通过 useCart 访问购物车
* - Layout 提供顶栏、主内容区、页脚;主内容区根据路由渲染不同页面
* - 路由:/ 商品列表,/product/:id 商品详情,/cart 购物车
*/
import { Routes, Route } from 'react-router-dom'
import { CartProvider } from './context/CartContext'
import Layout from './components/Layout'
import ProductList from './pages/ProductList'
import ProductDetail from './pages/ProductDetail'
import Cart from './pages/Cart'
function App() {
return (
<CartProvider>
<Layout>
<Routes>
<Route path="/" element={<ProductList />} />
<Route path="/product/:id" element={<ProductDetail />} />
<Route path="/cart" element={<Cart />} />
</Routes>
</Layout>
</CartProvider>
)
}
export default App购物车我们可以只存「商品 ID + 数量」的列表,例如:
```js
[
{ productId: 1, quantity: 2 },
{ productId: 3, quantity: 1 }
]
```
商品详情(名称、价格、图片等)仍然从原有的商品数据源(如 `products.js`)里根据 ID 查询。这样避免重复存储、便于和后台接口对齐(通常后台也只存 id 与数量)。
我们要创建 Context 与 Provider,可以在一个单独的文件(如 `CartContext.jsx`)中完成以下事情:
1. 使用 `createContext` 创建一个空的购物车上下文。
2. 在组件内用 `useState` 维护 `items` 数组(即 `[{ productId, quantity }, ...]`)。
3. 实现 `addToCart(productId, quantity)`:先校验商品是否存在,再在 `items` 中查找是否已有该 `productId`;若有则把对应项的 `quantity` 加上传入的数量,若无则追加新项。
4. 实现 `updateQuantity(productId, quantity)`:遍历 `items`,将对应 `productId` 的 `quantity` 改为新值(可约定数量小于 1 时不处理或视为删除)。
5. 实现 `removeFromCart(productId)`:从 `items` 中过滤掉该 `productId` 的项。
6. 用 `items` 计算总件数 `cartCount`(所有 `quantity` 之和)。
7. 将 `items`、`addToCart`、`updateQuantity`、`removeFromCart`、`cartCount` 通过 `CartContext.Provider` 的 `value` 传给子树。被 `CartProvider` 包裹的任意组件都可以通过 `useContext(CartContext)` 或自定义 Hook `useCart()` 访问上述状态和方法。
CartContext.jsx组件
/**
* 购物车上下文
* - 用 Context 在整站共享购物车状态,避免逐层传 props
* - 购物车数据格式:items = [{ productId, quantity }, ...],只存 ID 和数量,商品详情从 products 按 ID 查
*/
import { createContext, useContext, useState, useCallback } from 'react'
import { getProductById } from '../data/products'
const CartContext = createContext(null)
/** 购物车状态提供者,包裹在 App 根组件外层 */
export function CartProvider({ children }) {
const [items, setItems] = useState([]) // [{ productId, quantity }, ...]
/** 加入购物车:若已存在则数量累加,否则新增一项;会校验商品是否存在 */
const addToCart = useCallback((productId, quantity = 1) => {
const id = Number(productId)
if (!getProductById(id)) return
setItems((prev) => {
const existing = prev.find((i) => i.productId === id)
if (existing) {
return prev.map((i) =>
i.productId === id ? { ...i, quantity: i.quantity + quantity } : i
)
}
return [...prev, { productId: id, quantity }]
})
}, [])
/** 修改某商品数量,小于 1 不处理 */
const updateQuantity = useCallback((productId, quantity) => {
if (quantity < 1) return
setItems((prev) =>
prev.map((i) =>
i.productId === Number(productId) ? { ...i, quantity } : i
)
)
}, [])
/** 从购物车移除指定商品 */
const removeFromCart = useCallback((productId) => {
setItems((prev) => prev.filter((i) => i.productId !== Number(productId)))
}, [])
/** 购物车总件数(所有 quantity 之和),用于顶栏角标 */
const cartCount = items.reduce((sum, i) => sum + i.quantity, 0)
const value = {
items,
addToCart,
updateQuantity,
removeFromCart,
cartCount,
}
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
)
}
/** 在任意子组件中调用,获取购物车状态与操作方法;必须在 CartProvider 内部使用 */
export function useCart() {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be used within CartProvider')
return ctx
}各页面之间可以通过一下方式互相配合
商品列表页
- 列表数据仍从商品数据源(如 `products` 数组)读取,不做持久化。
- 每个商品卡片上有一个「加入购物车」按钮,点击时调用 `addToCart(product.id)`。
- 若希望点击卡片图片或标题跳转详情、而点击按钮只加购不跳转,需要把卡片拆成:图片/标题用 `Link` 包起来,按钮单独写,避免整张卡片是一个链接导致按钮无效。
/**
* 商品列表页(路由:/)
* - 使用 products 数组渲染商品卡片网格
* - 点击图片或商品名:跳转详情页;点击「加入购物车」:调用 addToCart,不跳转
*/
import { Link } from 'react-router-dom'
import { products } from '../data/products'
import { useCart } from '../context/CartContext'
import './ProductList.css'
export default function ProductList() {
const { addToCart } = useCart()
return (
<div className="product-list-page">
<div className="container">
<h1 className="page-title">商品列表</h1>
<div className="product-grid">
{products.map((product) => (
<article key={product.id} className="product-card">
{/* 图片区域:点击进入详情 */}
<Link to={`/product/${product.id}`} className="product-card-image-wrap">
<img
src={product.image}
alt={product.name}
className="product-card-image"
/>
<span className="product-card-category">{product.category}</span>
</Link>
<div className="product-card-body">
<Link to={`/product/${product.id}`} className="product-card-name">
{product.name}
</Link>
<div className="product-card-meta">
<span className="product-card-rating">★ {product.rating}</span>
<span className="product-card-sales">已售 {product.sales}</span>
</div>
<div className="product-card-prices">
<span className="product-card-price">¥{product.price}</span>
{product.originalPrice > product.price && (
<span className="product-card-original">
¥{product.originalPrice}
</span>
)}
</div>
<button
type="button"
className="product-card-add"
onClick={() => addToCart(product.id)}
>
加入购物车
</button>
</div>
</article>
))}
</div>
</div>
</div>
)
}商品详情页
- 通过路由参数(如 `useParams()`)拿到当前商品 ID,再用 `getProductById(id)` 取详情并渲染。
- 「加入购物车」按钮点击时执行 `addToCart(product.id)`。
- 「立即购买」可先做占位,后续再对接结算或下单流程。
/**
* 商品详情页(路由:/product/:id)
* - 从 useParams 取 URL 中的 id,用 getProductById 查商品;找不到则显示「未找到」和返回链接
* - 「加入购物车」调用 addToCart;「立即购买」为占位,未实现
*/
import { useParams, Link } from 'react-router-dom'
import { getProductById } from '../data/products'
import { useCart } from '../context/CartContext'
import './ProductDetail.css'
export default function ProductDetail() {
const { id } = useParams()
const { addToCart } = useCart()
const product = getProductById(id)
if (!product) {
return (
<div className="product-detail-page">
<div className="container">
<p className="detail-not-found">未找到该商品</p>
<Link to="/" className="detail-back">返回商品列表</Link>
</div>
</div>
)
}
return (
<div className="product-detail-page">
<div className="container">
<Link to="/" className="detail-back-link">← 返回列表</Link>
<article className="detail-layout">
<div className="detail-gallery">
<img
src={product.image}
alt={product.name}
className="detail-image"
/>
</div>
<div className="detail-info">
<span className="detail-category">{product.category}</span>
<h1 className="detail-title">{product.name}</h1>
<div className="detail-meta">
<span className="detail-rating">★ {product.rating}</span>
<span className="detail-sales">已售 {product.sales}</span>
</div>
<div className="detail-prices">
<span className="detail-price">¥{product.price}</span>
{product.originalPrice > product.price && (
<span className="detail-original">¥{product.originalPrice}</span>
)}
</div>
<p className="detail-desc">
品质保证,支持退换。下单即发,通常 1–3 日送达。
</p>
<div className="detail-actions">
<button
type="button"
className="btn btn-primary"
onClick={() => addToCart(product.id)}
>
加入购物车
</button>
<button type="button" className="btn btn-secondary">
立即购买
</button>
</div>
</div>
</article>
</div>
</div>
)
}购物车列表页
- 从 `useCart()` 取出 `items`。
- 遍历 `items`,对每一项用 `getProductById(productId)` 得到商品信息,与 `quantity` 组合成「一行」数据(注意过滤掉已下架或无效 ID)。
- 用「单价 × 数量」计算每行小计,再汇总得到总价。
- 每行提供「数量 − / +」按钮和「删除」按钮,分别调用 `updateQuantity` 和 `removeFromCart`。
- 当 `items.length === 0` 时展示空状态(如「购物车是空的」+ 跳转列表页的链接)。
/**
* 购物车页(路由:/cart)
* - 从 useCart 取 items,用 getProductById 拼成「商品 + 数量」列表 rows,并算总价 total
* - 空车时显示空状态和「去逛逛」;有数据时展示列表、数量加减、删除、右侧汇总与「去结算」(占位)
*/
import { Link } from 'react-router-dom'
import { useCart } from '../context/CartContext'
import { getProductById } from '../data/products'
import './Cart.css'
export default function Cart() {
const { items, updateQuantity, removeFromCart } = useCart()
// 将 items(仅含 productId、quantity)转为带完整商品信息的 rows,无效 id 过滤掉
const rows = items
.map(({ productId, quantity }) => ({
product: getProductById(productId),
quantity,
}))
.filter((r) => r.product)
const total = rows.reduce((sum, { product, quantity }) => sum + product.price * quantity, 0)
if (rows.length === 0) {
return (
<div className="cart-page">
<div className="container">
<h1 className="cart-title">购物车</h1>
<div className="cart-empty">
<p>购物车是空的</p>
<Link to="/" className="btn btn-primary">去逛逛</Link>
</div>
</div>
</div>
)
}
return (
<div className="cart-page">
<div className="container">
<h1 className="cart-title">购物车</h1>
<div className="cart-layout">
<ul className="cart-list">
{rows.map(({ product, quantity }) => (
<li key={product.id} className="cart-item">
<Link to={`/product/${product.id}`} className="cart-item-image-wrap">
<img src={product.image} alt={product.name} />
</Link>
<div className="cart-item-info">
<Link to={`/product/${product.id}`} className="cart-item-name">
{product.name}
</Link>
<p className="cart-item-price">¥{product.price}</p>
<div className="cart-item-actions">
<div className="quantity-wrap">
<button
type="button"
className="qty-btn"
onClick={() => updateQuantity(product.id, quantity - 1)}
>
−
</button>
<span className="qty-value">{quantity}</span>
<button
type="button"
className="qty-btn"
onClick={() => updateQuantity(product.id, quantity + 1)}
>
+
</button>
</div>
<button
type="button"
className="cart-remove"
onClick={() => removeFromCart(product.id)}
>
删除
</button>
</div>
</div>
<div className="cart-item-subtotal">
¥{product.price * quantity}
</div>
</li>
))}
</ul>
<aside className="cart-summary">
<p className="cart-summary-row">
<span>共 {rows.length} 件</span>
<span>¥{total}</span>
</p>
<button type="button" className="btn btn-primary btn-block">
去结算
</button>
</aside>
</div>
</div>
</div>
)
}文章小结:用 React 实现购物车功能时,核心是:“用 Context 在顶层维护「商品 ID + 数量」的列表,对外提供加入、修改数量、删除和总件数”;各页面按需调用这些方法并配合现有商品数据源展示。这样既能避免 props 层层传递,又便于后续扩展(例如将 `items` 持久化到 localStorage 或与后端接口同步)。
本文所述思路可直接对应到示例项目中的 `CartContext.jsx`、商品列表页、商品详情页、购物车页和布局组件,便于对照代码理解实现细节。