第3章 Button 组件夹具封装

3.1 组件原理

Material 3 Button 共分为四种经典样式:Filled、Filled Tonal、Outlined、Text。每种样式都有对应的 enabled、disabled、loading 三种状态,并且支持自定义主题色(primary、secondary、tertiary、error)。夹具封装的目标是提供一组类型安全的 Props,使消费者只需通过简洁的配置即可生成符合 Material 3 规范的按钮,同时内部自动处理点击防抖、禁用逻辑和加载动画。

样式MD3 前缀适用场景
Filledmd-sys-color-primary主要操作,高强调
Filled Tonalmd-sys-color-secondary-container次要操作,中强调
Outlinedmd-sys-color-outline边框按钮,中低强调
Textmd-sys-color-on-surface低强调链接式按钮

3.2 封装实现流程

  1. 定义 TS 类型:声明 ButtonVariant、ButtonSize、ButtonState 等联合类型与 ButtonProps 接口。
  2. 创建核心组件:接收 variant、disabled、loading、color 等 props,组装 classNames 和内联样式。
  3. 状态处理:disabled 和 loading 状态下禁用点击回调,并使用 aria-busy/aria-disabled 增强可访问性。
  4. 主题色注入:通过 CSS 变量覆盖 md-sys-color-* ,或直接内联 color/background-color。
  5. 加载动画:使用纯 CSS 旋转圆圈 Spinner 或 MD3 的 ProgressIndicator。
  6. 导出夹具:提供命名导出 M3Button 以及样式 Props 的 TS 类型,便于单元测试快照生成。

3.3 核心代码

以下为 TypeScript + React 实现的完整夹具组件(省略了动画 keyframes)。

// ===== types.ts =====
export type ButtonVariant = 'filled' | 'tonal' | 'outlined' | 'text';
export type ButtonColor = 'primary' | 'secondary' | 'tertiary' | 'error';
export type ButtonSize = 'sm' | 'md' | 'lg';

export interface ButtonProps {
  variant?: ButtonVariant;
  color?: ButtonColor;
  size?: ButtonSize;
  disabled?: boolean;
  loading?: boolean;
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
  children: React.ReactNode;
  className?: string;
  // 夹具专用:用于测试快照的 data-testid
  'data-testid'?: string;
}

// ===== constants.ts =====
export const COLOR_MAP: Record<ButtonColor, string> = {
  primary:   'var(--md-sys-color-primary)',
  secondary: 'var(--md-sys-color-secondary)',
  tertiary:  'var(--md-sys-color-tertiary)',
  error:     'var(--md-sys-color-error)',
};

// ===== M3Button.tsx =====
import React, { useCallback } from 'react';

export const M3Button: React.FC<ButtonProps> = ({
  variant = 'filled',
  color = 'primary',
  size = 'md',
  disabled = false,
  loading = false,
  onClick,
  children,
  className = '',
  ...rest
}) => {
  const isDisabled = disabled || loading;

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (isDisabled) return;
      onClick?.(e);
    },
    [isDisabled, onClick],
  );

  // 计算 css 类名
  const cls = [
    'm3-button',
    `m3-button--${variant}`,
    `m3-button--${size}`,
    isDisabled ? 'm3-button--disabled' : '',
    loading ? 'm3-button--loading' : '',
    className,
  ]
    .filter(Boolean)
    .join(' ');

  return (
    <button
      className={cls}
      disabled={isDisabled}
      aria-disabled={isDisabled}
      aria-busy={loading}
      onClick={handleClick}
      style={{
        '--m3-btn-text-color': COLOR_MAP[color],
      } as React.CSSProperties}
      {...rest}
    >
      {loading && <span className="m3-button__spinner" />}
      <span className="m3-button__label">{children}</span>
    </button>
  );
};

3.4 状态 & 主题色 CSS 变量

通过 CSS 变量实现主题色动态切换,无需覆盖整个主题系统。以下为 scoped 样式片段。

/* M3Button.css */
.m3-button {
  --m3-btn-bg: transparent;
  --m3-btn-border: transparent;
  --m3-btn-text: var(--m3-btn-text-color, var(--md-sys-color-primary));
  --m3-btn-radius: var(--md-sys-shape-corner-full);
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 0 24px;
  height: 40px;
  border: 1px solid var(--m3-btn-border);
  border-radius: var(--m3-btn-radius);
  background: var(--m3-btn-bg);
  color: var(--m3-btn-text);
  cursor: pointer;
  font-family: var(--md-sys-typescale-label-large-font);
  transition: box-shadow 0.2s, background 0.2s;
}

