第 11 章 设计系统与可访问性:Design Token / Storybook / a11y
一个人写页面时,"颜色写死 #2563EB"没什么问题;等到 3 个产品线、8 个前端同时开发,就会变成"同一个蓝色有 17 种写法、按钮有 5 套实现"。设计系统就是来解决这个的:把"视觉决策"和"可复用零件"沉淀成代码资产。而可访问性(a11y)是同一件事的另一面——让键盘用户、读屏软件用户也能用。后端同学可以这么类比:Design Token 像全局常量表,组件库像内部 SDK,Storybook 像组件的 Swagger 文档。
论设计系统的三层,缺一层都会塌
第一层:Design Token(设计变量)。颜色、字号、间距、圆角、阴影、动效时长——所有"魔法数字"统一命名、集中定义。改动一个 token,全站生效。
第二层:组件库。基于 token 实现的可复用零件(Button/Card/Modal/Table),每个组件有明确的 props 契约和不同状态(默认/hover/禁用/加载/出错)。
第三层:文档与流程。Storybook 展示用法和状态、设计稿与代码的一致性约定、谁来评审新增组件、语义化版本怎么发。没有第三层,前两层半年就会腐烂——因为没人知道"这个新场景该用哪个组件"。
顺序也很重要:token 先行。很多团队一上来就写 Button,结果颜色全是硬编码,等要换主题时才发现要改 400 个文件。
Design Token:把"魔法数字"变成有名字的变量
Token 业界习惯分三层,从下往上越来越"业务化",这样换主题时只需要重定向上层,不用动原始色值。
| 层级 | 命名示例 | 规则 |
|---|---|---|
| ① 原始值 primitive / global | --blue-600、--gray-50、--space-4 | 只描述"它是什么颜色/多大",不带任何业务含义。组件里禁止直接用这一层。 |
| ② 语义 semantic / alias | --color-bg、--color-text、--color-danger、--radius-card | 描述"它用来干什么"。换主题(亮/暗/品牌红)只改这一层的映射关系。 |
| ③ 组件级 component | --button-bg-hover、--card-padding | 只在某个组件的样式里出现,把"组件级的可变点"也暴露出来(比如按钮的三种尺寸)。 |
用 CSS 变量落地三层 token,顺便做暗色主题
跨端 token:一份 JSON,编译成 CSS 变量 / JS 对象 / 小程序样式
坑一:组件直接引原始色值。background: var(--blue-600) 一旦换主题就失效(暗色下这个蓝会刺眼)。铁律:组件只用语义 token。可以加 lint 规则或 code review 检查。
坑二:token 命名按"长相"而不是"用途"。--color-green 的问题是:品牌色从绿改蓝之后,这个变量名就变成了谎言。用 --color-success / --color-brand 这类语义命名。
坑三:只给颜色做 token,间距/字号/hover 状态全靠手写。结果就是"卡片间距 15px 和 16px 混着用"。间距、圆角、阴影、动效时长、层级(z-index)都值得 token 化——z-index 尤其值得,它是团队协作里最容易打架的地方。
Storybook:组件的"活文档"
Storybook 干的事是:把每个组件单独跑在一个隔离页面里,用不同 props 展示不同状态(Story),并且能自动生成文档页、跑交互测试和无障碍检查。它的真正价值不是"好看",而是三件事:组件能被独立开发和评审、UI 状态不容易漏(加载/空/出错/超长文本)、新人能直接看到"这个按钮怎么用"。
一个 Button 的 Story(CSF3 写法)
- 按"角色 / 文本"找元素(
getByRole)而不是按 class 找:这样测试和"用户和读屏软件看到的东西"一致,重构样式不会把测试搞挂。 - 必备的四种状态:正常、加载、空、出错。再加"超长文本"和"极端数量"这两条,能提前发现 80% 的布局崩坏。
- 加
@storybook/addon-a11y:每个 story 面板里直接显示无障碍问题(对比度不足、缺 label、无 alt),改起来比事后整改便宜得多。
可访问性(a11y):不是"做好事",是硬需求
a11y 是 accessibility 的缩写(a + 11 个字母 + y)。很多团队把它当"有余力再说"的事,但现实是:① 它是很多行业的合规要求(政府、金融、教育领域的无障碍标准通常写进合同);② 它天然改善 SEO 和移动端体验(语义化标签、可读性、点击区域);③ 它本来就是"正常写代码"的一部分——很多无障碍问题,就是 div 用多了。
| 基本盘 | 具体要求 |
|---|---|
| 语义化标签 | 按钮用 <button>、导航用 <nav>、主内容用 <main>、标题按 h1→h2→h3 层级。语义标签自带键盘行为(Enter/Space 可触发)和读屏信息,<div onclick> 什么都没有。 |
| 键盘可达 | 所有交互元素都能用 Tab 聚焦、用 Enter/Space 触发;不要用 tabindex 正数打乱顺序;模态框打开时焦点要进入内部并"困住",关闭后还原到触发按钮。 |
| 焦点可见 | 自定义样式时别把 outline 干掉。要用 :focus-visible 而不是 outline: none——鼠标点击不显示、键盘 Tab 才显示。 |
| 颜色对比度 | 正文文字与背景对比度至少 4.5:1,大号文字(≥18.66px 粗体或 ≥24px)至少 3:1。而且不能只靠颜色传达信息("红色表示错误"要同时给文字说明)。 |
| 表单有标签 | 每个输入框都要有 <label for>(或 aria-label),错误提示要和字段关联(aria-describedby),必填要标出来。占位符 placeholder 不能替代 label。 |
| 多媒体与动效 | 图片有意义的写 alt、纯装饰写 alt="";视频提供字幕;尊重 prefers-reduced-motion,给对动效敏感的用户关掉动画。 |
前后对比:同一个"可点击的删除"写法
① 能不用 ARIA 就不用。原生 <button>、<nav>、<input> 已经带好语义和行为;<div role="button"> 需要你手动补键盘事件和 tabindex,补漏了就是个假按钮。
② 不要用 ARIA 改原生语义。给 <h2 role="button"> 只会让读屏软件困惑。要按钮就写按钮。
③ 所有 ARIA 控件必须可用键盘操作。role="button" 不加 tabindex="0" 和键盘处理,等于挖坑。
④ 不要对可聚焦元素用 aria-hidden="true"。这会让读屏用户"能聚焦到但读不出内容",比不隐藏还糟。
⑤ 交互元素必须有可访问名称。只有图标的按钮一定要有 aria-label 或视觉隐藏的文本,否则读屏只念"按钮",用户完全不知道它干什么。
把 a11y 检查塞进流水线
"靠自觉"是防不住的。a11y 检查能自动化,且成本很低——lint 阶段抓低级错误,测试阶段抓真实渲染出来的问题。
- 自动化只能抓约三成问题。剩下的必须手动过一遍:只用键盘把主要流程走完(Tab / Shift+Tab / Enter / Esc),再用读屏软件(macOS 的 VoiceOver、Windows 的 NVDA)听一遍关键页面。
- 成本最低的三件事:① 图标按钮全补
aria-label;② 所有输入框补<label>;③ 别删 outline 改成:focus-visible。
① 设计系统三层:Token → 组件库 → 文档/流程;token 先行,组件只消费语义 token。
② Token 分三级:原始值 / 语义 / 组件级。换主题只改语义层,暗色主题就是一次变量重写。
③ Storybook 是组件的活文档:状态要写全(正常/加载/空/出错/超长),用 play 函数做交互测试,配 a11y addon。
④ a11y 六个基本盘:语义标签、键盘可达、焦点可见、对比度、表单 label、动效偏好。ARIA 能不用就不用,用错比不用更糟。
⑤ 把检查自动化:eslint-plugin-jsx-a11y + vitest-axe + axe-core/playwright,人工再用键盘和读屏走一遍。
1. 产品要做暗色主题,如果项目里颜色全是硬编码的 #1a1a1a,你会怎么重构?
看答案
先抽 token:扫描现有颜色,归纳成有限的原始值(如 gray-50~gray-900),再定义语义层(--color-bg、--color-text、--color-border),把组件里的硬编码替换成语义变量。之后暗色主题只需要在 [data-theme="dark"] 下重写语义层。如果只是"最小改动上线",可先用 color-scheme + 少量语义变量过渡。
2. <div role="button" onclick="..."> 相比 <button> 少了什么?
看答案
少了三样:① 键盘操作——原生 button 支持 Tab 聚焦和 Enter/Space 触发,div 需要手写 tabindex="0" 和 keydown 处理;② 语义——读屏会念出"按钮",用户才知道它可点;③ 表单行为——button 有默认 type、能被表单正确提交/重置、能配合 disabled。所以结论是"能用原生标签就别用 div 模拟"。
3. 为什么自定义样式中 outline: none 是 a11y 大忌?有什么替代方案?
看答案
outline 是键盘用户判断"焦点在哪"的唯一线索,去掉之后键盘用户完全不知道当前选中了哪个元素,表单基本没法用。替代方案是用 :focus-visible 自定义焦点样式——它只在键盘操作时生效,鼠标点击不会出现难看的外框,既好看又不牺牲可访问性。
4. Storybook 对一个团队的实际收益是什么?是不是"锦上添花"?
看答案
至少三块收益:① 组件能独立开发与评审——不需要把整个应用跑起来、走完登录流程才能看到一个按钮的改动;② 状态不容易漏——加载、空、出错、超长文本这些边界情况被显式写进 story,避免上线后才发现;③ 自动成为团队文档与验收环境,配合 play 函数还能顺手当回归测试。对多产品线共用组件库的团队来说,它是基础设施而不是装饰。
5. 无障碍自动化工具(axe / Lighthouse)能把问题全查出来吗?
看答案
不能,通常只能覆盖大约三分之一、且偏"可机器判定"的问题(缺 alt、缺 label、对比度、role 用错)。像"焦点顺序是否合理""读屏念出来是否通顺""模态框焦点有没有被困住""错误提示能不能被及时播报"这些必须人工验证。所以流程上要两条腿:自动化守住下限,键盘 + 读屏人工走查守住上限。