🚀 Next.js 新手入门指南:从搭建到起飞

536 阅读12分钟

如果 React 是一辆跑车,Next.js 就是给它装上导航、油门、空调和智能驾驶系统。本篇将带你循序渐进了解 Next.js 的核心概念与实战技巧。


一、Next.js 是什么?为什么要学它?

Next.js 是一个基于 React 的框架,它为前端开发者提供了构建生产级 Web 应用所需的一切:

  • 页面路由自动生成
  • 支持服务端渲染、静态生成、增量静态生成
  • 自带 API 路由,前后端一体化开发
  • 更好的 SEO、性能优化能力

🔍 一句话总结:React 负责 UI,Next.js 帮你搞定剩下的一切。


二、环境搭建:只需 60 秒就能起飞

  1. 安装 Node.js(推荐使用 LTS 版本)

  2. 初始化项目:

npx create-next-app@latest shop
cd shop
npm run dev
  1. 打开浏览器访问 http://localhost:3000,项目已运行!

三、项目目录结构详解

使用 create-next-app 初始化项目后,你会看到如下目录结构(默认启用 App Router):

shop/
├── app/                     # 新的 App Router 路由目录
│   ├── globals.css          # 全局样式
│   ├── layout.tsx           # 根布局组件
│   ├── page.tsx             # 首页 (/)
│   ├── loading.tsx          # 全局加载组件
│   ├── error.tsx            # 全局错误组件
│   ├── not-found.tsx        # 404 页面
│   ├── about/               # /about 路由
│   │   ├── page.tsx         # /about 页面
│   │   └── layout.tsx       # about 区域布局
│   ├── products/            # /products 路由组
│   │   ├── [slug]/          # 动态路由 /products/[slug]
│   │   │   └── page.tsx
│   │   ├── (categories)/    # 路由组(不影响URL)
│   │   │   ├── electronics/
│   │   │   │   └── page.tsx # /products/electronics
│   │   │   └── clothing/
│   │   │       └── page.tsx # /products/clothing
│   │   └── _components/     # 私有文件夹
│   │       └── ProductCard.tsx
│   └── api/                 # API 路由
│       └── products/
│           └── route.ts
├── public/                  # 静态资源目录
│   ├── images/
│   └── icons/
├── lib/                     # 工具函数库
├── components/              # 可复用组件
├── styles/                  # 样式文件
├── next.config.js           # Next.js 配置
└── package.json             # 项目依赖

App Router 架构从 Next.js 13 起成为官方推荐,带来了革命性的文件组织方式

  • 📁 基于文件夹的路由:每个文件夹代表一个路由段
  • 🎨 特殊文件约定page.tsxlayout.tsxloading.tsx 等有特定作用
  • 🔄 React Server Components:默认支持服务端组件,性能更优
  • 🎯 布局嵌套:支持多层布局复用,告别重复代码

四、App Router 路由系统全解析

Next.js 的路由系统是其核心优势之一。App Router 架构下,路由完全基于文件系统,让项目结构一目了然。

4.1 基础路由约定

app/ 目录中,文件夹即路由段,page.tsx 文件定义页面内容

app/
├── page.tsx              # 根路由 '/'
├── about/
│   └── page.tsx          # '/about'
└── contact/
    └── page.tsx          # '/contact'
// app/about/page.tsx
export default function AboutPage() {
  return (
    <div>
      <h1>关于我们</h1>
      <p>这里是关于页面的内容</p>
    </div>
  );
}

4.2 嵌套路由(Nested Routes)

App Router 天然支持嵌套路由,通过文件夹层级体现URL结构:

app/
└── dashboard/
    ├── page.tsx          # '/dashboard'
    ├── layout.tsx        # dashboard 布局
    ├── analytics/
    │   └── page.tsx      # '/dashboard/analytics'
    └── settings/
        ├── page.tsx      # '/dashboard/settings'
        └── profile/
            └── page.tsx  # '/dashboard/settings/profile'

每层文件夹都可以有自己的 layout.tsx,实现布局嵌套

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard-container">
      <aside className="sidebar">
        {/* 侧边栏导航 */}
        <nav>...</nav>
      </aside>
      <main className="content">
        {children}
      </main>
    </div>
  );
}