/* variant-specific tokens */
.m3-button--filled {
  --m3-btn-bg: var(--m3-btn-text-color);
  --m3-btn-text: var(--md-sys-color-on-primary);
}
.m3-button--tonal {
  --m3-btn-bg: var(--md-sys-color-secondary-container);
  --m3-btn-text: var(--md-sys-color-on-secondary-container);
}
.m3-button--outlined {
  --m3-btn-border: var(--md-sys-color-outline);
  --m3-btn-bg: transparent;
  --m3-btn-text: var(--m3-btn-text-color);
}
.m3-button--text {
  --m3-btn-bg: transparent;
  --m3-btn-text: var(--m3-btn-text-color);
}

/* disabled + loading */
.m3-button--disabled,
.m3-button--loading {
  opacity: 0.38;
  pointer-events: none;
}
.m3-button--loading .m3-button__spinner {
  width: 18px;
  height: 18px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: m3-spin 0.8s linear infinite;
}
@keyframes m3-spin {
  to { transform: rotate(360deg); }
}

3.5 调试与常见错误

现象原因解决方案
点击按钮不触发回调disabled 或 loading 为 true 时未阻止事件在 handleClick 开头检查 isDisabled;使用 aria-disabled 而非只改 CSS。
加载状态下依然可以点击只设置了 CSS opacity,未禁用 pointer-events同时设置 pointer-events: none 与 disabled 属性。
主题色不生效CSS 变量名拼写错误或未在组件上设置 style确认 style 中传入 --m3-btn-text-color,并在 CSS 中使用 var(--m3-btn-text-color)。
快照测试中样式丢失夹具导出时未包含 CSS 文件在夹具入口文件(如 index.ts)中 import './M3Button.css'。

3.6 性能优化

  • React.memo:对纯展示性的 Button 包裹 React.memo,避免不必要渲染。
  • useCallback:onClick 回调使用 useCallback(如上方代码所示),确保引用稳定。
  • 条件渲染 Spinner:仅在 loading===true 时渲染 spinner 元素,减少 DOM 节点数。
  • CSS contain:在按钮容器上添加 contain: layout style paint,限制重绘影响范围。
// 使用 memo 优化
export const M3Button = React.memo(M3ButtonRaw);

3.7 组件状态与样式关系图

Button 组件夹具状态机 Button 组件夹具状态与样式映射 Props 输入 variant / color / disabled / loading 状态计算 isDisabled = disabled || loading aria-busy = loading aria-disabled = isDisabled click 回调拦截 主题色与样式计算 --m3-btn-text-color = COLOR_MAP[color] class = variant + state class 内联 style 注入 CSS 变量 最终渲染 <button aria-busy disabled> spinner (条件) + 文本 CSS 变量驱动主题色 测试夹具注意 export 类型 + 组件 快照测试时需 import 对应 CSS

3.8 部署与单元测试示例

将夹具组件与样式一同打包为 ES Module,在测试中使用 @testing-library/react 渲染并校验 DOM 属性。

// M3Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { M3Button } from './M3Button';

describe('M3Button 夹具', () => {
  it('渲染文本并响应点击', async () => {
    const handleClick = vi.fn();
    render(<M3Button onClick={handleClick}>单击</M3Button>);
    const btn = screen.getByRole('button', { name: /单击/i });
    await userEvent.click(btn);
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('禁用状态下不触发点击', async () => {
    const handleClick = vi.fn();
    render(<M3Button disabled onClick={handleClick}>禁用</M3Button>);
    const btn = screen.getByRole('button', { name: /禁用/i });
    await userEvent.click(btn);
    expect(handleClick).not.toHaveBeenCalled();
  });

  it('加载状态显示 spinner 并禁止点击', () => {
    const { container } = render(<M3Button loading>加载中</M3Button>);
    const btn = screen.getByRole('button');
    expect(btn).toHaveAttribute('aria-busy', 'true');
    expect(container.querySelector('.m3-button__spinner')).toBeInTheDocument();
  });
});
夹具封装关键约束:所有样式变量必须通过 --m3-* 自定义属性暴露,避免直接绑定 CSS Module 哈希,否则快照测试每一次构建都会失效。

3.9 稳定性优化

  • 点击防抖:如果在异步场景中需要防止重复提交,可在夹具内部用 useRef 锁或在业务侧使用 loading prop 处理,夹具不内置防抖以保持通用性。
  • 主题色回退:当 var(--md-sys-color-primary) 未定义时,提供 #6750A4 等硬编码 fallback,保证无主题环境仍可显示。
  • CSS 变量继承:使用 var(--m3-btn-text-color, var(--md-sys-color-primary)) 多层回退,适配不同主题深度。
/* 回退示例 */
--m3-btn-text: var(--m3-btn-text-color, var(--md-sys-color-primary, #6750A4));