你如何设计一个组件?要考虑哪些方面?(API 设计、可访问性、可测试性)

20 阅读6分钟

你如何设计一个组件?要考虑哪些方面?(API 设计、可访问性、可测试性)

组件设计概述

设计一个优秀的 React 组件需要考虑多个方面,包括 API 设计、可访问性、可测试性、性能、可维护性等。一个好的组件应该易于使用、易于测试、易于维护,并且具有良好的用户体验。

1. API 设计

简洁明了的 Props 接口

// 好的 API 设计:清晰、直观
interface ButtonProps {
  // 必需属性
  children: React.ReactNode
  onClick: () => void

  // 可选属性,有合理的默认值
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'small' | 'medium' | 'large'
  disabled?: boolean
  loading?: boolean

  // 扩展性:支持原生属性
  className?: string
  style?: React.CSSProperties
  'data-testid'?: string
}

// 使用示例
<Button
  variant="primary"
  size="large"
  onClick={handleClick}
  disabled={isLoading}
  loading={isLoading}
>
  提交
</Button>

避免过度设计

// 避免:过于复杂的 API
interface BadButtonProps {
  primaryColor?: string
  secondaryColor?: string
  hoverColor?: string
  activeColor?: string
  borderColor?: string
  borderRadius?: number
  borderWidth?: number
  paddingTop?: number
  paddingBottom?: number
  paddingLeft?: number
  paddingRight?: number
  // ... 太多细节
}

// 好的设计:使用主题系统
interface GoodButtonProps {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'small' | 'medium' | 'large'
  // 样式细节由主题系统处理
}

组合 vs 继承

// 使用组合而不是继承
function Card({ children, header, footer, ...props }) {
  return (
    <div className="card" {...props}>
      {header && <div className="card-header">{header}</div>}
      <div className="card-body">{children}</div>
      {footer && <div className="card-footer">{footer}</div>}
    </div>
  )
}

// 使用示例
;<Card header={<h3>标题</h3>} footer={<button>操作</button>}>
  内容
</Card>

// 而不是通过继承创建不同的卡片类型

2. 可访问性 (Accessibility)

语义化 HTML

// 使用语义化的 HTML 元素
function Navigation({ items }) {
  return (
    <nav role="navigation" aria-label="主导航">
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            <a
              href={item.href}
              aria-current={item.current ? 'page' : undefined}
            >
              {item.label}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  )
}

// 表单元素的可访问性
function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [errors, setErrors] = useState({})

  return (
    <form onSubmit={onSubmit} noValidate>
      <div>
        <label htmlFor="email">邮箱地址</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
          required
        />
        {errors.email && (
          <div id="email-error" role="alert" aria-live="polite">
            {errors.email}
          </div>
        )}
      </div>

      <div>
        <label htmlFor="password">密码</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          aria-invalid={!!errors.password}
          aria-describedby={errors.password ? 'password-error' : undefined}
          required
        />
        {errors.password && (
          <div id="password-error" role="alert" aria-live="polite">
            {errors.password}
          </div>
        )}
      </div>

      <button type="submit">登录</button>
    </form>
  )
}

键盘导航支持

