前端 UI 工程
概述
构建可访问、高性能且视觉上经过打磨的生产级用户界面。目标是让 UI 看起来像是顶级公司的设计意识工程师构建的,而不是 AI 生成的。这意味着真正的设计系统一致性、适当的可访问性、深思熟虑的交互模式,并且没有通用的“AI 审美”。
何时使用
- 构建新的 UI 组件或页面
- 修改现有的面向用户界面
- 实现响应式布局
- 添加交互性或状态管理
- 修复视觉或 UX 问题
组件架构
文件结构
将与组件相关的所有内容放在一起:
src/components/
TaskList/
TaskList.tsx # 组件实现
TaskList.test.tsx # 测试
TaskList.stories.tsx # Storybook stories (如果使用)
use-task-list.ts # 自定义 Hook (如果状态复杂)
types.ts # 组件特定类型 (如果需要)
组件模式
组合优于配置:
// 好:可组合
<Card>
<CardHeader>
<CardTitle>任务</CardTitle>
</CardHeader>
<CardBody>
<TaskList tasks={tasks} />
</CardBody>
</Card>
// 避免:过度配置
<Card
title="任务"
headerVariant="large"
bodyPadding="md"
content={<TaskList tasks={tasks} />}
/>
保持组件专注:
// 好:只做一件事
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li className="flex items-center gap-3 p-3">
<Checkbox checked={task.done} onChange={() => onToggle(task.id)} />
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" onClick={() => onDelete(task.id)}>
<TrashIcon />
</Button>
</li>
);
}
将数据获取与展示分离:
// 容器:处理数据
export function TaskListContainer() {
const { tasks, isLoading, error } = useTasks();
if (isLoading) return <TaskListSkeleton />;
if (error) return <ErrorState message="加载任务失败" retry={refetch} />;
if (tasks.length === 0) return <EmptyState message="暂无任务" />;
return <TaskList tasks={tasks} />;
}
// 展示:处理渲染
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul role="list" className="divide-y">
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</ul>
);
}
状态管理
选择最简单有效的方法:
本地状态 (useState) → 组件特定的 UI 状态
提升状态 (Lifted state) → 在 2-3 个兄弟组件间共享
Context → 主题、认证、区域设置 (读多写少)
URL 状态 (searchParams) → 过滤、分页、可共享的 UI 状态
服务端状态 (React Query, SWR) → 带缓存的远程数据
全局存储 (Zustand, Redux) → 在整个应用中共享的复杂客户端状态
避免超过 3 层的属性透传。 如果你正在通过不直接使用这些属性的组件传递 props,请引入 context 或重构组件树。
设计系统一致性
参考驱动的 UI 质量
当产品需要独特的视觉方向时,在选择布局前收集证据:
- 搜索可信的参考目录或使用产品团队提供的参考资料。
- 研究两到三个相关屏幕。记录关于层次、密度、导航、控件、响应式行为和交互状态的决定。
- 在实现之前,将这些决定转化为简短的设计合同。命名屏幕的职责、主要操作、所需状态、响应式规则和要拒绝的模式。
- 使用产品自己的组件、令牌、内容和视觉语言重建有用的结构。切勿复制其他产品的品牌标识、专有文本、图片或精确布局。
将参考用作证据,而非模板。如果参考不可用,请记录假设,并对照产品现有的设计系统验证结果。
避免 AI 审美
AI 生成的 UI 具有可识别的模式。避免所有这些:
| AI 默认值 | 为什么是问题 | 生产质量 |
|---|---|---|
| 到处都是紫色/靛蓝色 | 模型默认选择视觉上“安全”的调色板,使每个应用看起来都一样 | 使用项目实际的调色板 |
| 过度的渐变 | 渐变增加了视觉噪音,并与大多数设计系统冲突 | 与设计系统匹配的平面或微妙渐变 |
| 到处都是圆角 (rounded-2xl) | 最大圆角表示“友好”,但忽略了真实设计中圆角半径的层次结构 | 来自设计系统一致的 border-radius |
| 通用的英雄区域 | 模板驱动的布局,与实际内容或用户需求无关 | 内容优先的布局 |
| Lorem ipsum 风格的文字 | 占位符文本隐藏了真实内容会暴露的布局问题(长度、换行、溢出) | 现实的占位符内容 |
| 到处都是过大的内边距 | 等量的慷慨内边距破坏了视觉层次并浪费了屏幕空间 | 一致的间距比例 |
| 通用的卡片网格 | 统一的网格是一种布局捷径,忽略了信息优先级和浏览模式 | 目的驱动的布局 |
| 阴影过重的设计 | 分层阴影增加了与内容竞争的深度,并在低端设备上减慢渲染速度 | 微妙的阴影或无阴影,除非设计系统指定 |
间距与布局
使用一致的间距比例。不要发明值:
/* 使用比例:0.25rem 增量 (或项目所用的任何比例) */
/* 好 */ padding: 1rem; /* 16px */
/* 好 */ gap: 0.75rem; /* 12px */
/* 坏 */ padding: 13px; /* 不在任何比例上 */
/* 坏 */ margin-top: 2.3rem; /* 不在任何比例上 */
排版
尊重标题层次:
h1 → 页面标题(每页一个)
h2 → 章节标题
h3 → 子章节标题
body → 默认文本
small → 次要/辅助文本
不要跳过标题级别。不要为非标题内容使用标题样式。
颜色
- 使用语义化颜色令牌:
text-primary、bg-surface、border-default— 而非原始 hex 值 - 确保足够对比度(正常文本 4.5:1,大文本 3:1)
- 不要仅依赖颜色传达信息(也使用图标、文本或图案)
可访问性 (WCAG 2.1 AA)
每个组件必须满足这些标准:
键盘导航
// 每个交互元素必须可通过键盘访问
<button onClick={handleClick}>点击我</button> // ✓ 默认可聚焦
<div onClick={handleClick}>点击我</div> // ✗ 不可聚焦
<div role="button" tabIndex={0} onClick={handleClick} // ✓ 但更喜欢 <button>
onKeyDown={e => {
if (e.key === 'Enter') handleClick();
if (e.key === ' ') e.preventDefault();
}}
onKeyUp={e => {
if (e.key === ' ') handleClick();
}}>
点击我
</div>
ARIA 标签
// 为缺少可见文本的交互元素添加标签
<button aria-label="关闭对话框"><XIcon /></button>
// 为表单输入添加标签
<label htmlFor="email">电子邮件</label>
<input id="email" type="email" />
// 或者当没有可见标签时使用 aria-label
<input aria-label="搜索任务" type="search" />
焦点管理
// 内容更改时移动焦点
function Dialog({ isOpen, onClose }: DialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
// 打开时将焦点限制在对话框内
return (
<dialog open={isOpen}>
<button ref={closeRef} onClick={onClose}>关闭</button>
{/* 对话框内容 */}
</dialog>
);
}
有意义的空状态和错误状态
// 不要显示空白屏幕
function TaskList({ tasks }: { tasks: Task[] }) {
if (tasks.length === 0) {
return (
<div role="status" className="text-center py-12">
<TasksEmptyIcon className="mx-auto h-12 w-12 text-muted" />
<h3 className="mt-2 text-sm font-medium">暂无任务</h3>
<p className="mt-1 text-sm text-muted">创建一个新任务来开始吧。</p>
<Button className="mt-4" onClick={onCreateTask}>创建任务</Button>
</div>
);
}
return <ul role="list">...</ul>;
}
响应式设计
移动优先设计,然后扩展:
// Tailwind:移动优先的响应式
<div className="
grid grid-cols-1 /* 移动端:单列 */
sm:grid-cols-2 /* 小屏:2列 */
lg:grid-cols-3 /* 大屏:3列 */
gap-4
">
在这些断点测试:320px、768px、1024px、1440px。
加载与过渡
// 骨架加载(内容区域不要使用加载旋转器)
function TaskListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="正在加载任务">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
);
}
// 乐观更新以提升感知速度
function useToggleTask() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleTask,
onMutate: async (taskId) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (old: Task[]) =>
old.map(t => t.id === taskId ? { ...t, done: !t.done } : t)
);
return { previous };
},
onError: (_err, _taskId, context) => {
queryClient.setQueryData(['tasks'], context?.previous);
},
});
}
参见
有关详细的可访问性要求和测试工具,请参阅 ../../references/accessibility-checklist.md。
常见辩解
| 辩解 | 现实 |
|---|---|
| "可访问性是个可有可无的东西" | 在许多司法管辖区这是一个法律要求,也是工程质量标准。 |
| "我们以后再让它响应式" | 事后改造响应式设计比从头构建要困难 3 倍。 |
| "设计尚未最终确定,所以我跳过样式" | 使用设计系统默认值。未设置样式的 UI 会给审阅者留下糟糕的第一印象。 |
| "这只是一个原型" | 原型会变成生产代码。从一开始就打好基础。 |
| "AI 审美暂时没问题" | 它暗示低质量。从一开始就使用项目实际的设计系统。 |
红旗警告
- 组件超过 200 行(拆分它们)
- 内联样式或任意像素值
- 缺少错误状态、加载状态或空状态
- 未测试键盘导航
- 颜色作为状态的唯一指示符(红色/绿色没有文字或图标)
- 通用的“AI 外观”(紫色渐变、超大卡片、通用布局)
验证
构建 UI 后:
- 组件渲染无控制台错误
- 所有交互元素可通过键盘访问(用 Tab 键浏览页面)
- 屏幕阅读器可以传达页面的内容和结构
- 响应式:在 320px、768px、1024px、1440px 正常工作
- 酌情处理加载、空、错误、成功和权限状态
- 遵循项目的设计系统(间距、颜色、排版)
- 渲染结果通过最终的 UI 专属完成审查
- 开发者工具或 axe-core 中没有无障碍警告