Skip to content

第12章:企业级进阶实战(综合应用)

实战 3:简易电商首页

在本章中,我们将创建一个简易电商首页,这是一个更加复杂的企业级应用。通过这个项目,你将学习如何构建具有多个页面、复杂状态管理和数据交互的 Next.js 应用。

12.1 项目架构设计

12.1.1 需求分析

我们要创建一个简易电商首页,包含以下功能:

  • 首页:轮播图、商品列表、分类导航
  • 商品详情页:商品信息、规格选择、加入购物车
  • 购物车:商品增删改查、数量计算
  • 状态管理:使用 Zustand 管理购物车状态
  • 数据获取:服务端渲染商品列表,客户端获取购物车数据
  • 性能优化:图片懒加载、组件懒加载、缓存策略

12.1.2 技术栈选择

  • 前端框架:Next.js 14 + React 18
  • 状态管理:Zustand(轻量级状态管理库)
  • 样式方案:Tailwind CSS
  • 数据获取:Server Components + fetch API
  • 图片优化:Next.js Image 组件
  • 路由:App Router

12.1.3 项目结构规划

src/
  app/
    layout.js       # 全局布局
    page.js         # 首页
    product/        # 商品相关路由
      [id]/         # 商品详情页
        page.js
    cart/           # 购物车页面
      page.js
    api/            # API 路由
      products/     # 商品相关 API
        route.js
  components/       # 组件
    Header.js       # 头部组件
    Footer.js       # 底部组件
    Carousel.js     # 轮播图组件
    ProductCard.js  # 商品卡片组件
    CategoryNav.js  # 分类导航组件
    CartItem.js     # 购物车商品项组件
  lib/              # 工具函数
    store.js        # Zustand 状态管理
    products.js     # 商品数据处理
  styles/           # 样式文件
    globals.css     # 全局样式

12.2 核心功能实现

12.2.1 项目初始化

步骤 1:创建 Next.js 项目

bash
npx create-next-app@latest ecommerce --tailwind --eslint --app --src-dir

步骤 2:安装依赖

bash
cd ecommerce
npm install zustand

12.2.2 状态管理配置

javascript
// src/lib/store.js
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useCartStore = create(
  persist(
    (set, get) => ({
      cart: [],
      
      // 添加商品到购物车
      addToCart: (product, quantity = 1) => {
        const existingItem = get().cart.find(item => item.id === product.id);
        
        if (existingItem) {
          set(state => ({
            cart: state.cart.map(item => 
              item.id === product.id 
                ? { ...item, quantity: item.quantity + quantity }
                : item
            )
          }));
        } else {
          set(state => ({
            cart: [...state.cart, { ...product, quantity }]
          }));
        }
      },
      
      // 从购物车移除商品
      removeFromCart: (productId) => {
        set(state => ({
          cart: state.cart.filter(item => item.id !== productId)
        }));
      },
      
      // 更新商品数量
      updateQuantity: (productId, quantity) => {
        if (quantity <= 0) {
          get().removeFromCart(productId);
        } else {
          set(state => ({
            cart: state.cart.map(item => 
              item.id === productId 
                ? { ...item, quantity }
                : item
            )
          }));
        }
      },
      
      // 清空购物车
      clearCart: () => {
        set({ cart: [] });
      },
      
      // 获取购物车总价
      getTotalPrice: () => {
        return get().cart.reduce((total, item) => {
          return total + (item.price * item.quantity);
        }, 0);
      },
      
      // 获取购物车商品数量
      getTotalItems: () => {
        return get().cart.reduce((total, item) => {
          return total + item.quantity;
        }, 0);
      }
    }),
    {
      name: 'cart-storage',
    }
  )
);

export default useCartStore;

12.2.3 模拟商品数据