// 支持键盘导航的组件
function Dropdown({ options, value, onChange }) {
  const [isOpen, setIsOpen] = useState(false)
  const [focusedIndex, setFocusedIndex] = useState(-1)
  const buttonRef = useRef(null)
  const listRef = useRef(null)

  const handleKeyDown = (e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setFocusedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0))
        break
      case 'ArrowUp':
        e.preventDefault()
        setFocusedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1))
        break
      case 'Enter':
      case ' ':
        e.preventDefault()
        if (focusedIndex >= 0) {
          onChange(options[focusedIndex])
          setIsOpen(false)
        }
        break
      case 'Escape':
        setIsOpen(false)
        buttonRef.current?.focus()
        break
    }
  }

  return (
    <div className="dropdown">
      <button
        ref={buttonRef}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        aria-expanded={isOpen}
        aria-haspopup="listbox"
      >
        {value?.label || '选择选项'}
      </button>

      {isOpen && (
        <ul ref={listRef} role="listbox" aria-label="选项列表">
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={option.value === value?.value}
              className={index === focusedIndex ? 'focused' : ''}
              onClick={() => {
                onChange(option)
                setIsOpen(false)
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

屏幕阅读器支持

// 为屏幕阅读器提供适当的反馈
function LoadingSpinner({ loading, children }) {
  return (
    <div>
      {children}
      {loading && (
        <div
          role="status"
          aria-live="polite"
          aria-label="加载中"
          className="sr-only"
        >
          正在加载...
        </div>
      )}
    </div>
  )
}

// 动态内容更新通知
function Notification({ message, type }) {
  const [isVisible, setIsVisible] = useState(false)

  useEffect(() => {
    if (message) {
      setIsVisible(true)
      const timer = setTimeout(() => setIsVisible(false), 5000)
      return () => clearTimeout(timer)
    }
  }, [message])

  return (
    <div
      role="alert"
      aria-live="assertive"
      className={`notification ${type} ${isVisible ? 'visible' : ''}`}
    >
      {message}
    </div>
  )
}

3. 可测试性

易于测试的组件结构

// 将业务逻辑与 UI 分离
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue)

  const increment = useCallback(() => {
    setCount((prev) => prev + 1)
  }, [])

  const decrement = useCallback(() => {
    setCount((prev) => prev - 1)
  }, [])

  const reset = useCallback(() => {
    setCount(initialValue)
  }, [initialValue])

  return { count, increment, decrement, reset }
}

// 组件只负责渲染
function Counter({ initialValue = 0, onCountChange }) {
  const { count, increment, decrement, reset } = useCounter(initialValue)

  useEffect(() => {
    onCountChange?.(count)
  }, [count, onCountChange])

  return (
    <div data-testid="counter">
      <span data-testid="count">{count}</span>
      <button data-testid="increment" onClick={increment}>
        +
      </button>
      <button data-testid="decrement" onClick={decrement}>
        -
      </button>
      <button data-testid="reset" onClick={reset}>
        重置
      </button>
    </div>
  )
}

测试友好的 API

// 提供测试所需的属性和方法
function Modal({ isOpen, onClose, children, title }) {
  const modalRef = useRef(null)

  // 暴露给测试的方法
  useImperativeHandle(ref, () => ({
    focus: () => modalRef.current?.focus(),
    getTitle: () => title,
    isOpen: () => isOpen,
  }))

  if (!isOpen) return null

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      data-testid="modal"
    >
      <div className="modal-header">
        <h2 id="modal-title">{title}</h2>
        <button
          data-testid="close-button"
          onClick={onClose}
          aria-label="关闭对话框"
        >
          ×
        </button>
      </div>
      <div className="modal-body">{children}</div>
    </div>
  )
}

// 测试示例
test('modal opens and closes correctly', () => {
  const { getByTestId, queryByTestId } = render(
    <Modal isOpen={true} onClose={jest.fn()} title="测试标题">
      内容
    </Modal>
  )

  expect(getByTestId('modal')).toBeInTheDocument()
  expect(getByTestId('modal')).toHaveAttribute('aria-modal', 'true')

  fireEvent.click(getByTestId('close-button'))
  expect(queryByTestId('modal')).not.toBeInTheDocument()
})

4. 性能优化

避免不必要的重新渲染

// 使用 React.memo 优化函数组件
const ExpensiveComponent = React.memo(function ExpensiveComponent({
  data,
  onUpdate,
}) {
  const processedData = useMemo(() => {
    return data.map((item) => ({
      ...item,
      processed: true,
    }))
  }, [data])

  const handleUpdate = useCallback(
    (id) => {
      onUpdate(id)
    },
    [onUpdate]
  )

  return (
    <div>
      {processedData.map((item) => (
        <ExpensiveItem key={item.id} item={item} onUpdate={handleUpdate} />
      ))}
    </div>
  )
})

// 自定义比较函数
const MyComponent = React.memo(
  function MyComponent({ user, settings }) {
    return <div>{user.name}</div>
  },
  (prevProps, nextProps) => {
    // 只有 user.id 变化时才重新渲染
    return prevProps.user.id === nextProps.user.id
  }
)

懒加载和代码分割

// 懒加载组件
const LazyComponent = lazy(() => import('./LazyComponent'))

function App() {
  return (
    <div>
      <Suspense fallback={<div>加载中...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  )
}

// 虚拟滚动优化长列表
function VirtualizedList({ items, itemHeight = 50 }) {
  const [scrollTop, setScrollTop] = useState(0)
  const containerHeight = 400
  const visibleCount = Math.ceil(containerHeight / itemHeight)
  const startIndex = Math.floor(scrollTop / itemHeight)
  const endIndex = Math.min(startIndex + visibleCount, items.length)

  const visibleItems = items.slice(startIndex, endIndex)

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.target.scrollTop)}
    >
      <div style={{ height: items.length * itemHeight, position: 'relative' }}>
        {visibleItems.map((item, index) => (
          <div
            key={item.id}
            style={{
              position: 'absolute',
              top: (startIndex + index) * itemHeight,
              height: itemHeight,
              width: '100%',
            }}
          >
            {item.content}
          </div>
        ))}
      </div>
    </div>
  )
}