4.3 动态路由(Dynamic Routes)

用方括号 [] 创建动态路由段,捕获URL参数:

app/
└── products/
    ├── page.tsx              # '/products'
    ├── [slug]/
    │   └── page.tsx          # '/products/laptop-pro'
    └── [category]/
        └── [id]/
            └── page.tsx      # '/products/electronics/123'

在页面组件中通过 params 获取动态参数:

// app/products/[slug]/page.tsx
export default function ProductPage({
  params
}: {
  params: { slug: string }
}) {
  return (
    <article className="max-w-4xl mx-auto p-6">
      <h1 className="text-3xl font-bold text-gray-900 mb-4">
        商品:{params.slug}
      </h1>
      {/* 根据 slug 获取商品内容 */}
    </article>
  );
}

捕获所有路由段:使用 [...slug] 语法

app/
└── shop/
    └── [...slug]/
        └── page.tsx          # 匹配 '/shop/a', '/shop/a/b', '/shop/a/b/c'
// app/shop/[...slug]/page.tsx
export default function ShopPage({
  params
}: {
  params: { slug: string[] }
}) {
  const path = params.slug.join('/');
  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-2xl font-semibold text-gray-800">
        购物路径:{path}
      </h1>
    </div>
  );
}

4.4 路由组(Route Groups)

使用圆括号 () 创建路由组,组织文件但不影响URL路径

app/
├── (marketing)/             # 路由组,不影响URL
│   ├── about/
│   │   └── page.tsx         # '/about' (不是 '/marketing/about')
│   └── contact/
│       └── page.tsx         # '/contact'
├── (shop)/                  # 另一个路由组
│   ├── products/
│   │   └── page.tsx         # '/products'
│   └── cart/
│       └── page.tsx         # '/cart'
└── layout.tsx               # 根布局

路由组的作用:

  • 逻辑分组:按功能模块组织代码
  • 多重布局:不同组可以有不同的布局
  • 团队协作:不同团队维护不同的路由组
// app/(marketing)/layout.tsx - 营销页面布局
export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white shadow-sm border-b">
        <div className="max-w-7xl mx-auto px-4 py-6">
          <h1 className="text-2xl font-bold text-gray-900">营销页面</h1>
        </div>
      </header>
      <main className="max-w-7xl mx-auto px-4 py-8">
        {children}
      </main>
      <footer className="bg-gray-800 text-white py-8 mt-auto">
        <div className="max-w-7xl mx-auto px-4 text-center">
          <p>&copy; 2025 我的商城</p>
        </div>
      </footer>
    </div>
  );
}

// app/(shop)/layout.tsx - 商城页面布局
export default function ShopLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-white">
      <header className="bg-blue-600 text-white shadow-lg">
        <div className="max-w-7xl mx-auto px-4 py-4">
          <h1 className="text-xl font-semibold">我的商城</h1>
        </div>
      </header>
      <nav className="bg-blue-500 text-white">
        <div className="max-w-7xl mx-auto px-4 py-2">
          <ul className="flex space-x-6">
            <li><a href="/products" className="hover:text-blue-200">商品</a></li>
            <li><a href="/cart" className="hover:text-blue-200">购物车</a></li>
          </ul>
        </div>
      </nav>
      <main className="max-w-7xl mx-auto px-4 py-6">
        {children}
      </main>
    </div>
  );
}

4.5 私有文件夹(Private Folders)

使用下划线 _ 前缀创建私有文件夹,不会被路由系统处理

app/
├── products/
│   ├── page.tsx
│   ├── _components/         # 私有文件夹
│   │   ├── ProductCard.tsx  # 仅供 products 模块使用的组件
│   │   └── ReviewList.tsx
│   ├── _lib/                # 私有工具库
│   │   └── api.ts
│   └── [slug]/
│       └── page.tsx
└── _shared/                 # 全局私有文件夹
    ├── components/
    └── utils/

私有文件夹的优势:

  • 避免路由冲突:组件和工具文件不会意外成为路由
  • 代码组织:相关文件就近存放,便于维护
  • 作用域明确:私有文件夹内的内容仅供特定模块使用

