优秀的组件并非“把逻辑塞进一个文件”那么简单,而是围绕清晰的职责、稳定的 API、良好的可组合性与可访问性构建出来的可复用最小单元。本文给出一套从设计目标到工程实践的准则,并附上可直接复用的示例。

一、设计目标与基本原则

  • 单一职责(Single Responsibility):一个组件只解决一类问题;超出边界的能力通过组合扩展。
  • 显式 API:输入输出清晰,避免“魔法”与隐式副作用;缺省值合理且可覆盖。
  • 可组合性优先:通过组合(children、slots、render props)扩展能力,而非内置过多开关。
  • 受控/非受控双模:同时支持受控(value/onChange)与非受控(defaultValue)用法。
  • 可访问性(A11y)内建:ARIA 语义、键盘可达、焦点管理默认可用。
  • 样式可定制:不强绑技术栈,支持 className/style/自定义变量/主题化;避免样式泄漏。
  • 稳定性与可测试:API 语义稳定,具备单测/快照/可视回归基础。

二、API 设计:Props、事件与透传

  • 命名规范
    • 值/回调:value + onChangeopen + onOpenChange
    • 状态布尔:使用肯定式 disabledloading
  • 必填与默认值:最小必填集;其他提供合理 defaultXxx
  • 透传策略:使用 ...rest 透传到根元素;className/style/id/data-* 允许覆盖。
  • 可扩展位点:children、renderXxxslots(如 headerfooter)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 受控/非受控 + 透传的文本框示例(React/TS)
import React, { useState, forwardRef, useImperativeHandle, useRef } from 'react';

type TextInputProps = {
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange'>;

export type TextInputRef = { focus: () => void };

export const TextInput = forwardRef<TextInputRef, TextInputProps>(function TextInput(
{ value, defaultValue = '', onChange, className, style, ...rest },
ref
) {
const [inner, setInner] = useState(defaultValue);
const inputRef = useRef<HTMLInputElement>(null);
const isControlled = value !== undefined;
const displayed = isControlled ? value! : inner;

useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
}));

return (
<input
ref={inputRef}
value={displayed}
onChange={(e) => {
if (!isControlled) setInner(e.target.value);
onChange?.(e.target.value);
}}
className={className}
style={style}
{...rest}
/>
);
});

三、可组合性:children / slots / render props

  • children:最通用的组合方式,适合简单布局插槽。
  • slots:命名插槽,适合多插入点结构化布局(如 headerfooter)。
  • render props:向下提供状态与动作,子节点以函数接收,适合“状态容器 + 视图解耦”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Modal 具名插槽风格(React)
type ModalProps = {
open: boolean;
onOpenChange: (next: boolean) => void;
title?: React.ReactNode;
footer?: React.ReactNode;
children?: React.ReactNode;
};

export function Modal({ open, onOpenChange, title, footer, children }: ModalProps) {
if (!open) return null;
return (
<div role="dialog" aria-modal="true" className="modal">
<div className="modal-header">
<div className="modal-title">{title}</div>
<button aria-label="Close" onClick={() => onOpenChange(false)}>×</button>
</div>
<div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
);
}
1
2
3
4
5
6
7
8
<!-- Vue 命名插槽示例 -->
<template>
<div class="card">
<header><slot name="header" /></header>
<section><slot /></section>
<footer><slot name="footer" /></footer>
</div>
</template>

四、可访问性(A11y):语义与键盘优先

  • 语义与角色:使用符合语义的元素或补充 role
  • ARIA 状态aria-expandedaria-selectedaria-disabled 等与真实状态同步。
  • 键盘可达Tab 巡航、Enter/Space 触发、方向键导航;焦点可见(focus ring)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Switch(开关)示例:语义 + 键盘控制
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onChange(!checked);
}
}}
className={`switch ${checked ? 'on' : 'off'}`}
>
{checked ? 'On' : 'Off'}
</button>
);
}

五、样式策略:隔离、主题与约束

  • 隔离:优先模块化(CSS Modules)、BEM 或 CSS-in-JS,避免全局污染。
  • 主题化:用 CSS 变量承载设计 Token(颜色、间距、圆角、阴影)。
  • 可覆盖:对外暴露 className/自定义变量,支持按需覆盖。
1
2
3
4
5
6
7
8
9
10
:root {
--btn-bg: #1f6feb;
--btn-fg: #ffffff;
--btn-radius: 8px;
}
.btn {
background: var(--btn-bg);
color: var(--btn-fg);
border-radius: var(--btn-radius);
}
1
2
3
4
<!-- 主题切换(示例) -->
<div style="--btn-bg:#0ea5e9; --btn-radius:6px">
<button class="btn">Action</button>
</div>

六、状态与数据流:受控优先,副作用外置

  • 受控/非受控:对外暴露受控优先;内部提供非受控兜底,保持一致事件语义。
  • 副作用外置:网络/缓存/路由放到容器层;纯视图组件保持函数式。
  • Ref 能力:通过 forwardRef + useImperativeHandle 暴露必要命令(如 focus())。

七、性能优化:更少渲染、更小工作量

  • 避免不必要的重渲染React.memouseMemo/useCallback;列表项带稳定 key
  • 延迟加载import() 懒加载;条件挂载隐藏内容;虚拟列表处理长清单。
  • 长任务切片:Web Worker/requestIdleCallback 处理重计算。
1
2
3
4
5
6
7
8
9
10
11
12
import React, { memo } from 'react';

type Item = { id: string; title: string };
export const ItemList = memo(function ItemList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((it) => (
<li key={it.id}>{it.title}</li>
))}
</ul>
);
});

八、测试与文档:可验证、可发现、可演示

  • 单元测试:覆盖受控/非受控、边界值、键盘交互。
  • 可视/快照:关键结构与状态快照;视觉回归用于主题/布局。
  • 文档与用例:Storybook/Playground,展示 API、交互与可定制点。
1
2
3
4
5
6
7
8
9
10
// React Testing Library 片段
import { render, screen, fireEvent } from '@testing-library/react';
test('switch toggles by keyboard', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} />);
const btn = screen.getByRole('switch');
btn.focus();
fireEvent.keyDown(btn, { key: ' ' });
expect(onChange).toHaveBeenCalledWith(true);
});

九、版本与兼容:语义化发布与变更控制

  • SemVer:破坏性变更走 major;新增功能 minor;修复 patch。
  • Deprecation 流程:先标记弃用 + 警告,再在下一个 major 移除。
  • 迁移指南:提供变更列表、替代方案、Codemod(可选)。

十、落地清单(Checklist)

  • API 完整且命名统一;受控/非受控一致;事件返回稳定。
  • 具名插槽/children/透传可用;样式可覆盖且不污染全局。
  • A11y 基本到位:语义、ARIA、键盘、焦点环;无阻断可达性问题。
  • 性能策略生效:最长渲染路径可控;大列表虚拟化;重计算移出主线程。
  • 测试覆盖关键路径并具备演示用例;版本发布遵循 SemVer。

结语

组件封装是一种长期主义:以最小的 API 面积覆盖最大的通用场景,把可扩展点留给组合,把复杂度留在内部。遵循上述准则,可以在不同技术栈与业务环境下产出更稳定、更易用、也更经得起演进的组件。

Happy Coding!