Skip to content

Button

在真实项目里,Button 是整个设计系统被点击次数最多的组件之一。一个“好按钮”要解决四件事:语义正确、样式体系清晰、状态切换不意外、可测试可维护。下面这份复习文档,从目标到代码结构再到测试与可扩展点,帮助你快速回忆这颗 Button 的设计与实现方式。

1. 背景与目的

  • 语义:默认渲染为 <button>,在表单与可访问设备中行为正确(键盘激活、禁用态、焦点环)。
  • 外观:外观分为变体与尺寸两条轴线,组合出常用的视觉风格。
  • 状态:加载态、禁用态、仅图标场景要有明确且一致的交互表现。
  • 体验:传递 leftIconrightIconfullWidth 等常用场景开关,降低使用成本。
  • 可测试:类名与 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",避免表单环境误触提交。
  • 加载态即禁用disabledaria-busy 同步,右侧图标让位于进度反馈;可选用 leftIcon 放旋转图标。
  • 语义标识data-variantdata-sizedata-loadingdata-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 可触发;在 disabledloading 时不触发;type 属性尊重传入值。
  • 样式:根据 variantsize 切换类名;fullWidthw-full
  • 图标:在加载时隐藏右图标;data-testid="left""right" 能被准确获取。
  • 可访问性aria-busytrue;仅图标按钮可通过 getByLabelText 获取。

6. 常见问题与排错

  • 表单误提交:忘记 type="button",默认会是 submit。本实现已默认 button,如需表单提交显式设置。
  • 仅图标无语义:缺少 aria-label;加上与图标语义一致的描述。
  • 加载态还能点击:未同步 disabled;这里已在 loading 时强制禁用。
  • 样式不生效className 覆盖顺序靠后;确保外部类名追加在最后或使用更高优先级的选择器。
  • 测试难以选择节点:优先使用 rolenamearia-*;其次使用 data-testiddata-* 标识。

7. 可拓展建议

  • 转发引用:用 React.forwardRef 暴露内部 <button>,便于聚焦与表单集成。
  • 多态渲染:增加 asChildas 支持,将按钮语义“借给” <a> 或路由 <Link>
  • 主题注入:把 variantClassessizeClasses 暴露为可覆盖的主题配置,或接入 CSS 变量。
  • 读屏提示:加载态配合 aria-live="polite" 与图标动画,提升辅助技术体验。

8. 心智模型与复盘

一个按钮 = 语义 + 外观 + 状态 + 体验 语义用 <button> 一步到位;外观用“变体 × 尺寸”的映射保证一致性;状态以“加载即禁用”为原则,读屏靠 aria-busyaria-label 补齐;体验层面留足扩展点与测试锚点。记住这四块,换一个项目也能在十分钟内把 Button 打磨到可上线的质量。

内容基于 MIT 许可 | 保持节奏 · 持续积累