4.6 特殊文件约定

App Router 定义了一系列特殊文件,各司其职:

文件名作用示例
layout.tsx布局组件,包裹子路由侧边栏、头部导航
page.tsx页面组件,定义路由内容具体页面内容
loading.tsx加载状态组件骨架屏、加载动画
error.tsx错误边界组件错误页面、重试按钮
not-found.tsx404页面组件自定义404页面
global-error.tsx全局错误处理捕获根布局错误
route.tsxAPI路由处理器RESTful API端点
default.tsx并行路由默认页面复杂路由场景
template.tsx模板组件每次导航重新挂载
// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse space-y-4 p-6">
      <div className="h-6 bg-gray-200 rounded-md w-3/4"></div>
      <div className="h-4 bg-gray-200 rounded-md w-1/2"></div>
      <div className="h-4 bg-gray-200 rounded-md w-2/3"></div>
    </div>
  );
}

// app/dashboard/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
        <h2 className="text-xl font-semibold text-red-600 mb-4">
          出现了一些问题!
        </h2>
        <p className="text-gray-600 mb-6">{error.message}</p>
        <button
          onClick={() => reset()}
          className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
        >
          重试
        </button>
      </div>
    </div>
  );
}

补充:pages 目录(经典路由模式)

虽然 App Router 是新的推荐架构,但了解传统的 pages/ 目录仍然重要,特别是维护旧项目时:

基本路由规则:

pages/
├── index.js                 # '/' 首页
├── about.js                 # '/about'
├── products/
│   ├── index.js             # '/products'
│   └── [slug].js            # '/products/laptop-pro'
├── _app.js                  # 全局App组件
├── _document.js             # HTML文档结构
└── 404.js                   # 404页面

主要特点:

  • 文件即路由:每个 .js/.tsx 文件自动成为路由
  • 支持动态路由:[param].js 语法
  • 全局配置:_app.js 包裹所有页面,_document.js 自定义HTML结构
  • API路由:pages/api/ 目录下的文件自动成为API端点

迁移建议:

新项目推荐使用 App Router,但以下情况可考虑继续使用 pages:

  • 维护现有项目,迁移成本较高
  • 团队对 pages 架构更熟悉
  • 项目规模较小,不需要复杂的嵌套布局

📌 两种架构可以在同一项目中共存,但建议统一使用一种以避免混乱。


五、布局系统(Layouts):告别重复代码

布局系统是 App Router 的一大亮点,让你能够优雅地复用UI结构,避免在每个页面重复写导航栏、侧边栏等公共组件。

5.1 根布局(Root Layout)

每个 Next.js 应用都必须有一个根布局 app/layout.tsx,它是最顶层的布局:

// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <head>
        <title>我的商城</title>
        <meta name="description" content="Next.js 电商学习项目" />
      </head>
      <body className="min-h-screen bg-gray-50">
        <header className="bg-white shadow-sm border-b">
          <div className="max-w-7xl mx-auto px-4 py-4">
            <nav className="flex items-center justify-between">
              <h1 className="text-2xl font-bold text-gray-900">我的商城</h1>
              <div className="space-x-4">
                <a href="/" className="text-gray-600 hover:text-gray-900">首页</a>
                <a href="/products" className="text-gray-600 hover:text-gray-900">商品</a>
                <a href="/cart" className="text-gray-600 hover:text-gray-900">购物车</a>
              </div>
            </nav>
          </div>
        </header>
        <main className="flex-1">{children}</main>
        <footer className="bg-gray-800 text-white py-8 mt-auto">
          <div className="max-w-7xl mx-auto px-4 text-center">
            <p>&copy; 2025 我的商城</p>
          </div>
        </footer>
      </body>
    </html>
  );
}

根布局的作用相当于传统 pages 架构中的 _app.js + _document.js 的组合。

5.2 嵌套布局(Nested Layouts)

任何路由段都可以定义自己的布局,实现多层嵌套

