Next.js 组件开发
组件是 Next.js 应用的基础构建块。本章将详细介绍如何在 Next.js 中创建、组织和优化 React 组件。
组件基础
函数组件
typescript
// components/Button.tsx
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={`btn btn-${variant} ${disabled ? 'btn-disabled' : ''}`}
>
{children}
</button>
)
}组件使用
typescript
// app/page.tsx
import Button from '@/components/Button'
export default function HomePage() {
const handleClick = () => {
console.log('按钮被点击')
}
return (
<div>
<h1>欢迎使用 Next.js</h1>
<Button onClick={handleClick}>
点击我
</Button>
<Button variant="secondary" disabled>
禁用按钮
</Button>
</div>
)
}服务端组件 vs 客户端组件
服务端组件 (默认)
typescript
// components/PostList.tsx
interface Post {
id: string
title: string
excerpt: string
publishedAt: string
}
async function fetchPosts(): Promise<Post[]> {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostList() {
const posts = await fetchPosts()
return (
<div className="post-list">
{posts.map((post) => (
<article key={post.id} className="post-card">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
</article>
))}
</div>
)
}客户端组件
typescript
// components/SearchBox.tsx
'use client'
import { useState, useEffect } from 'react'
interface SearchBoxProps {
onSearch: (query: string) => void
placeholder?: string
}
export default function SearchBox({ onSearch, placeholder = '搜索...' }: SearchBoxProps) {
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
// 防抖处理
useEffect(() => {
const timer = setTimeout(() => {
setDebounced(query)
}, 300)
return () => clearTimeout(timer)
}, [query])
useEffect(() => {
if (debounced) {
onSearch(debounced)
}
}, [debounced, onSearch])
return (
<div className="search-box">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="search-input"
/>
{query && (
<button
onClick={() => setQuery('')}
className="clear-button"
>
✕
</button>
)}
</div>
)
}组件组合模式
复合组件模式
typescript
// components/Card/index.tsx
interface CardProps {
children: React.ReactNode
className?: string
}
function Card({ children, className = '' }: CardProps) {
return (
<div className={`card ${className}`}>
{children}
</div>
)
}
function CardHeader({ children }: { children: React.ReactNode }) {
return <div className="card-header">{children}</div>
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
function CardFooter({ children }: { children: React.ReactNode }) {
return <div className="card-footer">{children}</div>
}
// 导出复合组件
Card.Header = CardHeader
Card.Body = CardBody
Card.Footer = CardFooter
export default Card使用复合组件
typescript
// app/profile/page.tsx
import Card from '@/components/Card'
export default function ProfilePage() {
return (
<div>
<Card>
<Card.Header>
<h2>用户资料</h2>
</Card.Header>
<Card.Body>
<p>姓名: 张三</p>
<p>邮箱: zhangsan@example.com</p>
</Card.Body>
<Card.Footer>
<button>编辑资料</button>
</Card.Footer>
</Card>
</div>
)
}高阶组件 (HOC)
创建 HOC
typescript
// hoc/withAuth.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
interface User {
id: string
name: string
email: string
}
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P & { user: User }>
) {
return function AuthenticatedComponent(props: P) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const router = useRouter()
useEffect(() => {
const checkAuth = async () => {
try {
const response = await fetch('/api/auth/me')
if (response.ok) {
const userData = await response.json()
setUser(userData)
} else {
router.push('/login')
}
} catch (error) {
router.push('/login')
} finally {
setLoading(false)
}
}
checkAuth()
}, [router])
if (loading) {
return <div>验证身份中...</div>
}
if (!user) {
return null
}
return <WrappedComponent {...props} user={user} />
}
}
export default withAuth使用 HOC
typescript
// components/Dashboard.tsx
'use client'
import withAuth from '@/hoc/withAuth'
interface DashboardProps {
user: {
id: string
name: string
email: string
}
}
function Dashboard({ user }: DashboardProps) {
return (
<div>
<h1>欢迎,{user.name}</h1>
<p>邮箱: {user.email}</p>
</div>
)
}
export default withAuth(Dashboard)自定义 Hooks
数据获取 Hook
typescript
// hooks/useFetch.ts
'use client'
import { useState, useEffect } from 'react'
interface FetchState<T> {
data: T | null
loading: boolean
error: string | null
}
function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null
})
useEffect(() => {
const fetchData = async () => {
try {
setState(prev => ({ ...prev, loading: true, error: null }))
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
setState({ data, loading: false, error: null })
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error.message : '未知错误'
})
}
}
fetchData()
}, [url])
return state
}
export default useFetch本地存储 Hook
typescript
// hooks/useLocalStorage.ts
'use client'
import { useState, useEffect } from 'react'
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(initialValue)
useEffect(() => {
try {
const item = window.localStorage.getItem(key)
if (item) {
setStoredValue(JSON.parse(item))
}
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error)
}
}, [key])
const setValue = (value: T | ((val: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
window.localStorage.setItem(key, JSON.stringify(valueToStore))
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
}
return [storedValue, setValue] as const
}
export default useLocalStorage使用自定义 Hooks
typescript
// components/UserProfile.tsx
'use client'
import useFetch from '@/hooks/useFetch'
import useLocalStorage from '@/hooks/useLocalStorage'
interface User {
id: string
name: string
email: string
}
export default function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`)
const [theme, setTheme] = useLocalStorage('theme', 'light')
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
if (!user) return <div>用户未找到</div>
return (
<div className={`profile theme-${theme}`}>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
切换主题
</button>
</div>
)
}表单组件
受控表单组件
typescript
// components/ContactForm.tsx
'use client'
import { useState } from 'react'
interface FormData {
name: string
email: string
message: string
}
interface ContactFormProps {
onSubmit: (data: FormData) => Promise<void>
}
export default function ContactForm({ onSubmit }: ContactFormProps) {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: ''
})
const [errors, setErrors] = useState<Partial<FormData>>({})
const [submitting, setSubmitting] = useState(false)
const validateForm = (): boolean => {
const newErrors: Partial<FormData> = {}
if (!formData.name.trim()) {
newErrors.name = '姓名不能为空'
}
if (!formData.email.trim()) {
newErrors.email = '邮箱不能为空'
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = '邮箱格式无效'
}
if (!formData.message.trim()) {
newErrors.message = '消息不能为空'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) return
setSubmitting(true)
try {
await onSubmit(formData)
setFormData({ name: '', email: '', message: '' })
setErrors({})
} catch (error) {
console.error('提交失败:', error)
} finally {
setSubmitting(false)
}
}
const handleChange = (field: keyof FormData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
setFormData(prev => ({
...prev,
[field]: e.target.value
}))
// 清除对应字段的错误
if (errors[field]) {
setErrors(prev => ({
...prev,
[field]: undefined
}))
}
}
return (
<form onSubmit={handleSubmit} className="contact-form">
<div className="form-group">
<label htmlFor="name">姓名</label>
<input
id="name"
type="text"
value={formData.name}
onChange={handleChange('name')}
className={errors.name ? 'error' : ''}
/>
{errors.name && <span className="error-message">{errors.name}</span>}
</div>
<div className="form-group">
<label htmlFor="email">邮箱</label>
<input
id="email"
type="email"
value={formData.email}
onChange={handleChange('email')}
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
<div className="form-group">
<label htmlFor="message">消息</label>
<textarea
id="message"
value={formData.message}
onChange={handleChange('message')}
className={errors.message ? 'error' : ''}
rows={4}
/>
{errors.message && <span className="error-message">{errors.message}</span>}
</div>
<button type="submit" disabled={submitting}>
{submitting ? '提交中...' : '提交'}
</button>
</form>
)
}列表和数据展示组件
数据表格组件
typescript
// components/DataTable.tsx
'use client'
import { useState } from 'react'
interface Column<T> {
key: keyof T
title: string
render?: (value: any, record: T) => React.ReactNode
sortable?: boolean
}
interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
loading?: boolean
pagination?: {
current: number
pageSize: number
total: number
onChange: (page: number) => void
}
}
export default function DataTable<T extends { id: string | number }>({
data,
columns,
loading = false,
pagination
}: DataTableProps<T>) {
const [sortField, setSortField] = useState<keyof T | null>(null)
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc')
const handleSort = (field: keyof T) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}
const sortedData = [...data].sort((a, b) => {
if (!sortField) return 0
const aValue = a[sortField]
const bValue = b[sortField]
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
return 0
})
if (loading) {
return <div className="table-loading">加载中...</div>
}
return (
<div className="data-table">
<table>
<thead>
<tr>
{columns.map((column) => (
<th
key={String(column.key)}
onClick={() => column.sortable && handleSort(column.key)}
className={column.sortable ? 'sortable' : ''}
>
{column.title}
{column.sortable && sortField === column.key && (
<span className="sort-indicator">
{sortDirection === 'asc' ? ' ↑' : ' ↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((record) => (
<tr key={record.id}>
{columns.map((column) => (
<td key={String(column.key)}>
{column.render
? column.render(record[column.key], record)
: String(record[column.key])
}
</td>
))}
</tr>
))}
</tbody>
</table>
{pagination && (
<div className="pagination">
<button
onClick={() => pagination.onChange(pagination.current - 1)}
disabled={pagination.current <= 1}
>
上一页
</button>
<span>
第 {pagination.current} 页,共 {Math.ceil(pagination.total / pagination.pageSize)} 页
</span>
<button
onClick={() => pagination.onChange(pagination.current + 1)}
disabled={pagination.current >= Math.ceil(pagination.total / pagination.pageSize)}
>
下一页
</button>
</div>
)}
</div>
)
}使用数据表格
typescript
// app/users/page.tsx
'use client'
import { useState, useEffect } from 'react'
import DataTable from '@/components/DataTable'
interface User {
id: string
name: string
email: string
role: string
createdAt: string
}
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0
})
const columns = [
{
key: 'name' as keyof User,
title: '姓名',
sortable: true
},
{
key: 'email' as keyof User,
title: '邮箱',
sortable: true
},
{
key: 'role' as keyof User,
title: '角色',
render: (role: string) => (
<span className={`role-badge role-${role}`}>
{role}
</span>
)
},
{
key: 'createdAt' as keyof User,
title: '创建时间',
render: (date: string) => new Date(date).toLocaleDateString(),
sortable: true
}
]
useEffect(() => {
fetchUsers()
}, [pagination.current])
const fetchUsers = async () => {
setLoading(true)
try {
const response = await fetch(
`/api/users?page=${pagination.current}&pageSize=${pagination.pageSize}`
)
const data = await response.json()
setUsers(data.users)
setPagination(prev => ({ ...prev, total: data.total }))
} catch (error) {
console.error('获取用户失败:', error)
} finally {
setLoading(false)
}
}
const handlePageChange = (page: number) => {
setPagination(prev => ({ ...prev, current: page }))
}
return (
<div>
<h1>用户管理</h1>
<DataTable
data={users}
columns={columns}
loading={loading}
pagination={{
...pagination,
onChange: handlePageChange
}}
/>
</div>
)
}组件优化
React.memo 优化
typescript
// components/ExpensiveComponent.tsx
import React from 'react'
interface ExpensiveComponentProps {
data: any[]
onItemClick: (id: string) => void
}
const ExpensiveComponent = React.memo(function ExpensiveComponent({
data,
onItemClick
}: ExpensiveComponentProps) {
console.log('ExpensiveComponent 重新渲染')
return (
<div>
{data.map((item) => (
<div key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</div>
))}
</div>
)
}, (prevProps, nextProps) => {
// 自定义比较函数
return (
prevProps.data.length === nextProps.data.length &&
prevProps.onItemClick === nextProps.onItemClick
)
})
export default ExpensiveComponentuseCallback 和 useMemo
typescript
// components/OptimizedParent.tsx
'use client'
import { useState, useCallback, useMemo } from 'react'
import ExpensiveComponent from './ExpensiveComponent'
export default function OptimizedParent() {
const [items, setItems] = useState([])
const [filter, setFilter] = useState('')
// 使用 useMemo 缓存计算结果
const filteredItems = useMemo(() => {
return items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
)
}, [items, filter])
// 使用 useCallback 缓存函数
const handleItemClick = useCallback((id: string) => {
console.log('点击了项目:', id)
}, [])
return (
<div>
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="搜索..."
/>
<ExpensiveComponent
data={filteredItems}
onItemClick={handleItemClick}
/>
</div>
)
}组件测试
单元测试
typescript
// __tests__/components/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import Button from '@/components/Button'
describe('Button 组件', () => {
it('应该渲染按钮文本', () => {
render(<Button>点击我</Button>)
expect(screen.getByText('点击我')).toBeInTheDocument()
})
it('应该处理点击事件', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>点击我</Button>)
fireEvent.click(screen.getByText('点击我'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('禁用状态下不应该触发点击事件', () => {
const handleClick = jest.fn()
render(
<Button onClick={handleClick} disabled>
禁用按钮
</Button>
)
fireEvent.click(screen.getByText('禁用按钮'))
expect(handleClick).not.toHaveBeenCalled()
})
})组件文档
使用 Storybook
typescript
// stories/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import Button from '@/components/Button'
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary'],
},
},
}
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
children: '主要按钮',
variant: 'primary',
},
}
export const Secondary: Story = {
args: {
children: '次要按钮',
variant: 'secondary',
},
}
export const Disabled: Story = {
args: {
children: '禁用按钮',
disabled: true,
},
}总结
Next.js 组件开发的关键点:
- 合理选择组件类型 - 服务端组件用于数据获取,客户端组件用于交互
- 组件复用 - 通过 props 和组合模式提高复用性
- 性能优化 - 使用 memo、useCallback、useMemo 等优化手段
- 类型安全 - 使用 TypeScript 定义清晰的接口
- 测试覆盖 - 编写单元测试确保组件质量
- 文档完善 - 使用 Storybook 等工具维护组件文档
良好的组件设计是构建可维护、可扩展 Next.js 应用的基础。