从0死磕全栈第6天:React useEffect副作用起了大作用之实现购物车功能

81 阅读6分钟

每一次渲染都是新的开始,而 useEffect 是时光中的锚点——它提醒我们:有些事需要铭记,有些则必须放手。

本文是《从0死磕全栈》系列第5篇,手把手教你使用 React 的 useEffect Hook 实现一个功能完整的购物车系统,涵盖商品展示、添加/删除、数量修改、总价计算和本地存储持久化,深入理解副作用的执行时机与最佳实践。


一、useEffect 简介

useEffect 是 React 函数组件中用于处理副作用(Side Effects) 的核心 Hook。

✅ 什么是副作用?

副作用是指在组件渲染之外发生的操作,例如:

  • 数据获取(API 请求)
  • 订阅事件(如 WebSocket、DOM 事件)
  • 手动修改 DOM
  • 设置定时器
  • 写入 localStorage / sessionStorage

🚫 注意:不能在渲染过程中直接执行这些操作,否则会阻塞 UI 渲染,影响性能。

✅ useEffect 的作用

副作用逻辑与渲染逻辑分离,确保:

  1. 组件优先完成渲染;
  2. 副作用在渲染完成后异步执行;
  3. 提供清理机制,避免内存泄漏。

✅ 基本语法

useEffect(() => {
  // 副作用代码(如数据请求、修改DOM)
  return () => {
    // 清理函数(如清除定时器、取消订阅)
  };
}, [依赖项数组]); // 控制执行时机

✅ 三种常见使用场景

依赖项数组执行时机适用场景
不传(或 undefined每次渲染后都执行需要实时响应状态变化(如更新 DOM)
空数组 []仅在组件挂载时执行一次初始化操作(如加载数据、绑定全局事件)
有具体依赖 [dep1, dep2]仅当依赖项变化时执行优化性能,避免不必要的重复执行

二、实战目标:构建购物车系统

我们要实现以下功能:

  1. 商品列表展示:显示多种水果及其价格
  2. 加入购物车:点击“加入购物车”按钮,商品添加至购物车
  3. 修改数量:输入框动态调整商品数量(最小为1)
  4. 移除商品:点击“移除”按钮删除商品
  5. 自动计算总价:根据商品数量实时计算总金额
  6. 本地持久化:购物车数据保存到 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死磕全栈,拒绝碎片化学习,构建系统性前端能力体系!