// app/dashboard/layout.tsx
import Link from 'next/link';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-100">
      <div className="flex">
        <aside className="w-64 bg-white shadow-lg">
          <div className="p-6">
            <h2 className="text-lg font-semibold text-gray-800 mb-4">仪表盘</h2>
            <nav className="space-y-2">
              <Link
                href="/dashboard"
                className="block px-4 py-2 text-gray-600 hover:bg-blue-50 hover:text-blue-600 rounded-md transition-colors"
              >
                总览
              </Link>
              <Link
                href="/dashboard/analytics"
                className="block px-4 py-2 text-gray-600 hover:bg-blue-50 hover:text-blue-600 rounded-md transition-colors"
              >
                数据分析
              </Link>
              <Link
                href="/dashboard/settings"
                className="block px-4 py-2 text-gray-600 hover:bg-blue-50 hover:text-blue-600 rounded-md transition-colors"
              >
                设置
              </Link>
            </nav>
          </div>
        </aside>
        <main className="flex-1 p-6">
          <div className="bg-white rounded-lg shadow p-6">
            {children}
          </div>
        </main>
      </div>
    </div>
  );
}

这样,/dashboard 下的所有页面都会被这个布局包裹,同时还会被根布局包裹:

RootLayout
  └─ DashboardLayout
       └─ Page Content

5.3 布局状态保持

布局组件在路由切换时保持挂载状态,这意味着:

  • 布局内的状态不会丢失
  • 订阅、计时器等不会被重置
  • 布局内的组件不会重新渲染(性能优化)
// app/dashboard/layout.tsx
'use client';
import { useState } from 'react';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const [sidebarOpen, setSidebarOpen] = useState(true);

  // 这个状态在页面切换时会保持
  return (
    <div className="dashboard">
      <button onClick={() => setSidebarOpen(!sidebarOpen)}>
        切换侧边栏
      </button>
      <aside className={sidebarOpen ? 'open' : 'closed'}>
        {/* 侧边栏内容 */}
      </aside>
      <main>{children}</main>
    </div>
  );
}

5.4 模板(Templates)

如果你希望在每次路由切换时都重新挂载组件,可以使用 template.tsx 而不是 layout.tsx

// app/dashboard/template.tsx
'use client';
import { useEffect } from 'react';

export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode;
}) {
  useEffect(() => {
    // 每次路由切换都会执行
    console.log('页面已挂载');
  }, []);

  return <div className="page-wrapper">{children}</div>;
}

Layout vs Template:

特性LayoutTemplate
重新挂载❌ 保持状态✅ 每次重新挂载
性能更好稍差
使用场景导航栏、侧边栏页面级动画、统计

六、组件与样式管理:让应用更美观

Next.js 内置支持多种样式方案,你可以根据项目需求选择最适合的方式。

6.1 Tailwind CSS(现代化首选)

Tailwind 是当前最流行的原子化CSS框架,开发效率极高,也是我们推荐的主要样式方案:

# 安装 Tailwind CSS
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
// components/Button.tsx
interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
}