5. 错误处理

错误边界

// 错误边界组件
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error }
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo)
    // 发送错误报告
    this.props.onError?.(error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div role="alert">
            <h2>出现错误</h2>
            <p>抱歉,发生了意外错误。</p>
            <button onClick={() => this.setState({ hasError: false })}>
              重试
            </button>
          </div>
        )
      )
    }

    return this.props.children
  }
}

// 使用错误边界
function App() {
  return (
    <ErrorBoundary
      onError={(error, errorInfo) => {
        // 发送错误报告
        reportError(error, errorInfo)
      }}
      fallback={<ErrorFallback />}
    >
      <MyComponent />
    </ErrorBoundary>
  )
}

优雅降级

// 支持优雅降级的组件
function ImageWithFallback({ src, alt, fallbackSrc, ...props }) {
  const [hasError, setHasError] = useState(false)
  const [isLoading, setIsLoading] = useState(true)

  const handleError = () => {
    setHasError(true)
    setIsLoading(false)
  }

  const handleLoad = () => {
    setIsLoading(false)
  }

  if (hasError) {
    return (
      <div className="image-fallback" {...props}>
        <img src={fallbackSrc} alt={alt} />
      </div>
    )
  }

  return (
    <div className="image-container" {...props}>
      {isLoading && <div className="image-loading">加载中...</div>}
      <img
        src={src}
        alt={alt}
        onError={handleError}
        onLoad={handleLoad}
        style={{ display: isLoading ? 'none' : 'block' }}
      />
    </div>
  )
}

6. 类型安全

TypeScript 支持

// 完整的 TypeScript 类型定义
interface ButtonProps {
  children: React.ReactNode
  onClick: () => void
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'small' | 'medium' | 'large'
  disabled?: boolean
  loading?: boolean
  className?: string
  style?: React.CSSProperties
  'data-testid'?: string
  'aria-label'?: string
  'aria-describedby'?: string
}

// 泛型组件
interface ListProps<T> {
  items: T[]
  renderItem: (item: T, index: number) => React.ReactNode
  keyExtractor: (item: T) => string | number
  emptyMessage?: string
  loading?: boolean
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyMessage = '暂无数据',
  loading = false
}: ListProps<T>) {
  if (loading) {
    return <div>加载中...</div>
  }

  if (items.length === 0) {
    return <div>{emptyMessage}</div>
  }

  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>
          {renderItem(item, index)}
        </li>
      ))}
    </ul>
  )
}

// 使用示例
const users = [
  { id: 1, name: 'John', email: 'john@example.com' },
  { id: 2, name: 'Jane', email: 'jane@example.com' }
]

<List
  items={users}
  renderItem={(user) => (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  )}
  keyExtractor={(user) => user.id}
/>

7. 文档和示例

组件文档

// 使用 JSDoc 注释
/**
 * 按钮组件
 *
 * @param {Object} props - 组件属性
 * @param {React.ReactNode} props.children - 按钮内容
 * @param {Function} props.onClick - 点击事件处理函数
 * @param {'primary'|'secondary'|'danger'} [props.variant='primary'] - 按钮样式变体
 * @param {'small'|'medium'|'large'} [props.size='medium'] - 按钮尺寸
 * @param {boolean} [props.disabled=false] - 是否禁用
 * @param {boolean} [props.loading=false] - 是否显示加载状态
 * @param {string} [props.className] - 自定义 CSS 类名
 * @param {Object} [props.style] - 自定义样式
 * @param {string} [props['data-testid']] - 测试 ID
 *
 * @example
 * // 基础用法
 * <Button onClick={handleClick}>点击我</Button>
 *
 * @example
 * // 不同样式和尺寸
 * <Button variant="danger" size="large" onClick={handleDelete}>
 *   删除
 * </Button>
 *
 * @example
 * // 加载状态
 * <Button loading={isLoading} onClick={handleSubmit}>
 *   提交
 * </Button>
 */
