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 动态切换。

component --button-bg 局部覆盖 semantic --color-primary 表达意图 primitive --blue-500 原料 / 具体值 暗色 / 换肤 → 只重绑这一层
图 5.1token 的指向链:用处(component)→ 意图(semantic)→ 值(primitive)。注意:① 组件只认 semantic 名,从不直接碰 primitive;② 暗色 / 换肤改 semantic 那一层(指向另一组 primitive),组件零改动;③ 换品牌色改 primitive,语义和组件都不动——这就是"间接层"省下的维护成本。
tokens.cssCSS
: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 反过来:标记干净、复用直接,但需要自己命名、自己守纪律,容易在大团队里漂移。两者不是对错,是匹配场景。

表 5.1 · 两种写法的权衡(按场景选)
维度原子化(Tailwind)语义化(自写类)
一致性工具强制取自尺度,默认一致靠人自律,易漂移
就近性样式与标记同处,改动不跳文件样式在另一处,需来回找
标记可读性类名很长,初看吵干净,语义清晰
复用靠抽组件(React 等)共享一个类即可
适合组件化框架 + 快速迭代 + 想强制尺度稳定设计语言 + 少量手写页面
现状 · 截至 2026-06

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 可提示浏览器提前为元素提层,但滥用反而吃内存,只在确有需要时加。

Style Layout重排 Paint绘制 CompositeGPU 合成 width / left / margin 从这里全跑 · 最慢 color / 背景 transform / opacity 只到这里 · 最快 越靠左进入,代价越大(波及越多、越占主线程)
图 5.2同样是"让元素动起来",入口不同,代价天差地别。注意:① 改 transform / opacity 只触发最后的 GPU 合成,主线程空闲 → 顺;② 改 width / left 从 Layout 全跑且波及邻居 → 卡;③ 写动画前先问"我改的属性从哪一步进入管线",而不是写完再调性能。
smooth-vs-janky.cssCSS
/* ✗ 卡:动 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。

linear · 匀速(机械) ease-out · 快进慢停(进入) ease-in · 慢起快冲(退出)
图 5.3三种缓动的"位置随时间"曲线。注意:① linear(直线)在 UI 里显得机械,真实物体不会匀速启停;② ease-out 开头斜率大(快)、结尾平(慢),最跟手,是元素进入 / 展开的默认;③ 缓动选错比没动画更糟——它会让交互"感觉不对"却说不出原因。

与下一节的关系:动效是单个元素的事;把这些元素攒成可复用的组件时,真正的工作量藏在一个容易被跳过的地方——状态。

5.4组件 API:工作量在状态

设计一个组件,大部分工作不是默认长相,而是它的各种状态:hover / focus / active / disabled / loading / error / empty。

底层机制(比文档深一层):新手把"做一个按钮"理解成"画一个按钮样子",于是只做了默认态。真实的按钮是七种态的集合,缺一种,用户就会撞到"点了没反应 / 不知道能不能点 / 提交时狂点"这类问题。组件的三个设计轴:variant(primary / secondary / ghost——这是 01 章主次对比的制度化)、size(sm / md / lg,取自字阶与间距阶)、state(上述七态)。其中 :focus-visible 对键盘可达性是硬要求(回扣 02 章无障碍):它只在键盘聚焦时显示焦点环,鼠标点击不显示,兼顾美观与可达。

只画了默认态 · 不完整
提交 ↑ hover? disabled? loading? 都没有
设计了状态 · 完整
提交 hover disabled ● 提交中

右边把同一个主按钮的几种态都做了:hover 加深(明度,02 章)、disabled 降对比并改光标、loading 给反馈。这才是一个"能上线"的按钮。一套设计系统的体量,很大程度就是组件数 × 状态数。

5.5收束:落地顺序

  1. token 先行:把 02–04 的色阶 / 字阶 / 间距阶写成三层 token,组件只引用语义名。
  2. 选 CSS 方案:要强制尺度 + 快迭代 → 原子化;稳定设计语言 + 手写页 → 语义化。两者都吃同一套 token。
  3. 动画只碰合成属性:位移 / 缩放用 transform,显隐用 opacity;时长 150–300ms,进入 ease-out;尊重 reduce-motion。
  4. 组件按状态设计:variant × size × state 三轴,七种态一个都不少,焦点用 :focus-visible。

到这里,设计判断(01–04)已经全部沉淀成可维护、可复用、不卡顿的代码。最后一章把视角拉高:当组件多到几十上百个,如何在规模上保持一致,又如何在当下保持不土。

§本章 self-check

先合上教程,把答案写下来再展开对照。

  1. token 为什么要分三层?暗色模式和换品牌色,分别只改哪一层?
  2. Tailwind 的核心价值除了"快",更关键的是什么?它如何从工具层面保证一致性?
  3. 为什么用 transform: translateX() 做位移动画比用 left 顺滑?用渲染管线四阶段解释。
  4. "做一个按钮"为什么大部分工作不在默认态?至少列出该设计的 4 种状态。
答案(先做完再展开)
  1. 三层把"值 / 意图 / 用处"解耦:primitive 存值、semantic 表意图、component 局部覆盖。暗色 / 换肤只重绑 semantic 层(指向另一组 primitive);换品牌色只改 primitive。组件代码都不动。
  2. 更关键的是它把间距 / 字阶 / 色阶做成强制 token 集合——你只能用 p-4 取自阶、很难随手 p-[7px],从工具层堵死"随手填值",让一致性成为默认。
  3. 改 left 从 Layout 阶段全跑(重排,波及邻居)每帧掉帧;改 transform 跳过 Layout 和 Paint,只在合成线程走 Composite(GPU),主线程不阻塞,稳定 60fps。
  4. 因为按钮是七种态的集合,缺一种用户就撞问题。至少: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 下关掉。