前端组件设计
让 Agent 在新增或拆分组件时对齐项目的框架习惯、命名空间与 Storybook/文档模式,并兼顾可测试与 a11y。
SKILL 中应写明 Props/事件/插槽(或 children 组合)的边界:哪些是底层原语、哪些是业务封装,以及是否使用 compound components 模式。
要求列出键盘操作、焦点管理、aria-* 与角色,对表单类组件补充受控/非受控约定与错误态展示,避免仅实现视觉稿。
与设计系统技能配合:token、间距、断点引用方式一致;若项目有 eslint-plugin-jsx-a11y 或自定义规则,在步骤里点名需通过的检查项。
组件 API 设计原则与代码示例
// 组件 API 设计原则:类型/默认值/callback 命名约定
import React from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps {
// 语义化 variant,不用 color 或 type 表达视觉意图
variant?: ButtonVariant; // 默认 'primary'
size?: ButtonSize; // 默认 'md'
disabled?: boolean; // 标准 HTML 语义
loading?: boolean; // 加载态:显示 spinner,同时设 disabled
// callback 统一用 on 前缀,类型明确
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
// 内容:children 优于 label prop
children: React.ReactNode;
// 测试友好:data-testid 作为顶层 prop
'data-testid'?: string;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', disabled, loading, onClick, children, ...rest }, ref) => (
<button
ref={ref}
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || loading}
aria-busy={loading}
onClick={onClick}
{...rest}
>
{loading && <span aria-hidden="true">⟳</span>}
{children}
</button>
)
);
Button.displayName = 'Button';
Compound Component 模式——Form + Form.Item 组合:
// Compound Component:Form + Form.Item 通过 Context 共享隐式状态
const FormContext = React.createContext<FormContextValue | null>(null);
function Form({ onSubmit, children }: FormProps) {
const [errors, setErrors] = React.useState<Record<string, string>>({});
return (
<FormContext.Provider value={{ errors, setErrors }}>
<form onSubmit={onSubmit}>{children}</form>
</FormContext.Provider>
);
}
// Form.Item:自动从 Context 读取错误状态,关联 label 与 input
Form.Item = function FormItem({ name, label, children }: FormItemProps) {
const { errors } = React.useContext(FormContext)!;
const inputId = `form-${name}`;
const errorId = `${inputId}-error`;
return (
<div className="form-item">
<label htmlFor={inputId}>{label}</label>
{React.cloneElement(children as React.ReactElement, {
id: inputId,
'aria-describedby': errors[name] ? errorId : undefined,
'aria-invalid': !!errors[name],
})}
{errors[name] && <span id={errorId} role="alert">{errors[name]}</span>}
</div>
);
};
// 使用:对外 API 清晰,不暴露内部 Context
<Form onSubmit={handleSubmit}>
<Form.Item name="email" label="邮箱">
<input type="email" name="email" />
</Form.Item>
</Form>
- 导出与文件结构:
index出口、共置测试与 stories 的路径约定。 - 破坏性变更:Props 重命名或默认值变更时要求迁移说明与 codemod 提示。
- 与父级契约:受控值、回调命名(
onXxx)与「禁止透传」的 props 列表。
无障碍与 Props 契约
在 SKILL 中把「可见文案」与「仅读屏可见」分开:优先用真实文本节点;必要时为图标-only 控件暴露 aria-label 或与 aria-labelledby 指向的稳定 id。
常见对外 Props / 属性(按项目框架映射为 props 或顶层属性)建议显式列出,避免散落在实现里:
- 命名与描述:
aria-label、aria-labelledby、aria-describedby(错误提示、帮助文案)。 - 状态:
disabled、readOnly、aria-invalid、aria-required、aria-busy、aria-expanded、aria-selected。 - 角色与模式:必要时
role(仅当原生语义不足)、复合部件时的aria-controls、aria-activedescendant。 - 焦点:是否需
autoFocus、打开层时焦点陷阱、关闭时焦点归还(在步骤中写清,而不是隐含在代码里)。
给 Agent:表单关联优先用
htmlFor / for 绑定真实 <label>;不要用占位符代替 label。自定义控件时补充键盘表(Tab、Arrow、Enter、Escape)。
从原语到封装的流程
[ 设计稿 / 用例 ]
│
▼
┌─────────────┐ 单一职责:一个组件一种「用户任务」
│ 职责切分 │──── 原语(Button) vs 复合(DateRangePicker)
└─────────────┘
│
▼
┌─────────────┐ Props / 事件 / 插槽边界写入 SKILL
│ 对外 API │──── 受控与非受控、默认值、forwardRef(若适用)
└─────────────┘
│
▼
┌─────────────┐ 角色、标签、键盘、焦点、错误态文案
│ a11y 契约 │──── 对齐 eslint-plugin-jsx-a11y / 团队规则
└─────────────┘
│
▼
┌─────────────┐ Story / 单测 / 视觉回归(按项目)
│ 文档与测试 │
└─────────────┘
Headless vs Styled、可组合性与测试友好
// Headless 组件:只提供逻辑,样式由消费者控制(适合设计系统底层)
function useToggle(initialOpen = false) {
const [open, setOpen] = React.useState(initialOpen);
return {
open,
toggle: () => setOpen(o => !o),
// getToggleProps:消费者传入后可扩展,而非内置样式
getToggleProps: () => ({ onClick: () => setOpen(o => !o), 'aria-expanded': open }),
getContentProps: () => ({ hidden: !open }),
};
}
// 消费者自由控制样式:Headless vs Styled 的适用场景
function Accordion({ title, children }) {
const { open, getToggleProps, getContentProps } = useToggle();
return (
<div>
<button {...getToggleProps()} className="accordion-trigger">{title}</button>
<div {...getContentProps()} className="accordion-content">{children}</div>
</div>
);
}
// 测试友好:data-testid 规范 + RTL 最佳实践
function UserCard({ user }: { user: User }) {
return (
<article data-testid="user-card" aria-label={`用户 ${user.name}`}>
<h2 data-testid="user-name">{user.name}</h2>
<button data-testid="edit-user-btn" onClick={() => onEdit(user.id)}>编辑</button>
</article>
);
}
// 对应测试:优先用 role/label 查询,data-testid 作为最后手段
// test('shows user name', () => {
// render(<UserCard user={{ id: '1', name: '张三' }} />);
// expect(screen.getByRole('article', { name: '用户 张三' })).toBeInTheDocument();
// expect(screen.getByTestId('user-name')).toHaveTextContent('张三');
// });
- 设计系统:token、间距、断点与变体(size / tone)的引用方式与禁止硬编码色值。
- 测试:交互与 a11y 断言(如角色、名称):与 Vue Test Utils / RTL 等项目工具对齐。
- 性能:大列表虚拟化、memo / useCallback 的启用准则,避免无差别优化。
页内工具:a11y / Props 草稿
以下脚本仅在本页运行:按组件名与交互模式生成一段可粘贴进 SKILL 或 PR 描述的 Props / a11y 要点清单。
生成结果需按项目框架(Vue/React/Svelte 等)改写为真实类型与文档;键盘表请补全具体键位与焦点顺序。
SKILL 大纲
---
name: frontend-component
description: 按项目约定设计可访问、可测试、可组合的 UI 组件
---
# API 设计
1. Props 类型完整定义(TypeScript interface),可选项提供默认值
2. 回调命名 on 前缀:onClick/onChange/onSubmit,类型标注参数与返回值
3. forwardRef 用于需要 ref 的底层组件(input/button/等)
4. children 优于 label prop,slots/render props 优于长条件渲染
# 组合模式选择
5. Compound Component:父子通过 Context 共享状态,对外 API 稳定(Form.Item)
6. Headless Hook:只暴露状态与 getXxxProps,样式由消费者控制
7. Render Props:「将同一逻辑用于不同 UI」时,类型标注参数避免 any
8. 扩展点通过组合暴露,而非在父组件内用 variant 字符串条件渲染子类型
# 无障碍(a11y)
9. 表单控件:label/htmlFor 关联;aria-describedby 指向错误与帮助文案
10. 浮层:打开时焦点移入,关闭时焦点归还触发器;Escape 关闭
11. 列表/菜单:方向键导航,aria-activedescendant 指向当前项
12. 图标按钮:aria-label 或 aria-labelledby 指向可见文本
# 测试友好
13. data-testid 命名规范:组件名-语义(user-card, edit-btn)
14. 优先用 role/name 查询(getByRole),data-testid 作为最后手段
15. 每个 Story 覆盖:默认态、disabled、loading、error 各一个 variant