chapter[8] · part 2 / 程序员的界面美学
第七章给了你七条原则,但原则有一个工程上的致命弱点:它依赖人的自觉执行,而一切依赖自觉的规则都会在 deadline 面前衰减。程序员对这类问题有标准解法:把规则变成代码,让违反规则的成本高于遵守规则——类型检查、lint、CI。设计的同款答案就是设计系统(design system)。这一章不再把它当比喻讲(第〇章已经做过),而是当工程讲:架构、实现、演化策略。
没有系统的界面会经历一种特别的技术债。它的积累路径每个人都眼熟:上线第一版时界面挺干净 → 三个月后加新功能,没人记得当初的灰色是哪个值,目测选了个差不多的 → 外包做的活动页又带来一批 → 换了个前端,他喜欢另一种圆角……十八个月后做一次审计,结果通常令人发笑:几十种灰色、十几个字号、五种蓝色的"主色"。大公司公开的设计系统案例研究里,审计出上百个颜色值的故事屡见不鲜。
关键是要看清这不是懒惰问题,是结构问题:每一次单独的目测选色都是合理的,腐烂发生在没有任何机制让这些决定相互可见。和代码腐烂一模一样——没有人故意写出大泥球。
设计系统的本质只有一句话:把视觉决定从"每次现做"变成"一次决定、处处引用"。颜色、字号、间距、圆角、阴影,全部变成具名常量;界面代码里不允许出现裸的魔法值。美感的一致性从此不靠记忆和自觉,靠引用。
设计系统的最小单元叫设计令牌(design token)——一个具名的视觉常量。成熟的系统把 token 组织成三层,每层回答不同的问题:
三层架构里最值钱的纪律是命名即语义。对比两种写法:
/* ✗ 写法一:界面代码直接引用原始值 */
.price-alert { color: var(--red-600); }
/* 它没有回答"为什么是红的"。降价提醒是危险?是强调?
暗色模式该把它变成什么?没人知道。 */
/* ✓ 写法二:经过语义层 */
.price-alert { color: var(--color-emphasis); }
/* 意图进了代码。哪天品牌色从红换成橙,
全站语义不变,只改一行映射。 */
你完全可以用依赖管理的直觉来审查样式:color: #C2452D 是硬编码常量;var(--red-600) 是引用了具体实现(好一点,但相当于 new MySQLConnection());var(--color-accent) 才是依赖注入——面向语义编程,不面向取值编程。第〇章说"一个页面用了第六种颜色等同于类型错误",现在你有了让编译器真的报错的办法:设计 lint(如 stylelint 的 declaration-property-value-allowed-list)可以禁止任何裸色值进入代码库。
Token 约束了取值,但还约束不了组合——没人拦着你用合法的 token 拼出一个直角红底绿字的按钮。第二级封装是组件:把七原则的正确组合固化成可复用单元,并且用变体(variants)枚举合法状态:
// 按钮的变体是枚举,不是自由参数
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
// ✗ 反模式:开放任意样式注入,等于没有系统
<Button style={{ background: "#7B4E9B", borderRadius: 17 }}>
// ✓ 只暴露语义选项,样式细节封装在组件内
<Button variant="danger" size="md">删除</Button>
判断组件 API 好坏的标准和函数签名一样:参数表达意图(danger),不表达实现(红色);选项是有限枚举,不是连续自由度。每开放一个自由样式入口,就等于在类型系统上凿一个 any。
把三层模型套回本书自己的 style.css(第〇章的承诺在此兑现):原始层是五个色值与 1.25 字阶、8px 标尺;语义层是"纸/墨/朱砂/黛青/金"的角色绑定——注意这些名字全是语义(朱砂=强调,不是"红色");组件层是 figure.art、.callout.dev、.principle 这些封装好的版式单元。写作十几章以来,正文里没有出现过一个裸色值或随手字号——不是因为作者自觉,而是因为组件库里根本没有提供犯错的入口。这就是系统的全部意义:把对的做法变成最省事的做法。
| 系统 | 是什么 | 从它学什么 |
|---|---|---|
| Material Design(Google) | 最完备的开源设计系统文档 | 规范怎么写:每个组件都有解剖图、行为定义、反例。第十一章精读。 |
| Human Interface Guidelines(Apple) | 平台设计哲学 + 约定 | 约定的价值:尊重平台习惯本身就是 UX。第十章精读。 |
| Tailwind CSS | 原子化的 token 直接长在 class 名里 | token 思维的极致:text-lg、gap-4 就是公开的标尺,魔法值无处藏身。 |
| shadcn/ui | 复制进项目、可完全修改的组件集 | 组件层的所有权:系统不是依赖,是你代码库的一部分,可以演化。 |
最后是落地顺序。设计系统最常见的死法是"启动一个设计系统项目":先憋三个月做出五十个组件,业务方一个没用。正确路径是收割式的,像重构而不像重写:
用脚本扫出代码库里所有颜色、字号、间距的实际取值(练习 8 就做这个)。腐烂程度可视化之后,收敛的优先级自然浮现。
几十种灰合并成 8 级灰阶,字号收进一个字阶。这一步纯机械,一两天能做完,收益立竿见影。
定义十来个语义 token,规定新代码只许引用语义层;旧代码遇到就改,不专项清理。配上 lint 防止回潮。
同一个 UI 模式第三次出现时,才把它提取为组件(三次法则,和抽函数同一条规则)。组件库是沉淀出来的,不是规划出来的。
新手和成熟设计系统之间,有一道几乎一眼可辨的分水岭:新手画的是组件"长什么样",成熟系统定义的是组件"在每一种状态下长什么样"。一个按钮不是一张图,它是一个状态集合——默认、悬停、聚焦、按下、禁用、加载……每一个都要被显式设计,而不是浏览器默认值随便给。一个输入框还要加上:空、已填、聚焦、错误、禁用。把它们排成矩阵,你立刻看出设计的真实工作量:
状态矩阵就是组件的类型签名。一个 <Button> 的所有合法状态,本质是它 props 的笛卡尔积:variant × size × {disabled, loading} × {hover, focus, active}。设计一个组件,等于为这个状态空间里的每一个可达状态都定义一个连贯的样子——这和给一个函数的所有输入分支都写好返回值是同一件事,漏掉的状态就是 UI 里的未处理异常。所以成熟组件库(如 shadcn/Radix)把状态做成显式的 data-state 属性或 CSS 伪类(:hover/:focus-visible/:disabled/[aria-busy]),让每个状态都有名字、可被单独设计与测试。下次提"组件已完成"前,先问:它的状态矩阵填满了吗?
两道题。① 风格审计:用你最顺手的方式扫描代码库——例如 grep -roE "#[0-9a-fA-F]{3,8}" src/ | sort | uniq -c | sort -rn,再用同样思路扫 font-size 和 margin/padding 的取值。把三个数字写下来:颜色数、字号数、间距数。然后按 8.6 的第 ① ② 步收敛它们。多数项目第一次审计会超过 50 种颜色——别沮丧,把"审计前/审计后"的数字记进 swipe file,这是你第一个可量化的设计改进。② 状态审计:挑你产品里一个核心组件(按钮、输入框、列表项),照图 8-2 把它的状态矩阵画出来,逐格检查——悬停、聚焦、禁用、加载、错误、空,哪几格是浏览器默认值在凑数、哪几格根本没设计?没填的格子,就是用户迟早会撞见的"未处理状态"。