Chapter 05
工程落地
02–04 章产出了一堆决策:色阶、字阶、间距阶。这章解决"决策怎么变成可维护的代码"——用三层 token 把它们沉淀下来,并用浏览器渲染管线的知识,写出不卡顿的动效。
本章你将建立的 schema
- Design tokens 三层(primitive → semantic → component):一层间接换来主题 / 暗色 / 换肤只改一处
- 原子化(Tailwind)与语义化 CSS 的真实权衡——以及 Tailwind 为何天然强制一致
- 渲染管线 style→layout→paint→composite:为什么只动 transform / opacity 的动画才不卡
- 组件的工作量在"状态":hover / focus / active / disabled / loading 才是大头
前四章是"设计判断",这一章是"工程纪律"。判断再好,若每个颜色都写死 hex、每个动画都掉帧、每个组件只画了默认态,落地就会塌。本章给三条纪律。
5.1Design tokens:三层间接
把设计值组织成三层——primitive(具体值)→ semantic(表达意图)→ component(局部覆盖);中间这层间接,让换主题、做暗色、改品牌只动一处。
把 #3b82f6 直接写在 50 个组件里,改一次品牌色就要全局搜替换,还总有漏网。token 的间接层把"值"和"用处"解耦:用处指向语义名,语义名再指向值。
底层机制(比文档深一层):三层各司其职。primitive / global 是原料盘(--blue-500: oklch(...)、--space-4: 16px),只命名"是什么",不含用途。semantic / alias 表达意图(--color-primary、--color-text-muted、--surface),指向某个 primitive。component 是局部覆盖(--button-bg 指向 --color-primary),只在组件需要偏离全局时才加。换皮肤 / 暗色模式只重绑 semantic 那一层(02 章暗色已演示),换品牌色只改 primitive,组件代码一行不动。CSS 自定义属性是它们的运行时载体——可被媒体查询、data-theme 属性、JS 动态切换。
:root {
/* ① primitive:原料,只说"是什么" */
--blue-500: oklch(0.62 0.19 255);
--gray-700: oklch(0.28 0.01 255);
--space-4: 16px;
/* ② semantic:意图,指向 primitive */
--color-primary: var(--blue-500);
--color-text: var(--gray-700);
--pad-card: var(--space-4);
}
.button { /* ③ component:用语义名,不碰具体值 */
background: var(--color-primary);
padding: var(--pad-card);
}
与下一节的关系:token 是"值怎么存";"样式写在哪"是另一个选择——写在标记上,还是收在类里。
5.2原子化 vs 语义化 CSS
原子化(Tailwind:class="px-4 text-sm")把样式直接写在标记上;语义化(.card { … })把样式收进命名类。真实权衡是"就近、约束尺度、无命名负担"对"标记冗长、复用靠组件"。
底层机制(比文档深一层):Tailwind 的价值常被简化成"写得快",真正的关键在于——它把间距 / 字阶 / 色阶做成了一套强制的 token 集合:你只能写 p-4(取自阶),很难写 p-[7px](随手值需要特意逃逸)。这从工具层面堵死了 §4.1 说的"随手填像素",让一致性变成默认而非自律。代价是标记变长、跨元素复用要靠抽组件(而非共享 CSS 类)。语义化 CSS 反过来:标记干净、复用直接,但需要自己命名、自己守纪律,容易在大团队里漂移。两者不是对错,是匹配场景。
| 维度 | 原子化(Tailwind) | 语义化(自写类) |
|---|---|---|
| 一致性 | 工具强制取自尺度,默认一致 | 靠人自律,易漂移 |
| 就近性 | 样式与标记同处,改动不跳文件 | 样式在另一处,需来回找 |
| 标记可读性 | 类名很长,初看吵 | 干净,语义清晰 |
| 复用 | 靠抽组件(React 等) | 共享一个类即可 |
| 适合 | 组件化框架 + 快速迭代 + 想强制尺度 | 稳定设计语言 + 少量手写页面 |
Tailwind v4(2025 发布)把配置从 JavaScript 的 tailwind.config.js 改成了 CSS 内的 @theme 块,直接用原生 CSS 变量定义尺度——这让"Tailwind 的尺度"和"CSS 自定义属性的 token"合流,§5.1 的三层 token 可以直接喂给 Tailwind。
与下一节的关系:无论样式写在哪,动起来卡不卡,取决于一个更底层的东西——浏览器怎么把 CSS 变成像素。
5.3动效与渲染管线:为什么有的动画卡
浏览器渲染分四阶段 style → layout → paint → composite;只改 transform 和 opacity 的动画只触发最后一步(GPU 合成),所以顺滑;改 width / top / margin 会从 layout 重跑,所以卡。
"动画为什么卡"几乎是工程师做交互时第一个撞上的问题,而答案不在 CSS 语法里,在浏览器的渲染流程里。知道哪些属性"便宜"、哪些"昂贵",就能在写动画前就避开掉帧。
底层机制(比文档深一层):浏览器每一帧最多约 16.7ms(60fps)。把 CSS 变成屏幕像素要经过四步:① Style(算出每个元素的最终样式)→ ② Layout(算几何:位置、尺寸——又叫重排 reflow)→ ③ Paint(把像素画到图层)→ ④ Composite(把各图层在 GPU 上合成)。关键:你改动的属性,决定从哪一步开始重跑。
- 改
width / height / top / left / margin→ 从 Layout 开始全跑(重排会波及其它元素),每帧重排 = 掉帧。 - 改
color / background / box-shadow→ 跳过 Layout,从 Paint 开始,较省但仍占主线程。 - 改
transform / opacity→ 跳过 Layout 和 Paint,只在合成线程做 Composite(GPU),主线程不阻塞 → 稳定 60fps。
所以铁律:位移用 transform: translate() 而非 left;缩放用 transform: scale() 而非 width;显隐用 opacity 而非 display / height。 will-change 可提示浏览器提前为元素提层,但滥用反而吃内存,只在确有需要时加。
/* ✗ 卡:动 left 每帧触发 Layout 重排 */
.menu-bad { transition: left 200ms; } /* left: -300px → 0 */
/* ✓ 顺:动 transform 只触发 Composite(GPU) */
.menu-good { transition: transform 200ms ease-out; } /* translateX(-300px) → 0 */
/* 进入用 ease-out(快进慢停,跟手);尊重用户的减弱动画设置 */
@media (prefers-reduced-motion: reduce) {
.menu-good { transition: none; }
}
时长与缓动
UI 微交互的时长落在 150–300ms:短于 ~100ms 感知不到、形同瞬变,长于 ~400ms 拖沓挡手。缓动(easing)表达"运动的性格":元素进入用 ease-out(开头快、结尾慢,跟手且利落),退出用 ease-in;需要弹性时用 spring / 自定义 cubic-bezier。
与下一节的关系:动效是单个元素的事;把这些元素攒成可复用的组件时,真正的工作量藏在一个容易被跳过的地方——状态。
5.4组件 API:工作量在状态
设计一个组件,大部分工作不是默认长相,而是它的各种状态:hover / focus / active / disabled / loading / error / empty。
底层机制(比文档深一层):新手把"做一个按钮"理解成"画一个按钮样子",于是只做了默认态。真实的按钮是七种态的集合,缺一种,用户就会撞到"点了没反应 / 不知道能不能点 / 提交时狂点"这类问题。组件的三个设计轴:variant(primary / secondary / ghost——这是 01 章主次对比的制度化)、size(sm / md / lg,取自字阶与间距阶)、state(上述七态)。其中 :focus-visible 对键盘可达性是硬要求(回扣 02 章无障碍):它只在键盘聚焦时显示焦点环,鼠标点击不显示,兼顾美观与可达。
右边把同一个主按钮的几种态都做了:hover 加深(明度,02 章)、disabled 降对比并改光标、loading 给反馈。这才是一个"能上线"的按钮。一套设计系统的体量,很大程度就是组件数 × 状态数。
5.5收束:落地顺序
- token 先行:把 02–04 的色阶 / 字阶 / 间距阶写成三层 token,组件只引用语义名。
- 选 CSS 方案:要强制尺度 + 快迭代 → 原子化;稳定设计语言 + 手写页 → 语义化。两者都吃同一套 token。
- 动画只碰合成属性:位移 / 缩放用 transform,显隐用 opacity;时长 150–300ms,进入 ease-out;尊重 reduce-motion。
- 组件按状态设计:variant × size × state 三轴,七种态一个都不少,焦点用
:focus-visible。
到这里,设计判断(01–04)已经全部沉淀成可维护、可复用、不卡顿的代码。最后一章把视角拉高:当组件多到几十上百个,如何在规模上保持一致,又如何在当下保持不土。
§本章 self-check
先合上教程,把答案写下来再展开对照。
- token 为什么要分三层?暗色模式和换品牌色,分别只改哪一层?
- Tailwind 的核心价值除了"快",更关键的是什么?它如何从工具层面保证一致性?
- 为什么用
transform: translateX()做位移动画比用left顺滑?用渲染管线四阶段解释。 - "做一个按钮"为什么大部分工作不在默认态?至少列出该设计的 4 种状态。
答案(先做完再展开)
- 三层把"值 / 意图 / 用处"解耦:primitive 存值、semantic 表意图、component 局部覆盖。暗色 / 换肤只重绑 semantic 层(指向另一组 primitive);换品牌色只改 primitive。组件代码都不动。
- 更关键的是它把间距 / 字阶 / 色阶做成强制 token 集合——你只能用
p-4取自阶、很难随手p-[7px],从工具层堵死"随手填值",让一致性成为默认。 - 改
left从 Layout 阶段全跑(重排,波及邻居)每帧掉帧;改transform跳过 Layout 和 Paint,只在合成线程走 Composite(GPU),主线程不阻塞,稳定 60fps。 - 因为按钮是七种态的集合,缺一种用户就撞问题。至少:hover、focus(-visible)、active、disabled、loading;再加 error / empty 视组件而定。
把一个"加入购物车"按钮做成生产级组件
要求:① 用 §5.1 的三层 token 定义它的背景 / 文字 / 圆角 / 内边距(取自前几章的阶);② 写出 primary 与 secondary 两个 variant;③ 列全并实现它的状态:hover、:focus-visible、active、disabled、loading(点击后到请求返回前);④ 点击后的"加入成功"反馈动画,只能用 transform / opacity 实现,时长和缓动给出取值并说明理由。
提示(卡住再展开)
① --btn-bg: var(--color-primary) 等,组件只引用语义名。② primary 实心强调色、secondary 描边灰(01 章主次)。③ hover 把背景明度降一档(OKLCH 只动 L);:focus-visible 加 2px 焦点环(可达性);active 轻微 transform: scale(0.98) 给"按下感";disabled 降对比 + cursor:not-allowed + 去掉 hover;loading 禁用点击 + 显示 spinner(避免重复提交)。④ 成功反馈用 opacity 渐显一个对勾 + 轻微 translateY,200ms、ease-out(进入跟手);记得 prefers-reduced-motion 下关掉。