export default function Button({
  variant = 'primary',
  size = 'md',
  children,
  onClick,
  disabled = false
}: ButtonProps) {
  const baseClasses = "font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";

  const variantClasses = {
    primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
    secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500",
    danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
  };

  const sizeClasses = {
    sm: "px-3 py-1.5 text-sm",
    md: "px-4 py-2 text-sm",
    lg: "px-6 py-3 text-base"
  };

  const disabledClasses = disabled ? "opacity-50 cursor-not-allowed" : "";

  const className = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${disabledClasses}`.trim();

  return (
    <button
      className={className}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
}
// 使用 Tailwind 构建商品卡片
export default function ProductCard({ product }: { product: Product }) {
  return (
    <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
      <img
        src={product.image}
        alt={product.name}
        className="w-full h-48 object-cover"
      />
      <div className="p-4">
        <h3 className="text-lg font-semibold text-gray-800 mb-2">{product.name}</h3>
        <p className="text-gray-600 text-sm mb-3 line-clamp-2">{product.description}</p>
        <div className="flex items-center justify-between">
          <span className="text-xl font-bold text-blue-600">¥{product.price}</span>
          <Button size="sm">加入购物车</Button>
        </div>
      </div>
    </div>
  );
}

6.2 CSS Modules(传统方案)

如果你更偏爱传统的 CSS 文件组织方式,CSS Modules 仍然是很好的选择:

/* components/ProductCard.module.css */
.card {
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  transition: box-shadow 0.3s ease;
}

.card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.image {
  width: 100%;
  height: 192px;
  object-fit: cover;
}

.content {
  padding: 16px;
}

.title {
  font-size: 1.125rem;
  font-weight: 600;
  color: #1f2937;
  margin-bottom: 8px;
}
// components/ProductCard.tsx
import styles from './ProductCard.module.css';

export default function ProductCard({ product }: { product: Product }) {
  return (
    <div className={styles.card}>
      <img src={product.image} alt={product.name} className={styles.image} />
      <div className={styles.content}>
        <h3 className={styles.title}>{product.name}</h3>
        <p className={styles.description}>{product.description}</p>
      </div>
    </div>
  );
}

6.3 Sass/SCSS 支持

Next.js 原生支持 Sass,适合喜欢嵌套语法的开发者:

npm install sass
// styles/components.scss
$primary-color: #3b82f6;
$success-color: #10b981;
$border-radius: 8px;

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  padding: 24px;

  .product-card {
    background: white;
    border-radius: $border-radius;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    transition: transform 0.2s ease;

    &:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
    }

    .product-image {
      width: 100%;
      height: 200px;
      object-fit: cover;
    }

    .product-info {
      padding: 16px;

      .product-title {
        font-size: 1.125rem;
        font-weight: 600;
        color: #1f2937;
        margin-bottom: 8px;
      }

      .product-price {
        font-size: 1.25rem;
        font-weight: 700;
        color: $primary-color;
      }
    }
  }
}
}

6.4 CSS-in-JS 方案

支持 styled-components、emotion 等库,适合组件级样式管理:

// 使用 styled-components
import styled from 'styled-components';

const StyledProductCard = styled.div<{ featured?: boolean }>`
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s ease;
  border: ${props => props.featured ? '2px solid #3b82f6' : '1px solid #e5e7eb'};

  &:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
  }

  .product-image {
    width: 100%;
    height: 200px;
    object-fit: cover;
    border-radius: 8px 8px 0 0;
  }

  .product-info {
    padding: 16px;

    .title {
      font-size: 1.125rem;
      font-weight: 600;
      color: #1f2937;
      margin-bottom: 8px;
    }

    .price {
      font-size: 1.25rem;
      font-weight: 700;
      color: #3b82f6;
    }
  }