javascript
// src/lib/products.js
export const products = [
  {
    id: 1,
    name: 'Next.js 开发实战',
    price: 99.99,
    image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=Next.js%20book%20cover%20with%20modern%20design&image_size=square_hd',
    description: '一本全面介绍 Next.js 开发的实战指南,涵盖从基础到进阶的所有知识点。',
    category: '书籍',
    stock: 100
  },
  {
    id: 2,
    name: 'React 高级编程',
    price: 89.99,
    image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=React%20advanced%20programming%20book%20cover&image_size=square_hd',
    description: '深入学习 React 高级特性,包括 Hooks、Context API、性能优化等。',
    category: '书籍',
    stock: 80
  },
  {
    id: 3,
    name: 'JavaScript 权威指南',
    price: 129.99,
    image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=JavaScript%20definitive%20guide%20book%20cover&image_size=square_hd',
    description: 'JavaScript 领域的权威参考书籍,适合各个层次的开发者。',
    category: '书籍',
    stock: 120
  },
  {
    id: 4,
    name: 'TypeScript 实战',
    price: 79.99,
    image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=TypeScript%20practical%20guide%20book%20cover&image_size=square_hd',
    description: 'TypeScript 实战指南,从基础语法到高级应用。',
    category: '书籍',
    stock: 90
  }
];

export const categories = [
  { id: 1, name: '全部', slug: 'all' },
  { id: 2, name: '书籍', slug: 'books' },
  { id: 3, name: '电子产品', slug: 'electronics' },
  { id: 4, name: '服装', slug: 'clothing' },
  { id: 5, name: '家居', slug: 'home' }
];

export function getProducts() {
  return products;
}

export function getProductById(id) {
  return products.find(product => product.id === parseInt(id));
}

export function getProductsByCategory(category) {
  if (category === 'all') {
    return products;
  }
  return products.filter(product => product.category.toLowerCase() === category);
}

12.2.4 首页实现

javascript
// src/app/page.js
import Carousel from '@/components/Carousel';
import CategoryNav from '@/components/CategoryNav';
import ProductCard from '@/components/ProductCard';
import { getProducts } from '@/lib/products';

export default function Home() {
  const products = getProducts();

  return (
    <div>
      <Carousel />
      <CategoryNav />
      <div className="container mx-auto px-4 py-8">
        <h2 className="text-2xl font-bold mb-6">热门商品</h2>
        <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
          {products.map(product => (
            <ProductCard key={product.id} product={product} />
          ))}
        </div>
      </div>
    </div>
  );
}

12.2.5 轮播图组件

javascript
// src/components/Carousel.js
import { useState, useEffect } from 'react';

export default function Carousel() {
  const [currentIndex, setCurrentIndex] = useState(0);
  
  const slides = [
    {
      id: 1,
      image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=Ecommerce%20website%20banner%20with%20modern%20design&image_size=landscape_16_9',
      title: '新品上市',
      description: '探索我们的最新产品',
      link: '/'
    },
    {
      id: 2,
      image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=Book%20sale%20banner%20with%20discount&image_size=landscape_16_9',
      title: '限时折扣',
      description: '全场图书 8 折起',
      link: '/'
    },
    {
      id: 3,
      image: 'https://trae-api-cn.mchost.guru/api/ide/v1/text_to_image?prompt=Tech%20gadgets%20promotion%20banner&image_size=landscape_16_9',
      title: '科技新品',
      description: '最新科技产品等你来抢',
      link: '/'
    }
  ];
  
  useEffect(() => {
    const interval = setInterval(() => {
      setCurrentIndex((prevIndex) => 
        (prevIndex + 1) % slides.length
      );
    }, 5000);
    
    return () => clearInterval(interval);
  }, [slides.length]);
  
  return (
    <div className="relative h-80 md:h-96 overflow-hidden">
      {slides.map((slide, index) => (
        <div
          key={slide.id}
          className={`absolute inset-0 transition-opacity duration-1000 ${index === currentIndex ? 'opacity-100' : 'opacity-0'}`}
        >
          <img 
            src={slide.image} 
            alt={slide.title} 
            className="w-full h-full object-cover"
          />
          <div className="absolute inset-0 bg-black bg-opacity-40 flex items-center">
            <div className="container mx-auto px-4">
              <h2 className="text-3xl md:text-4xl font-bold text-white mb-2">
                {slide.title}
              </h2>
              <p className="text-white text-lg mb-4">
                {slide.description}
              </p>
              <a 
                href={slide.link} 
                className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700"
              >
                立即查看
              </a>
            </div>
          </div>
        </div>
      ))}
      <div className="absolute bottom-4 left-0 right-0 flex justify-center space-x-2">
        {slides.map((_, index) => (
          <button
            key={index}
            className={`w-3 h-3 rounded-full ${index === currentIndex ? 'bg-white' : 'bg-white bg-opacity-50'}`}
            onClick={() => setCurrentIndex(index)}
          />
        ))}
      </div>
    </div>
  );
}

