06. 组件开发基础

Next.js 13+ 引入了 Server Components 和 Client Components 的概念,这是理解 Next.js 架构的关键。本章将深入讲解这两种组件的区别和使用场景。


1. Server Component vs Client Component

核心区别

特性Server ComponentClient Component
运行位置服务器浏览器
JavaScript不发送到客户端发送到客户端
交互性❌ 不支持事件处理、useState 等✅ 支持所有 React Hooks
数据获取✅ 可以直接访问数据库、API❌ 需要通过 API 路由
性能🚀 更快的首屏加载⚠️ 需要下载 JS 才能交互

默认是 Server Component

在 Next.js App Router 中,默认所有组件都是 Server Component

// src/app/page.tsx
// 这是一个 Server Component(默认)
export default function Home() {
  // ✅ 可以直接访问数据库
  // ✅ 可以直接读取文件系统
  // ❌ 不能使用 useState、useEffect
  // ❌ 不能使用事件处理(onClick 等)
  
  return <div>Hello World</div>
}

使用 Client Component

需要交互性时,使用 'use client' 指令:

// src/components/Counter.tsx
'use client' // 标记为 Client Component
 
import { useState } from 'react'
 
export default function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
    </div>
  )
}

2. 何时使用 Server Component?

✅ 适合 Server Component 的场景

  1. 数据获取(从数据库、API 获取数据)
  2. 访问后端资源(文件系统、环境变量)
  3. 减少客户端 JS 体积(提升性能)
  4. SEO 优化(内容在服务端渲染)

实战案例:博客文章列表(Server Component)

// src/app/blog/page.tsx
// Server Component - 适合数据获取
 
// 模拟从数据库获取数据
async function getPosts() {
  // 实际项目中这里可能是:
  // const posts = await db.post.findMany()
  return [
    { id: '1', title: 'Next.js 入门', content: '...' },
    { id: '2', title: 'TypeScript 实践', content: '...' },
  ]
}
 
export default async function BlogPage() {
  const posts = await getPosts()
 
  return (
    <div className="max-w-4xl mx-auto px-4 py-20">
      <h1 className="text-4xl font-bold mb-8">博客</h1>
      <div className="space-y-6">
        {posts.map((post) => (
          <article key={post.id} className="bg-white p-6 rounded-lg shadow">
            <h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
            <p className="text-gray-600">{post.content}</p>
          </article>
        ))}
      </div>
    </div>
  )
}

3. 何时使用 Client Component?

✅ 适合 Client Component 的场景

  1. 用户交互(点击、输入、拖拽)
  2. 状态管理(useState、useReducer)
  3. 生命周期(useEffect、useLayoutEffect)
  4. 浏览器 API(localStorage、window、document)
  5. 第三方库(需要客户端运行的库)

实战案例:搜索框(Client Component)

// src/components/SearchBox.tsx
'use client'
 
import { useState } from 'react'
import { useRouter } from 'next/navigation'
 
export default function SearchBox() {
  const [query, setQuery] = useState('')
  const router = useRouter()
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    router.push(`/search?q=${encodeURIComponent(query)}`)
  }
 
  return (
    <form onSubmit={handleSubmit} className="flex gap-2">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
        className="px-4 py-2 border rounded-lg flex-1"
      />
      <button
        type="submit"
        className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
      >
        搜索
      </button>
    </form>
  )
}

4. 混合使用:Server + Client Component

最佳实践是:在 Server Component 中获取数据,传递给 Client Component 进行交互

实战案例:产品列表 + 购物车

// src/app/products/page.tsx
// Server Component - 获取数据
async function getProducts() {
  // 从数据库获取
  return [
    { id: '1', name: '商品A', price: 100 },
    { id: '2', name: '商品B', price: 200 },
  ]
}
 
export default async function ProductsPage() {
  const products = await getProducts()
 
  return (
    <div>
      <h1>商品列表</h1>
      {/* 将数据传递给 Client Component */}
      <ProductList products={products} />
    </div>
  )
}
// src/components/ProductList.tsx
'use client'
 
import { useState } from 'react'
 
interface Product {
  id: string
  name: string
  price: number
}
 
interface ProductListProps {
  products: Product[] // 从 Server Component 接收数据
}
 
export default function ProductList({ products }: ProductListProps) {
  const [cart, setCart] = useState<Product[]>([])
 
  const addToCart = (product: Product) => {
    setCart([...cart, product])
  }
 
  return (
    <div>
      <div className="grid gap-4">
        {products.map((product) => (
          <div key={product.id} className="border p-4 rounded">
            <h3>{product.name}</h3>
            <p>¥{product.price}</p>
            <button
              onClick={() => addToCart(product)}
              className="mt-2 bg-blue-600 text-white px-4 py-2 rounded"
            >
              加入购物车
            </button>
          </div>
        ))}
      </div>
      
      <div className="mt-8">
        <h2>购物车 ({cart.length})</h2>
        {cart.map((item) => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    </div>
  )
}

5. 组件组织最佳实践

目录结构

src/
├── app/                    # 页面(Server Component)
│   ├── page.tsx
│   └── blog/
│       └── page.tsx
├── components/             # 可复用组件
│   ├── ui/                 # UI 组件(Client Component)
│   │   ├── Button.tsx
│   │   └── Input.tsx
│   ├── features/           # 功能组件
│   │   ├── SearchBox.tsx   # Client Component
│   │   └── ProductList.tsx  # Client Component
│   └── server/              # Server Component(可选)
│       └── DataFetcher.tsx
└── lib/                    # 工具函数
    └── utils.ts

组件命名规范

  • PascalCase:组件文件名和组件名
  • 描述性命名UserProfile 而不是 Profile
  • 功能分组:相关组件放在同一文件夹

6. Props 类型定义

使用 TypeScript 定义 Props

// src/components/Button.tsx
'use client'
 
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
  disabled?: boolean
}
 
export default function Button({
  children,
  onClick,
  variant = 'primary',
  disabled = false,
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`
        px-4 py-2 rounded-lg
        ${variant === 'primary' ? 'bg-blue-600 text-white' : 'bg-gray-200'}
        ${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90'}
      `}
    >
      {children}
    </button>
  )
}