`;

6.5 全局样式配置

在根布局中导入全局样式,使用 Tailwind 的基础配置:

// app/layout.tsx
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN">
      <body className="min-h-screen bg-gray-50 font-sans antialiased">
        {children}
      </body>
    </html>
  );
}
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  html {
    font-family: 'Inter', system-ui, sans-serif;
  }
}

@layer components {
  .btn {
    @apply px-4 py-2 rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2;
  }

  .btn-primary {
    @apply bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500;
  }

  .btn-secondary {
    @apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
  }

  .product-grid {
    @apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6;
  }
}

6.6 组件组织最佳实践

按功能模块分组:

components/
├── ui/                  # 基础UI组件
│   ├── Button/
│   │   ├── index.tsx
│   │   └── Button.test.tsx
│   ├── Card/
│   └── Input/
├── layout/              # 布局组件
│   ├── Header/
│   ├── Footer/
│   └── Sidebar/
└── shop/                # 商城业务组件
    ├── ProductCard/
    ├── CartItem/
    ├── CheckoutForm/
    └── OrderSummary/

组件文件结构示例:

// components/ui/Button/index.tsx
import { ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils'; // 用于合并className的工具函数

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  fullWidth?: boolean;
}

export default function Button({
  variant = 'primary',
  size = 'md',
  loading = false,
  fullWidth = false,
  children,
  className,
  disabled,
  ...props
}: ButtonProps) {
  const baseClasses = "inline-flex items-center justify-center font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";

  const variantClasses = {
    primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
    secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500",
    danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
  };

  const sizeClasses = {
    sm: "px-3 py-1.5 text-sm",
    md: "px-4 py-2 text-sm",
    lg: "px-6 py-3 text-base"
  };

  const widthClasses = fullWidth ? "w-full" : "";

  const buttonClasses = cn(
    baseClasses,
    variantClasses[variant],
    sizeClasses[size],
    widthClasses,
    className
  );

  return (
    <button
      className={buttonClasses}
      disabled={disabled || loading}
      {...props}
    >
      {loading && (
        <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
          <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
          <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
        </svg>
      )}
      {children}
    </button>
  );
}

// 导出类型供其他组件使用
export type { ButtonProps };
// components/shop/ProductCard/index.tsx
import Image from 'next/image';
import Button from '@/components/ui/Button';

interface Product {
  id: string;
  name: string;
  price: number;
  image: string;
  description: string;
  inStock: boolean;
}

interface ProductCardProps {
  product: Product;
  onAddToCart: (productId: string) => void;
}

export default function ProductCard({ product, onAddToCart }: ProductCardProps) {
  return (
    <div className="group bg-white rounded-lg shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1">
      <div className="relative aspect-square overflow-hidden">
        <Image
          src={product.image}
          alt={product.name}
          fill
          className="object-cover group-hover:scale-105 transition-transform duration-300"
        />
        {!product.inStock && (
          <div className="absolute inset-0 bg-gray-900 bg-opacity-50 flex items-center justify-center">
            <span className="text-white font-semibold bg-red-600 px-3 py-1 rounded-full text-sm">
              售罄
            </span>
          </div>
        )}
      </div>

      <div className="p-4">
        <h3 className="text-lg font-semibold text-gray-800 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 items-center justify-between">
          <div className="flex items-center space-x-1">
            <span className="text-2xl font-bold text-blue-600">
              ¥{product.price.toLocaleString()}
            </span>
          </div>

          <Button
            size="sm"
            disabled={!product.inStock}
            onClick={() => onAddToCart(product.id)}
            className="shrink-0"
          >
            {product.inStock ? '加入购物车' : '缺货'}
          </Button>
        </div>
      </div>
    </div>
  );
}
// lib/utils.ts - className 合并工具函数
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

七、部署上线:让全世界看到你的作品

7.1 Vercel 部署(一键部署)

Vercel 是 Next.js 官方推荐的部署平台,配置简单,性能卓越:

步骤:

  1. 将代码推送到 GitHub/GitLab/Bitbucket
  2. 访问 vercel.com,使用 Git 账号登录
  3. 点击 "New Project",选择你的仓库
  4. Vercel 会自动识别 Next.js 项目并部署
  5. 几分钟后,你就有一个 your-app.vercel.app 的在线地址

优势:

  • 🚀 零配置部署:自动识别 Next.js 项目
  • 🌍 全球 CDN:Edge Network 保证访问速度
  • 🔧 自动构建:每次 Git push 都会自动重新部署
  • 📊 性能监控:内置 Web Vitals 监控
  • 🆓 免费额度:个人项目完全够用

7.2 其他部署选择

Netlify:

# 构建命令
npm run build

# 发布目录
out/  # 需要在 next.config.js 中配置 output: 'export'

自建服务器:

# 生产环境构建
npm run build

# 启动生产服务器
npm start

Docker 部署:

# Dockerfile
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]

7.3 部署前优化检查

性能优化:

// 图片优化
import Image from 'next/image';

export default function Profile() {
  return (
    <Image
      src="/profile.jpg"
      alt="Profile Picture"
      width={200}
      height={200}
      priority  // 首屏图片
    />
  );
}

代码分割:

// 动态导入减少首屏包大小
import dynamic from 'next/dynamic';

const HeavyComponent = dynamic(() => import('../components/Heavy'), {
  loading: () => <p>Loading...</p>,
  ssr: false  // 客户端渲染
});

环境变量配置:

# .env.local
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
DATABASE_URL=your_production_database_url

八、总结与期望

Next.js 为 React 项目注入了“全栈血统”,具备更强的页面组织能力、布局机制和开发体验。

本篇重点回顾:

  • App Router 架构下的项目目录与布局方式
  • 基于文件的嵌套路由设计
  • 与经典 pages 路由的对比和过渡策略

📘 下一篇我们将重点介绍 Next.js 的数据获取方式以及API路由的使用方法,敬请期待!