06. 组件开发基础
Next.js 13+ 引入了 Server Components 和 Client Components 的概念,这是理解 Next.js 架构的关键。本章将深入讲解这两种组件的区别和使用场景。
1. Server Component vs Client Component
核心区别
| 特性 | Server Component | Client 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 的场景
- 数据获取(从数据库、API 获取数据)
- 访问后端资源(文件系统、环境变量)
- 减少客户端 JS 体积(提升性能)
- 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 的场景
- 用户交互(点击、输入、拖拽)
- 状态管理(useState、useReducer)
- 生命周期(useEffect、useLayoutEffect)
- 浏览器 API(localStorage、window、document)
- 第三方库(需要客户端运行的库)
实战案例:搜索框(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. 总结
本章我们学习了:
- ✅ Server Component 和 Client Component 的区别
- ✅ 何时使用 Server Component(数据获取、性能优化)
- ✅ 何时使用 Client Component(用户交互、状态管理)
- ✅ 混合使用的最佳实践
- ✅ 构建了完整的待办事项应用
关键原则:
- 🎯 默认使用 Server Component(性能更好)
- 🎯 只在需要交互时使用 Client Component(减少 JS 体积)
- 🎯 Server Component 获取数据,Client Component 处理交互
下一步:在下一章,我们将学习样式方案,包括 Tailwind CSS 和 CSS Modules 的使用。