使用示例

// src/app/page.tsx
import Button from '@/components/Button'
 
export default function Home() {
  return (
    <div>
      <Button variant="primary" onClick={() => alert('点击了')}>
        主要按钮
      </Button>
      <Button variant="secondary">次要按钮</Button>
      <Button disabled>禁用按钮</Button>
    </div>
  )
}

7. 实战案例:待办事项应用

需求

创建一个待办事项应用:

  • 显示待办列表(Server Component 获取数据)
  • 添加新待办(Client Component 处理交互)
  • 切换完成状态(Client Component)

实现

// src/app/todos/page.tsx
// Server Component - 获取初始数据
async function getTodos() {
  // 实际项目中从数据库获取
  return [
    { id: '1', text: '学习 Next.js', completed: false },
    { id: '2', text: '完成项目', completed: true },
  ]
}
 
export default async function TodosPage() {
  const initialTodos = await getTodos()
 
  return (
    <div className="max-w-2xl mx-auto px-4 py-20">
      <h1 className="text-4xl font-bold mb-8">待办事项</h1>
      <TodoApp initialTodos={initialTodos} />
    </div>
  )
}
// src/components/TodoApp.tsx
'use client'
 
import { useState } from 'react'
 
interface Todo {
  id: string
  text: string
  completed: boolean
}
 
interface TodoAppProps {
  initialTodos: Todo[]
}
 
export default function TodoApp({ initialTodos }: TodoAppProps) {
  const [todos, setTodos] = useState<Todo[]>(initialTodos)
  const [input, setInput] = useState('')
 
  const addTodo = () => {
    if (input.trim()) {
      const newTodo: Todo = {
        id: Date.now().toString(),
        text: input,
        completed: false,
      }
      setTodos([...todos, newTodo])
      setInput('')
    }
  }
 
  const toggleTodo = (id: string) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    )
  }
 
  const deleteTodo = (id: string) => {
    setTodos(todos.filter((todo) => todo.id !== id))
  }
 
  return (
    <div>
      {/* 添加待办 */}
      <div className="flex gap-2 mb-6">
        <input
          type="text"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder="输入待办事项..."
          className="flex-1 px-4 py-2 border rounded-lg"
        />
        <button
          onClick={addTodo}
          className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700"
        >
          添加
        </button>
      </div>
 
      {/* 待办列表 */}
      <div className="space-y-2">
        {todos.map((todo) => (
          <div
            key={todo.id}
            className={`flex items-center gap-3 p-4 bg-white rounded-lg shadow ${
              todo.completed ? 'opacity-60' : ''
            }`}
          >
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              className="w-5 h-5"
            />
            <span
              className={`flex-1 ${
                todo.completed ? 'line-through text-gray-500' : ''
              }`}
            >
              {todo.text}
            </span>
            <button
              onClick={() => deleteTodo(todo.id)}
              className="text-red-600 hover:text-red-800"
            >
              删除
            </button>
          </div>
        ))}
      </div>
 
      {/* 统计 */}
      <div className="mt-6 text-gray-600">
        总计:{todos.length} | 已完成:{todos.filter((t) => t.completed).length} | 未完成:{todos.filter((t) => !t.completed).length}
      </div>
    </div>
  )
}

8. 常见错误和解决方案

错误 1:在 Server Component 中使用 Hooks

// ❌ 错误
export default function Page() {
  const [count, setCount] = useState(0) // 错误!
  return <div>{count}</div>
}
 
// ✅ 正确:添加 'use client'
'use client'
export default function Page() {
  const [count, setCount] = useState(0)
  return <div>{count}</div>
}

错误 2:在 Client Component 中直接访问数据库

// ❌ 错误
'use client'
import { db } from '@/lib/db'
 
export default function Component() {
  const data = db.query() // 错误!数据库只能在服务端访问
  return <div>{data}</div>
}
 
// ✅ 正确:通过 API 路由
'use client'
export default function Component() {
  const [data, setData] = useState(null)
  
  useEffect(() => {
    fetch('/api/data')
      .then(res => res.json())
      .then(setData)
  }, [])
  
  return <div>{data}</div>
}

错误 3:将 Server Component 作为 Client Component 的子组件

// ❌ 错误
'use client'
export default function ClientComponent() {
  return (
    <div>
      <ServerComponent /> {/* 错误!不能这样做 */}
    </div>
  )
}
 
// ✅ 正确:将 Server Component 作为 props 传递
'use client'
export default function ClientComponent({ children }: { children: React.ReactNode }) {
  return <div>{children}</div>
}
 
// 使用
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent /> {/* ✅ 正确 */}
    </ClientComponent>
  )
}

9. 总结

本章我们学习了:

  1. ✅ Server Component 和 Client Component 的区别
  2. ✅ 何时使用 Server Component(数据获取、性能优化)
  3. ✅ 何时使用 Client Component(用户交互、状态管理)
  4. ✅ 混合使用的最佳实践
  5. ✅ 构建了完整的待办事项应用

关键原则

  • 🎯 默认使用 Server Component(性能更好)
  • 🎯 只在需要交互时使用 Client Component(减少 JS 体积)
  • 🎯 Server Component 获取数据,Client Component 处理交互

下一步:在下一章,我们将学习样式方案,包括 Tailwind CSS 和 CSS Modules 的使用。