前端 UI 工程
概览
构建既可访问、又高性能、而且视觉完成度高的生产级用户界面。目标是让 UI 看起来像出自一家优秀公司的设计感工程师之手,而不是像 AI 随手拼出来的东西。这意味着要真正遵守设计系统、认真处理可访问性、设计合理的交互模式,并彻底避免通用的 “AI 审美”。
何时使用
- 构建新的 UI 组件或页面
- 修改现有用户界面
- 实现响应式布局
- 添加交互或状态管理
- 修复视觉或 UX 问题
组件架构
文件结构
把与组件相关的内容就近放在一起:
src/components/
TaskList/
TaskList.tsx # Component implementation
TaskList.test.tsx # Tests
TaskList.stories.tsx # Storybook stories (if using)
use-task-list.ts # Custom hook (if complex state)
types.ts # Component-specific types (if needed)
组件模式
优先组合,而不是堆配置:
// Good: Composable
<Card>
<CardHeader>
<CardTitle>Tasks</CardTitle>
</CardHeader>
<CardBody>
<TaskList tasks={tasks} />
</CardBody>
</Card>
// Avoid: Over-configured
<Card
title="Tasks"
headerVariant="large"
bodyPadding="md"
content={<TaskList tasks={tasks} />}
/>
让组件保持聚焦:
// Good: Does one thing
export function TaskItem({ task, onToggle, onDelete }: TaskItemProps) {
return (
<li className="flex items-center gap-3 p-3">
<Checkbox checked={task.done} => onToggle(task.id)} />
<span className={task.done ? 'line-through text-muted' : ''}>{task.title}</span>
<Button variant="ghost" size="sm" => onDelete(task.id)}>
<TrashIcon />
</Button>
</li>
);
}
把数据获取和展示分开:
// Container: handles data
export function TaskListContainer() {
const { tasks, isLoading, error } = useTasks();
if (isLoading) return <TaskListSkeleton />;
if (error) return <ErrorState message="Failed to load tasks" retry={refetch} />;
if (tasks.length === 0) return <EmptyState message="No tasks yet" />;
return <TaskList tasks={tasks} />;
}
// Presentation: handles rendering
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<ul role="list" className="divide-y">
{tasks.map(task => <TaskItem key={task.id} task={task} />)}
</ul>
);
}
状态管理
选择刚好够用的最简单方案:
Local state (useState) → 组件内部 UI 状态
Lifted state → 2-3 个兄弟组件共享的状态
Context → 主题、认证、语言环境这类读多写少状态
URL state (searchParams) → 筛选、分页、可分享的 UI 状态
Server state (React Query, SWR) → 带缓存的远程数据
Global store (Zustand, Redux) → 跨应用共享的复杂客户端状态
不要让 prop drilling 深过 3 层。 如果 props 穿过很多并不消费它们的组件,就该考虑 context 或重新调整组件树。
遵守设计系统
避免 AI 审美
AI 生成的 UI 往往有很明显的默认套路,要主动避开:
| AI 默认套路 | 为什么有问题 | 生产级替代 |
|---|---|---|
| 全是紫色 / 靛蓝色 | 模型偏爱“安全审美”,结果所有应用都长得一样 | 使用项目真实配色体系 |
| 滥用大面积渐变 | 渐变容易制造噪音,还经常和设计系统冲突 | 使用设计系统定义的纯色或轻微渐变 |
| 所有元素都超圆角 | rounded-2xl 到处用,会破坏真实设计里的层级感 |
用设计系统规定的统一圆角体系 |
| 通用 Hero 区块 | 模板化布局与真实内容和用户需求无关 | 以内容优先的布局 |
| 假文案占位 | 假文案会掩盖真实内容长度、换行和溢出问题 | 使用尽可能真实的占位内容 |
| 所有地方都超大留白 | 一样大的充裕 padding 会毁掉视觉层级 | 使用稳定的 spacing scale |
| 通用卡片网格 | 这是布局捷径,不考虑信息优先级和扫描路径 | 根据内容优先级设计布局 |
| 重阴影设计 | 复杂阴影会和内容抢注意力,还影响低端设备渲染 | 除非设计系统明确要求,否则少用或不用 |
间距与布局
使用统一的 spacing scale,不要发明新数值:
/* Use the scale: 0.25rem increments (or whatever the project uses) */
/* Good */ padding: 1rem; /* 16px */
/* Good */ gap: 0.75rem; /* 12px */
/* Bad */ padding: 13px; /* Not on any scale */
/* Bad */ margin-top: 2.3rem; /* Not on any scale */
排版
尊重文字层级:
h1 → 页面标题,每页一个
h2 → 区块标题
h3 → 子区块标题
body → 正文
small → 次级 / 辅助文本
不要跳 heading 层级,也不要把 heading 样式拿去装非标题内容。
颜色
- 使用语义化颜色 token,例如
text-primary、bg-surface、border-default,不要直接写 hex - 确保对比度足够,正文至少 4.5:1,大字号至少 3:1
- 不要只靠颜色表达信息,还要辅以图标、文本或图案
可访问性(WCAG 2.1 AA)
每个组件都必须满足这些要求:
键盘导航
// Every interactive element must be keyboard accessible
<button me</button> // ✓ Focusable by default
<div me</div> // ✗ Not focusable
<div role="button" tabIndex={0} // ✓ But prefer <button>
=> e.key === 'Enter' && handleClick()}>
Click me
</div>
ARIA 标签
// Label interactive elements that lack visible text
<button aria-label="Close dialog"><XIcon /></button>
// Label form inputs
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Or use aria-label when no visible label exists
<input aria-label="Search tasks" type="search" />
焦点管理
// Move focus when content changes
function Dialog({ isOpen, onClose }: DialogProps) {
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (isOpen) closeRef.current?.focus();
}, [isOpen]);
// Trap focus inside dialog when open
return (
<dialog open={isOpen}>
<button ref={closeRef}
{/* dialog content */}
</dialog>
);
}
有意义的空状态和错误状态
// Don't show blank screens
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">No tasks</h3>
<p className="mt-1 text-sm text-muted">Get started by creating a new task.</p>
<Button className="mt-4" Task</Button>
</div>
);
}
return <ul role="list">...</ul>;
}
响应式设计
先为移动端设计,再往上扩展:
// Tailwind: mobile-first responsive
<div className="
grid grid-cols-1 /* Mobile: single column */
sm:grid-cols-2 /* Small: 2 columns */
lg:grid-cols-3 /* Large: 3 columns */
gap-4
">
至少测试这些断点:320px、768px、1024px、1440px。
Loading 与过渡
// Skeleton loading (not spinners for content)
function TaskListSkeleton() {
return (
<div className="space-y-3" aria-busy="true" aria-label="Loading tasks">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
))}
</div>
);
}
// Optimistic updates for perceived speed
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);
},
});
}
常见自我安慰
| 自我安慰 | 现实 |
|---|---|
| “可访问性只是加分项” | 在很多地区它是法律要求,同时也是工程质量标准。 |
| “响应式以后再补” | 事后补响应式,难度通常是从一开始就做好它的 3 倍。 |
| “设计还没定,我先不管样式” | 先按设计系统默认值做。完全没样式的 UI 会让 reviewer 第一眼就觉得是坏的。 |
| “这只是原型” | 原型经常会直接流入生产。基础要从一开始就打对。 |
| “先用 AI 默认审美也没关系” | 它会直接传达出低质量信号。应该从一开始就使用项目真正的设计系统。 |
危险信号
- 单个组件超过 200 行,应拆分
- 出现大量内联样式或随意像素值
- 缺少 loading、error 或 empty 状态
- 从未做过键盘导航测试
- 只靠颜色表达状态,例如纯红绿没有图标或文字
- 充满通用 AI 风格,例如紫色渐变、超大卡片、库存式布局
验证
完成 UI 后,确认:
- 组件渲染时没有控制台错误
- 所有交互元素都可键盘访问,可以 Tab 一遍页面验证
- 屏幕阅读器可以传达页面内容和结构
- 在 320px、768px、1024px、1440px 下都正常工作
- Loading、error、empty 状态都已处理
- 遵守项目设计系统,包括间距、颜色和排版
- 在 dev tools 或 axe-core 中没有可访问性警告