项目有两个产品线,五六十个页面,UI 散落在六个仓库里。改一次品牌色要在二十多处手改,按钮的圆角值有三个版本同时在线。我们决定建一套设计系统,把「散点」收束成「单源」。
三个月后,Token 单源、组件库统一、文档可交互。这篇文章记录完整的搭建路径——从地基到屋顶。
从 Token 开始:设计系统的地基
设计系统最容易跑偏的地方是——一上来就画组件。但组件的稳定性取决于它依赖的值是否单源。Token 就是那个单源。
三层 Token 结构
我们把 Token 分成三层,每层职责不同:
| 层 | 命名 | 职责 | 示例 |
|---|---|---|---|
| 基础层 | color-red-500 |
原始值,不参与业务 | #ff3d00 |
| 语义层 | color-accent |
赋予含义,可换源 | = 基础层 red-500 |
| 组件层 | button-bg-primary |
绑定到具体组件 | = 语义层 accent |
// tokens/color.ts —— 基础层
export const red = {
500: '#ff3d00',
600: '#e63600',
} as const;
// tokens/semantic.ts —— 语义层
export const semantic = {
'color-accent': red[500],
'color-danger': red[600],
'color-ink': '#0d0d0d',
} as const;
// tokens/component.ts —— 组件层
export const component = {
'button-bg-primary': semantic['color-accent'],
'button-text-primary': '#ffffff',
'input-border': semantic['color-ink'],
} as const;
三层的好处是——换品牌色只改一处。把语义层的 color-accent 指向新的基础值,所有组件自动跟随,因为它们只认语义层、不认基础层。
组件只消费语义层和组件层,禁止直接引用基础层。这条 lint 规则是 Token 体系不腐化的关键。一旦有组件写了 red[500],换色就要改它。
Token 的多平台分发
同一个 Token 源,要分发给 CSS 变量、Tailwind 配置、iOS / Android 平台。用 Style Dictionary 做转换:
// style-dictionary.config.json
{
"source": ["tokens/**/*.ts"],
"platforms": {
"css": {
"transformGroup": "css",
"buildPath": "dist/css/",
"files": [{ "destination": "tokens.css", "format": "css/variables" }]
},
"js": {
"transformGroup": "js",
"buildPath": "dist/js/",
"files": [{ "destination": "tokens.js", "format": "javascript/es6" }]
}
}
}
一次定义、多处分发,是设计系统作为「工程产物」而非「Figma 文档」的分水岭。
组件库:分层而非堆叠
组件库不是把所有 UI 往一个目录里堆。我们按「原子 → 分子 → 有机体 → 模板」的原子设计思路分层,每层有明确的边界:
| 层 | 组件 | 知道什么 | 不该知道什么 |
|---|---|---|---|
| 原子 | Button、Icon、Input | 自己的样式、a11y | 业务语义 |
| 分子 | FormField、SearchBar | 几个原子的组合 | 数据获取 |
| 有机体 | DataTable、FormSection | 组合 + 交互 | 路由、全局态 |
| 模板 | PageLayout | 页面骨架 | 具体业务内容 |
关键是禁止越层引用:原子不该 import 有机体。一旦下层依赖上层,整个分层就退化为网状依赖,谁也拆不出来。
API 设计:可控与可组合
// 组件 props 的三条铁律:
// 1. 职责单一:Button 只负责"点击"
// 2. 透传原生属性:不发明平行 API
// 3. 变体用对象,不用布尔堆叠
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
};
// ❌ variant 多了就退化成布尔地狱
// <Button primary danger outline /> ← 语义冲突
// ✅ 变体约束互斥
<Button variant="primary" size="md" />
组件的可复用性,取决于它对外部世界的无知程度。
— 组件 API 审查原则
文档:让规范可执行
没有可交互的文档,设计系统等于没有。我们用 Storybook 做了一件事——让每个组件的所有状态都可被看见。
覆盖所有状态而非只画默认态
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Atoms/Button',
component: Button,
// 穷举所有合法组合,作为视觉回归基线
args: { children: '按钮' },
};
export default meta;
export const Primary: StoryObj = {
args: { variant: 'primary', size: 'md' },
};
export const PrimaryDisabled: StoryObj = {
args: { variant: 'primary', size: 'md', disabled: true },
};
export const Danger: StoryObj = {
args: { variant: 'danger', size: 'md' },
};
这一步最大的价值不是"展示",而是给视觉回归测试留基线。配合 Chromatic 做 snapshot,任何组件的视觉偏移都会在 CI 里被拦下——一致性从此由机器守。
文档即契约
每个组件的 Story 页面附带一段「何时用 / 何时别用」的说明。这段文字不是装饰,而是给 PR 评审当判据。当有人想用 Button 做 Tab 切换,评审可以指着文档说"这不符合它的设计意图"。
把"什么时候不该用"写进文档。反例比正例更能阻止误用。一份只写"怎么用"的文档,挡不住"我就借它一用"的诱惑。
治理:谁来把关一致性
设计系统不是交付物,是持续运转的机制。没有治理流程,三个月后它会重新散开。
三方协作模型
我们把治理拆成三个角色,互相制衡:
- 设计组:维护 Token 与视觉规范,组件提案的第一道评审。
- 前端组:实现组件、写 Story、保证 a11y 与测试覆盖。
- 消费方:提需求、报问题,但不直接改组件库源码。
流程是:消费方提 RFC → 设计组评审意图 → 前端组评审实现 → 合并进主仓库 → 发版同步。任何一方跳过,变更都可能制造不一致。
版本化与变更日志
# 组件库独立发版,消费方按 semver 升级
npm version minor # 新增组件 → minor
npm version patch # 修复 → patch
npm version major # 破坏性 API 变更 → major + 迁移指南
# CHANGELOG 由 conventional commits 自动生成
# 消费方升级时能看到每一个 breaking change 的迁移路径
独立版本化的真正意义是——把"改一个组件"和"所有产品同步"解耦。没有版本边界,一次"优化"就会变成对所有消费方的强迫升级。
设计系统的敌人不是复杂度,是熵增。
— 治理流程的设计起点
复盘与收益
三个月落地,六个月后回头看,量化收益如下:
| 指标 | 建系统前 | 建系统后 |
|---|---|---|
| 新页面开发周期 | 5–8 天 | 2–3 天 |
| UI 不一致工单 / 月 | 14 | 2 |
| 品牌色全量替换 | 2 人天 | 10 分钟 |
但更值钱的是非量化收益——产品、设计、前端第一次有了一份共享的词汇表。讨论从"按钮再灰一点"变成了"调 color-ink-3 的语义指向",争论的颗粒度变细,效率自然上来。
回头看走过的路,有四条经验值得留给后来者:
- Token 先行,组件后行。地基不稳,组件迟早要重写。
- 分层比堆叠重要。层次清晰的组件库比"功能全但耦合"的库更长寿。
- 文档是契约不是展示。没有反例和评审判据的文档,挡不住误用。
- 治理决定寿命。没有流程的设计系统,三个月后就会重新散开。
设计系统不是交付一个仓库,而是建立一种让一致性可持续的机制。代码可以重写,机制会沉淀下来——这才是它真正的价值。