Button
在真实项目里,Button 是整个设计系统被点击次数最多的组件之一。一个“好按钮”要解决四件事:语义正确、样式体系清晰、状态切换不意外、可测试可维护。下面这份复习文档,从目标到代码结构再到测试与可扩展点,帮助你快速回忆这颗 Button 的设计与实现方式。
1. 背景与目的
- 语义:默认渲染为
<button>,在表单与可访问设备中行为正确(键盘激活、禁用态、焦点环)。 - 外观:外观分为变体与尺寸两条轴线,组合出常用的视觉风格。
- 状态:加载态、禁用态、仅图标场景要有明确且一致的交互表现。
- 体验:传递
leftIcon、rightIcon、fullWidth等常用场景开关,降低使用成本。 - 可测试:类名与
data-标识、data-testid与 ARIA 属性都为测试与诊断预留了锚点。
2. 代码详解
2.1 属性与类型
ts
export type ButtonVariant =
| 'default' | 'secondary' | 'destructive'
| 'outline' | 'ghost' | 'link'
export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'
export interface ButtonProps extends
React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant // 默认 'default'
size?: ButtonSize // 默认 'lg'
loading?: boolean // 默认 false
loadingText?: string // 加载态替换 children 的文案
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
fullWidth?: boolean // 默认 false,占满父容器宽度
}这一组类型覆盖了“外观 + 状态 + 语义”三类关切,并且保留了原生 button 的全部属性。
2.2 样式映射与结构
ts
const base = `
inline-flex items-center justify-center
rounded-md font-medium transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2
disabled:pointer-events-none disabled:opacity-50
`
const variantClasses = {
default: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600',
secondary: 'bg-slate-100 text-slate-900 hover:bg-slate-200',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-600',
outline: 'border border-slate-300 hover:bg-slate-50',
ghost: 'hover:bg-slate-100',
link: 'text-blue-600 underline-offset-4 hover:underline',
} as const
const sizeClasses = {
default: 'h-10 px-4 text-sm',
sm: 'h-8 px-3 text-sm',
lg: 'h-11 px-6 text-base',
icon: 'h-10 w-10 p-0',
} as const- 基础类
base:描述所有按钮共有的“按钮感”(布局、焦点、禁用)。 - 变体映射
variantClasses:把主题色、边框、悬浮态按枚举集中维护。 - 尺寸映射
sizeClasses:统一高度与内边距,icon尺寸为等宽等高。
2.3 组件主体
tsx
function Button({
variant = 'default',
size = 'lg',
type = 'button', // 不侵入表单提交
loading = false,
loadingText,
leftIcon,
rightIcon,
fullWidth = false,
className,
children,
disabled,
...rest
}: ButtonProps) {
const content = loading && loadingText ? loadingText : children
const isDisabled = disabled || loading
return (
<button
type={type}
className={[
base,
variantClasses[variant],
sizeClasses[size],
fullWidth && 'w-full',
variant, // 标记类,便于测试或覆盖
size,
className,
].filter(Boolean).join(' ')}
aria-busy={loading ? 'true' : undefined}
disabled={isDisabled}
data-variant={variant}
data-size={size}
data-loading={loading ? 'true' : undefined}
data-full-width={fullWidth ? 'true' : undefined}
{...rest}
>
{leftIcon && !loading && (
<span className="mr-2 inline-flex items-center" data-testid="left">
{leftIcon}
</span>
)}
<span>{content}</span>
{!loading && rightIcon && (
<span className="ml-2 inline-flex items-center" data-testid="right">
{rightIcon}
</span>
)}
</button>
)
}
export default Button实现要点:
- 默认
type="button",避免表单环境误触提交。 - 加载态即禁用:
disabled与aria-busy同步,右侧图标让位于进度反馈;可选用leftIcon放旋转图标。 - 语义标识:
data-variant、data-size、data-loading、data-full-width作为样式与测试锚点。 - 类名可叠加:保留
className入口,外部可以按需覆盖边角样式。
3. 使用方式
3.1 基础与外观
tsx
<Button>提交</Button>
<Button variant="outline">更多</Button>
<Button variant="destructive">删除</Button>
<Button variant="link">查看文档</Button>3.2 尺寸与图标
tsx
<Button size="sm">小号</Button>
<Button size="lg">大号</Button>
<Button size="icon" aria-label="下一步">
<ChevronRightIcon />
</Button>IMPORTANT
仅图标按钮必须提供 aria-label,否则屏幕阅读器无法得知语义。
3.3 加载与全宽
tsx
<Button loading leftIcon={<Loader className="animate-spin" />}>
请稍候
</Button>
<Button
loading
loadingText="处理中"
leftIcon={<Loader className="animate-spin" />}
/>
<Button fullWidth>占满一行</Button>3.4 原生属性与事件
tsx
<Button type="submit">提交表单</Button>
<Button onClick={() => console.log('clicked')}>
点击事件
</Button>
<Button disabled onClick={() => console.log('不会触发')}>
禁用态
</Button>4. 可访问性与交互细节
- 键盘与读屏:根节点使用
<button>,焦点用focus-visible样式显示;aria-busy在加载中提示“忙碌”状态。 - 禁用语义:
loading时同步设置disabled,保证无法触发点击。 - 仅图标:
size="icon"时强烈建议配合aria-label;测试用getByLabelText验证。
5. 测试要点
- 内容:
children正常渲染;loadingText覆盖原文案。 - 原生:
onClick可触发;在disabled或loading时不触发;type属性尊重传入值。 - 样式:根据
variant、size切换类名;fullWidth有w-full。 - 图标:在加载时隐藏右图标;
data-testid="left"与"right"能被准确获取。 - 可访问性:
aria-busy为true;仅图标按钮可通过getByLabelText获取。
6. 常见问题与排错
- 表单误提交:忘记
type="button",默认会是submit。本实现已默认button,如需表单提交显式设置。 - 仅图标无语义:缺少
aria-label;加上与图标语义一致的描述。 - 加载态还能点击:未同步
disabled;这里已在loading时强制禁用。 - 样式不生效:
className覆盖顺序靠后;确保外部类名追加在最后或使用更高优先级的选择器。 - 测试难以选择节点:优先使用
role、name、aria-*;其次使用data-testid与data-*标识。
7. 可拓展建议
- 转发引用:用
React.forwardRef暴露内部<button>,便于聚焦与表单集成。 - 多态渲染:增加
asChild或as支持,将按钮语义“借给”<a>或路由<Link>。 - 主题注入:把
variantClasses与sizeClasses暴露为可覆盖的主题配置,或接入 CSS 变量。 - 读屏提示:加载态配合
aria-live="polite"与图标动画,提升辅助技术体验。
8. 心智模型与复盘
一个按钮 = 语义 + 外观 + 状态 + 体验 语义用 <button> 一步到位;外观用“变体 × 尺寸”的映射保证一致性;状态以“加载即禁用”为原则,读屏靠 aria-busy 与 aria-label 补齐;体验层面留足扩展点与测试锚点。记住这四块,换一个项目也能在十分钟内把 Button 打磨到可上线的质量。