# Next.js 页面组件最佳实践
## 规则类型
代码审查 (REVIEW)
## 适用技术
Next.js, React, TypeScript
## 问题描述
Next.js 应用中的页面组件经常混合了过多的职责,导致代码难以维护和测试。常见问题包括:
- 服务器组件和客户端组件职责混淆
- 数据获取逻辑不当
- 组件过大,职责不清晰
- 缺乏适当的错误处理和加载状态
- TypeScript 类型定义不完整
- 可访问性问题
## 规则内容
Next.js 页面组件应遵循以下最佳实践:
1. 明确区分服务器组件和客户端组件
2. 使用适当的数据获取方法
3. 将大型组件拆分为更小的、职责单一的组件
4. 实现完善的错误处理和加载状态
5. 使用完整的 TypeScript 类型定义
6. 确保组件符合可访问性标准
## 不良代码示例
```tsx
// app/products/page.tsx
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
export default function ProductsPage() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function fetchProducts() {
try {
const res = await fetch('/api/products')
if (!res.ok) throw new Error('Failed to fetch')
const data = await res.json()
setProducts(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchProducts()
}, [])
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
return (
<div>
<h1>Products</h1>
<div className="grid">
{products.map(product => (
<div key={product.id} className="card">
<img src={product.image} alt={product.name} />
<h2>{product.name}</h2>
<p>{product.price}</p>
<p>{product.description}</p>
<button onClick={() => alert(`Added ${product.name} to cart`)}>
Add to Cart
</button>
<Link href={`/products/${product.id}`}>View Details</Link>
</div>
))}
</div>
</div>
)
}
```
## 推荐代码示例
```tsx
// app/products/page.tsx (服务器组件)
import { Suspense } from 'react'
import ProductList from './components/ProductList'
import ProductListSkeleton from './components/ProductListSkeleton'
import { getProducts } from '@/lib/products'
import ErrorBoundary from '@/components/ErrorBoundary'
export const metadata = {
title: 'Products | Our Store',
description: 'Browse our latest products',
}
export default async function ProductsPage() {
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">Products</h1>
<ErrorBoundary fallback={<p className="text-red-500">Failed to load products</p>}>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList />
</Suspense>
</ErrorBoundary>
</div>
)
}
// app/products/components/ProductList.tsx (服务器组件)
import { getProducts } from '@/lib/products'
import ProductCard from './ProductCard'
import type { Product } from '@/types/product'
export default async function ProductList() {
const products = await getProducts()
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)
}
// app/products/components/ProductCard.tsx (客户端组件)
'use client'
import { useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { addToCart } from '@/lib/cart-actions'
import type { Product } from '@/types/product'
interface ProductCardProps {
product: Product
}
export default function ProductCard({ product }: ProductCardProps) {
const [isAdding, setIsAdding] = useState(false)
async function handleAddToCart() {
setIsAdding(true)
await addToCart(product.id)
setIsAdding(false)
}
return (
<div className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<div className="relative h-48">
<Image
src={product.image}
alt={product.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
</div>
<div className="p-4">
<h2 className="text-xl font-semibold">{product.name}</h2>
<p className="text-lg font-bold text-blue-600">{product.price}</p>
<p className="text-gray-600 line-clamp-2">{product.description}</p>
<div className="mt-4 flex justify-between">
<button
onClick={handleAddToCart}
disabled={isAdding}
aria-label={`Add ${product.name} to cart`}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isAdding ? 'Adding...' : 'Add to Cart'}
</button>
<Link
href={`/products/${product.id}`}
className="text-blue-600 px-4 py-2 border border-blue-600 rounded hover:bg-blue-50"
>
View Details
</Link>
</div>
</div>
</div>
)
}
```
## 解释说明
改进后的代码有以下优势:
1. **服务器组件与客户端组件分离**:
- 页面主体和数据获取使用服务器组件
- 交互部分(如添加到购物车按钮)使用客户端组件
2. **更好的数据获取**:
- 使用服务器组件直接获取数据,减少客户端 JavaScript
- 避免了客户端的 useEffect 获取数据,减少了瀑布流请求
3. **组件拆分**:
- 将页面拆分为多个职责单一的组件
- ProductList 负责展示产品列表
- ProductCard 负责单个产品的展示和交互
4. **改进的错误处理和加载状态**:
- 使用 Suspense 和 ErrorBoundary 处理加载和错误状态
- 提供了骨架屏组件作为加载状态
5. **TypeScript 类型增强**:
- 为组件 props 和数据添加了明确的类型定义
- 使用类型导入确保类型安全
6. **可访问性改进**:
- 添加了 aria-label 属性
- 使用语义化 HTML 结构
- 添加了禁用状态的视觉反馈
7. **性能优化**:
- 使用 Image 组件进行图片优化
- 添加了 sizes 属性以适应不同屏幕尺寸
- 使用 line-clamp 限制文本长度
## 例外情况
在以下情况下可以适当调整这些规则:
- 非常简单的页面可以不需要拆分组件
- 某些特殊场景可能需要在客户端组件中获取数据
- 原型开发阶段可以简化错误处理和加载状态
## 相关规则
- Next.js 数据获取最佳实践
- React 组件拆分原则
- TypeScript 类型定义最佳实践
- React 可访问性指南
## 参考资料
- [Next.js 官方文档 - 服务器组件](https://nextjs.org/docs/app/building-your-application/rendering/server-components)
- [Next.js 官方文档 - 数据获取](https://nextjs.org/docs/app/building-your-application/data-fetching)
- [React 官方文档 - 错误边界](https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary)
- [Web 可访问性指南 (WCAG)](https://www.w3.org/WAI/standards-guidelines/wcag/)