第3章 Button 组件夹具封装
3.1 组件原理
Material 3 Button 共分为四种经典样式:Filled、Filled Tonal、Outlined、Text。每种样式都有对应的 enabled、disabled、loading 三种状态,并且支持自定义主题色(primary、secondary、tertiary、error)。夹具封装的目标是提供一组类型安全的 Props,使消费者只需通过简洁的配置即可生成符合 Material 3 规范的按钮,同时内部自动处理点击防抖、禁用逻辑和加载动画。
| 样式 | MD3 前缀 | 适用场景 |
|---|---|---|
| Filled | md-sys-color-primary | 主要操作,高强调 |
| Filled Tonal | md-sys-color-secondary-container | 次要操作,中强调 |
| Outlined | md-sys-color-outline | 边框按钮,中低强调 |
| Text | md-sys-color-on-surface | 低强调链接式按钮 |
3.2 封装实现流程
- 定义 TS 类型:声明 ButtonVariant、ButtonSize、ButtonState 等联合类型与 ButtonProps 接口。
- 创建核心组件:接收 variant、disabled、loading、color 等 props,组装 classNames 和内联样式。
- 状态处理:disabled 和 loading 状态下禁用点击回调,并使用 aria-busy/aria-disabled 增强可访问性。
- 主题色注入:通过 CSS 变量覆盖 md-sys-color-* ,或直接内联 color/background-color。
- 加载动画:使用纯 CSS 旋转圆圈 Spinner 或 MD3 的 ProgressIndicator。
- 导出夹具:提供命名导出 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 组件状态与样式关系图
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锁或在业务侧使用loadingprop 处理,夹具不内置防抖以保持通用性。 - 主题色回退:当
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));