12.2.6 分类导航组件

javascript
// src/components/CategoryNav.js
import Link from 'next/link';
import { categories } from '@/lib/products';

export default function CategoryNav() {
  return (
    <div className="bg-gray-100 py-4">
      <div className="container mx-auto px-4">
        <div className="flex overflow-x-auto space-x-6">
          {categories.map(category => (
            <Link
              key={category.id}
              href={`/?category=${category.slug}`}
              className="whitespace-nowrap px-4 py-2 rounded-full bg-white shadow-sm hover:bg-gray-50"
            >
              {category.name}
            </Link>
          ))}
        </div>
      </div>
    </div>
  );
}

12.2.7 商品卡片组件

javascript
// src/components/ProductCard.js
import Link from 'next/link';
import Image from 'next/image';

export default function ProductCard({ product }) {
  return (
    <div className="bg-white rounded-lg shadow-sm overflow-hidden hover:shadow-md transition-shadow">
      <div className="relative h-48">
        <Image
          src={product.image}
          alt={product.name}
          fill
          className="object-cover"
          priority={false}
        />
      </div>
      <div className="p-4">
        <h3 className="font-semibold mb-2 line-clamp-2">
          {product.name}
        </h3>
        <p className="text-gray-600 text-sm mb-3 line-clamp-2">
          {product.description}
        </p>
        <div className="flex justify-between items-center">
          <span className="text-lg font-bold text-blue-600">
            ${product.price.toFixed(2)}
          </span>
          <Link
            href={`/product/${product.id}`}
            className="bg-gray-100 hover:bg-gray-200 px-3 py-1 rounded-md text-sm"
          >
            查看详情
          </Link>
        </div>
      </div>
    </div>
  );
}

12.2.8 商品详情页

javascript
// src/app/product/[id]/page.js
'use client';

import { useState } from 'react';
import Image from 'next/image';
import useCartStore from '@/lib/store';
import { getProductById } from '@/lib/products';

export default function ProductDetailPage({ params }) {
  const { id } = params;
  const product = getProductById(id);
  const [quantity, setQuantity] = useState(1);
  const addToCart = useCartStore(state => state.addToCart);
  
  if (!product) {
    return (
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-2xl font-bold mb-4">商品未找到</h1>
        <p>抱歉,您请求的商品不存在。</p>
      </div>
    );
  }
  
  const handleAddToCart = () => {
    addToCart(product, quantity);
    alert('商品已添加到购物车');
  };
  
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div className="relative h-96">
          <Image
            src={product.image}
            alt={product.name}
            fill
            className="object-cover rounded-lg"
            priority
          />
        </div>
        <div>
          <h1 className="text-3xl font-bold mb-4">{product.name}</h1>
          <p className="text-gray-600 mb-6">{product.description}</p>
          <div className="text-2xl font-bold text-blue-600 mb-6">
            ${product.price.toFixed(2)}
          </div>
          <div className="mb-6">
            <label className="block text-sm font-medium mb-2">数量</label>
            <div className="flex items-center">
              <button
                onClick={() => setQuantity(Math.max(1, quantity - 1))}
                className="bg-gray-100 px-3 py-1 rounded-l-md"
              >
                -
              </button>
              <input
                type="number"
                value={quantity}
                onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
                className="w-16 px-3 py-1 border-y border-gray-300 text-center"
              />
              <button
                onClick={() => setQuantity(quantity + 1)}
                className="bg-gray-100 px-3 py-1 rounded-r-md"
              >
                +
              </button>
            </div>
          </div>
          <button
            onClick={handleAddToCart}
            className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 w-full"
          >
            加入购物车
          </button>
        </div>
      </div>
    </div>
  );
}

