Design Token 体系的落地
Design Token 不是「把颜色写成变量」,而是一套从设计源到多端产物的命名与转换体系。
一、三层命名体系
三层模型是骨架,自上而下是「原始值 → 语义 → 组件」:
- 原始层(Primitives / Global) —— 纯粹的值,不带意图:
blue-500: #3b82f6、space-4: 16px; - 语义层(Semantics / Alias) —— 表达用途:
color-action-primary指向blue-500,color-surface-raised指向gray-50; - 组件层(Component) —— 把语义绑定到具体组件:
button-primary-bg指向color-action-primary。
组件层可以按需建立——只有真正需要独立控制的组件(表格、图表、编辑器)才建,普通按钮通常不需要。
{
"color": {
"blue": { "500": { "$value": "#3b82f6", "$type": "color" } },
"action": { "primary": { "$value": "{color.blue.500}", "$type": "color" } }
}
}
上面是 W3C Design Tokens 社区组(DTCG)的格式:$value 存值、$type 声明类型、$description 写说明,引用用 {token.path} 语法。它的价值在于跨工具可交换——设计工具与构建工具都读得懂同一份文件。
关键纪律:业务代码只准用语义层。 直接引用 blue-500 意味着换个主色要改遍全站,而引用 color-action-primary 只需改一层映射。这条纪律要靠工具强制(lint 检查硬编码颜色),不能靠自觉。
二、从设计源到多端产物
一份 token 要产出多种形态:
- CSS 自定义属性 —— 优先级最高的产物;
- SCSS / Less 变量 —— 兼容老代码;
- JS / TS 常量 —— 给需要参与计算的场景(图表、Canvas);
- 移动端格式 —— iOS 与 Android 各自的产物。
优先 CSS 变量的理由很实在:它可以在运行时切换、可以继承、可以穿透 Shadow DOM。这意味着换主题不需要重新构建,也不需要把整套值再打一份进 JS。
:root {
--color-surface: var(--gray-50);
--color-text-primary: var(--gray-900);
}
[data-theme='dark'] {
--color-surface: var(--gray-900);
--color-text-primary: var(--gray-50);
}
典型链路是:设计工具里的变量 → DTCG JSON(交换格式)→ 构建工具(如 Style Dictionary)→ 各平台产物。不必一上来就搭全套,从手写的 DTCG JSON 构建出 CSS 变量开始,够用了再加。
// Style Dictionary 配置:把 DTCG JSON 转成多端产物
module.exports = {
source: ['tokens/**/*.json'],
platforms: {
css: { transformGroup: 'css', buildPath: 'dist/css/', files: [{ destination: 'variables.css', format: 'css/variables' }] },
js: { transformGroup: 'js', buildPath: 'dist/js/', files: [{ destination: 'tokens.js', format: 'javascript/es6' }] },
},
}
三、一套语义两套值
深色主题之所以能「一套组件两套皮肤」,就是因为换的只有语义层的映射:color-surface 在浅色指向 gray-50、在深色指向 gray-900,组件代码一行不改。
反过来,如果组件里写死了颜色值,做暗色就是重写一遍组件。所以判断一套 token 是否及格,只有一条标准:能不能只改映射就换主题。
三条设计细节:
- 深色不是反色。层级在浅色里靠阴影表达,在深色里要改成靠表面亮度(越靠上的层越亮);
- 饱和度整体降一档,纯白纯黑要避开;
- 自动生成的深色往往不可用——它只是把颜色变暗,对比度、层级、图表配色都要重新设计。
四、发现漂移的检查点
「漂移」指的是代码里出现硬编码值、或者设计与代码不一致。三种发现手段:
- Lint 拦截 —— 禁止在样式里直接写颜色值(用允许列表规则),把问题挡在提交前;
- 对比度门禁 —— token 定义时就校验组合是否满足无障碍要求(正文 4.5:1、大字 3:1),避免上线几个月后在审计里才发现问题;
- 设计与代码比对 —— 定期把设计源导出的 token 与代码产物做一次 diff,差异即漂移。
还有一条治理经验:先审计再创建。中型产品做一次盘点,常常会发现几十种灰阶和十几种字号;先收敛到一个合理的规模(比如 8~12 级灰、6 级字阶),再谈体系。
同时要警惕过度建模:把每种排列组合都建成 token,系统会膨胀到没人愿意用,最后大家又退回硬编码。一个参考尺度是:原始层控制在两百以内、语义层一百五十以内,总量超过五百就该先审计冗余。
切换主题的成本取决于有多少元素订阅了变量,量一下再决定要不要做:
const start = performance.now()
document.documentElement.dataset.theme = 'dark'
document.body.offsetHeight // 强制同步布局,把重算时间算进来
console.log('切换耗时', (performance.now() - start).toFixed(1), 'ms')
// 经验值:上千个元素订阅变量时,切换通常在十几毫秒级;
// 若到了百毫秒,说明不该用变量切换,应该换成整份样式表切换
五、变量切换的开销
主题切换的实现是改根级的 CSS 变量,这会触发受影响元素的样式重算。变量规模可控时几乎无感;变量膨胀到一定量级,切换就会有可感知的卡顿。
对策:
- 控制变量规模,不要为每个组件都造一套变量;
- 切换瞬间可临时禁用过渡动画,避免动效叠加放大卡顿;
- 避免把变量挂在会频繁重算的层级,必要时用类名切换替代大量变量重写。
漂移检查不用人工看,脚本比对两份 token 的键集合即可:
import design from './tokens/design.json' with { type: 'json' }
import code from './tokens/code.json' with { type: 'json' }
const flat = (o, p = '') => Object.entries(o).flatMap(([k, v]) =>
v && typeof v === 'object' && '$value' in v
? [p + k]
: v && typeof v === 'object' ? flat(v, p + k + '.') : [])
const a = new Set(flat(design))
const b = new Set(flat(code))
console.log('设计源有、代码没有:', [...a].filter((k) => !b.has(k)))
console.log('代码有、设计源没有:', [...b].filter((k) => !a.has(k)))
六、token 也要发版本
token 是被多个产品消费的接口,所以要按接口来管:
- 破坏性改名 → 主版本;
- 新增别名 → 次版本;
- 修正值 → 修订版本。
不做版本管理的结果很典型:产品 A 升级了、产品 B 没升级,于是同一个品牌色在公司自己的两个站点上长得不一样。
七、不只是把颜色写成变量
把颜色值集中放一个文件,如果没有语义分层,那只是常量表——换主题时一样要改遍全站。
产物也不能只有 CSS 变量。多端场景还要 JS 常量和其它平台格式,单一产物不够用。
token 也不是越多越好:过度建模会让系统膨胀到没人愿意用,最后大家退回硬编码。
深色更不能由浅色自动生成——层级、阴影、饱和度、图表配色都要重新设计。
检验 token 体系是否及格只有一条——换主题时你改的是映射,还是每一个组件。
Token 的术语与分层还在标准化过程中,W3C Design Tokens 社区组 的输出可以作为对齐依据。