function Button({
  children,
  onClick,
  variant = 'primary',
  size = 'medium',
  disabled = false,
  loading = false,
  className,
  style,
  'data-testid': testId,
  ...props
}: ButtonProps) {
  // 组件实现
}

8. 实际应用示例

完整的组件设计示例

// 完整的组件设计示例
interface SearchInputProps {
  value: string
  onChange: (value: string) => void
  placeholder?: string
  disabled?: boolean
  loading?: boolean
  suggestions?: string[]
  onSuggestionSelect?: (suggestion: string) => void
  className?: string
  'data-testid'?: string
}

function SearchInput({
  value,
  onChange,
  placeholder = '搜索...',
  disabled = false,
  loading = false,
  suggestions = [],
  onSuggestionSelect,
  className,
  'data-testid': testId,
  ...props
}: SearchInputProps) {
  const [isFocused, setIsFocused] = useState(false)
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
  const inputRef = useRef<HTMLInputElement>(null)
  const listRef = useRef<HTMLUListElement>(null)

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (!isFocused || suggestions.length === 0) return

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setHighlightedIndex((prev) =>
          prev < suggestions.length - 1 ? prev + 1 : 0
        )
        break
      case 'ArrowUp':
        e.preventDefault()
        setHighlightedIndex((prev) =>
          prev > 0 ? prev - 1 : suggestions.length - 1
        )
        break
      case 'Enter':
        e.preventDefault()
        if (highlightedIndex >= 0) {
          onSuggestionSelect?.(suggestions[highlightedIndex])
        }
        break
      case 'Escape':
        setIsFocused(false)
        inputRef.current?.blur()
        break
    }
  }

  const handleSuggestionClick = (suggestion: string) => {
    onSuggestionSelect?.(suggestion)
    setIsFocused(false)
  }

  return (
    <div className={`search-input ${className || ''}`} {...props}>
      <div className="search-input-wrapper">
        <input
          ref={inputRef}
          type="text"
          value={value}
          onChange={(e) => onChange(e.target.value)}
          onFocus={() => setIsFocused(true)}
          onBlur={() => setTimeout(() => setIsFocused(false), 200)}
          onKeyDown={handleKeyDown}
          placeholder={placeholder}
          disabled={disabled}
          aria-autocomplete="list"
          aria-expanded={isFocused && suggestions.length > 0}
          aria-activedescendant={
            highlightedIndex >= 0 ? `suggestion-${highlightedIndex}` : undefined
          }
          data-testid={testId}
        />

        {loading && (
          <div className="search-input-loading" aria-label="搜索中">
            <Spinner size="small" />
          </div>
        )}
      </div>

      {isFocused && suggestions.length > 0 && (
        <ul
          ref={listRef}
          className="search-input-suggestions"
          role="listbox"
          aria-label="搜索建议"
        >
          {suggestions.map((suggestion, index) => (
            <li
              key={suggestion}
              id={`suggestion-${index}`}
              role="option"
              aria-selected={index === highlightedIndex}
              className={index === highlightedIndex ? 'highlighted' : ''}
              onClick={() => handleSuggestionClick(suggestion)}
            >
              {suggestion}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

export default SearchInput

总结

设计一个优秀的 React 组件需要考虑:

  1. API 设计:简洁明了、易于使用、扩展性好
  2. 可访问性:语义化 HTML、键盘导航、屏幕阅读器支持
  3. 可测试性:易于测试、提供测试所需的属性和方法
  4. 性能优化:避免不必要的重新渲染、懒加载、虚拟滚动
  5. 错误处理:错误边界、优雅降级、用户友好的错误信息
  6. 类型安全:完整的 TypeScript 支持
  7. 文档和示例:清晰的文档和使用示例
  8. 一致性:遵循设计系统、保持 API 一致性

一个好的组件应该让使用者感到简单易用,让维护者感到清晰明了,让测试者感到易于验证。