12.2.9 购物车页面

javascript
// src/app/cart/page.js
'use client';

import CartItem from '@/components/CartItem';
import useCartStore from '@/lib/store';

export default function CartPage() {
  const cart = useCartStore(state => state.cart);
  const getTotalPrice = useCartStore(state => state.getTotalPrice);
  const clearCart = useCartStore(state => state.clearCart);
  
  if (cart.length === 0) {
    return (
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-2xl font-bold mb-4">购物车</h1>
        <p>您的购物车是空的。</p>
      </div>
    );
  }
  
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-bold mb-6">购物车</h1>
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
        <div className="lg:col-span-2">
          {cart.map(item => (
            <CartItem key={item.id} item={item} />
          ))}
        </div>
        <div className="bg-gray-100 p-6 rounded-lg">
          <h2 className="text-xl font-bold mb-4">订单摘要</h2>
          <div className="space-y-2 mb-6">
            <div className="flex justify-between">
              <span>商品总价</span>
              <span>${getTotalPrice().toFixed(2)}</span>
            </div>
            <div className="flex justify-between">
              <span>运费</span>
              <span>免费</span>
            </div>
            <div className="border-t pt-2 flex justify-between font-bold">
              <span>总计</span>
              <span>${getTotalPrice().toFixed(2)}</span>
            </div>
          </div>
          <button
            className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 w-full mb-4"
          >
            结算
          </button>
          <button
            onClick={clearCart}
            className="bg-gray-200 text-gray-700 px-6 py-2 rounded-md hover:bg-gray-300 w-full"
          >
            清空购物车
          </button>
        </div>
      </div>
    </div>
  );
}

12.2.10 购物车商品项组件

javascript
// src/components/CartItem.js
import Image from 'next/image';
import useCartStore from '@/lib/store';

