Skip to content

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 ExpensiveComponent

useCallback 和 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 应用的基础。

本站内容仅供学习和研究使用。