你如何设计一个组件?要考虑哪些方面?(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 组件需要考虑:
- API 设计:简洁明了、易于使用、扩展性好
- 可访问性:语义化 HTML、键盘导航、屏幕阅读器支持
- 可测试性:易于测试、提供测试所需的属性和方法
- 性能优化:避免不必要的重新渲染、懒加载、虚拟滚动
- 错误处理:错误边界、优雅降级、用户友好的错误信息
- 类型安全:完整的 TypeScript 支持
- 文档和示例:清晰的文档和使用示例
- 一致性:遵循设计系统、保持 API 一致性
一个好的组件应该让使用者感到简单易用,让维护者感到清晰明了,让测试者感到易于验证。