export default function CartItem({ item }) {
  const removeFromCart = useCartStore(state => state.removeFromCart);
  const updateQuantity = useCartStore(state => state.updateQuantity);
  
  return (
    <div className="bg-white rounded-lg shadow-sm p-4 mb-4 flex flex-col sm:flex-row gap-4">
      <div className="relative w-24 h-24 sm:w-32 sm:h-32">
        <Image
          src={item.image}
          alt={item.name}
          fill
          className="object-cover rounded-md"
        />
      </div>
      <div className="flex-1">
        <h3 className="font-semibold mb-2">{item.name}</h3>
        <p className="text-gray-600 text-sm mb-4">{item.description}</p>
        <div className="flex justify-between items-center">
          <div className="flex items-center">
            <button
              onClick={() => updateQuantity(item.id, item.quantity - 1)}
              className="bg-gray-100 px-2 py-1 rounded-l-md"
            >
              -
            </button>
            <input
              type="number"
              value={item.quantity}
              onChange={(e) => updateQuantity(item.id, parseInt(e.target.value) || 1)}
              className="w-12 px-2 py-1 border-y border-gray-300 text-center"
            />
            <button
              onClick={() => updateQuantity(item.id, item.quantity + 1)}
              className="bg-gray-100 px-2 py-1 rounded-r-md"
            >
              +
            </button>
          </div>
          <div className="text-right">
            <div className="font-bold">${(item.price * item.quantity).toFixed(2)}</div>
            <button
              onClick={() => removeFromCart(item.id)}
              className="text-red-500 text-sm mt-1 hover:underline"
            >
              删除
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

12.2.11 头部组件(包含购物车图标)

javascript
// src/components/Header.js
import Link from 'next/link';
import useCartStore from '@/lib/store';

export default function Header() {
  const getTotalItems = useCartStore(state => state.getTotalItems);
  const totalItems = getTotalItems();
  
  return (
    <header className="bg-white shadow-sm sticky top-0 z-10">
      <div className="container mx-auto px-4 py-4 flex justify-between items-center">
        <Link href="/" className="text-2xl font-bold text-blue-600">
          简易电商
        </Link>
        <nav className="hidden md:flex space-x-6">
          <Link href="/" className="hover:text-blue-600">
            首页
          </Link>
          <Link href="/cart" className="hover:text-blue-600">
            购物车
            {totalItems > 0 && (
              <span className="ml-1 bg-blue-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
                {totalItems}
              </span>
            )}
          </Link>
        </nav>
        <div className="md:hidden">
          <Link href="/cart" className="relative">
            <svg
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
              strokeWidth={1.5}
              stroke="currentColor"
              className="w-6 h-6"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 00-16.536-1.84M7.5 14.25v-.007a60.116 60.116 0 0112.538-2.347M7.5 14.25v.191a59.905 59.905 0 01-3.374 2.443M15 12a3 3 0 11-6 0 3 3 0 016 0zm6 2.25a3 3 0 11-6 0 3 3 0 016 0z"
              />
            </svg>
            {totalItems > 0 && (
              <span className="absolute -top-2 -right-2 bg-blue-600 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
                {totalItems}
              </span>
            )}
          </Link>
        </div>
      </div>
    </header>
  );
}

12.3 状态管理

我们使用 Zustand 来管理购物车状态,这是一个轻量级的状态管理库,比 Redux 更简单易用。Zustand 的核心特点是:

  1. 简洁的 API:使用 create 函数创建 store,无需繁琐的配置
  2. 中间件支持:内置 persist 中间件,轻松实现状态持久化
  3. 组件友好:支持 React 组件订阅状态变化
  4. 类型安全:支持 TypeScript

12.4 数据获取

12.4.1 服务端数据获取

在首页和商品详情页,我们使用 Server Components 来获取商品数据,这样可以:

  • 减少客户端 JavaScript 体积
  • 提高首屏加载速度
  • 改善 SEO

12.4.2 客户端数据获取

在购物车页面,我们使用客户端组件来获取购物车数据,因为:

  • 购物车状态需要在客户端实时更新
  • 需要与用户交互(添加、删除、修改数量)
  • 使用 Zustand 进行状态管理

12.5 性能优化

12.5.1 图片优化

使用 Next.js 的 Image 组件优化图片加载:

  • 自动生成不同尺寸的图片
  • 支持懒加载
  • 提高页面加载速度

12.5.2 组件懒加载

对于大型组件,可以使用 React.lazy 和 Suspense 进行懒加载:

javascript
// 示例:懒加载购物车组件
import { lazy, Suspense } from 'react';

const Cart = lazy(() => import('./Cart'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Cart />
    </Suspense>
  );
}

12.5.3 缓存策略

使用 Next.js 的缓存策略优化数据获取:

javascript
// 示例:缓存商品数据
export async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    next: {
      revalidate: 3600, // 1小时重新验证
    },
  });
  return res.json();
}

12.5.4 代码分割

Next.js 自动进行代码分割,将不同页面的代码分离,减少初始加载时间。

12.6 项目总结

通过本实战项目的学习,你已经掌握了以下企业级应用开发的核心技能:

  1. 复杂状态管理:使用 Zustand 管理购物车状态
  2. 多页面应用:使用 App Router 创建多个页面
  3. 服务端渲染:使用 Server Components 优化首屏加载
  4. 客户端交互:使用客户端组件处理用户交互
  5. 性能优化:图片优化、组件懒加载、缓存策略
  6. 响应式设计:适配不同设备屏幕

这个简易电商首页虽然功能简单,但已经包含了企业级应用的核心要素。在实际开发中,你可以根据具体需求对这个项目进行扩展,例如:

  • 添加用户认证系统
  • 集成支付功能
  • 实现订单管理
  • 添加商品搜索和筛选
  • 集成推荐系统

通过不断学习和实践,你可以逐步构建更加复杂和功能完整的电商应用。

© 2026 编程马·菜鸟教程 版权所有