Design Token 工程:语义化 Token、主题扩展与组件约束,如何让 UI 一致性变成“系统能力”
Design Token 工程:语义化 Token、主题扩展与组件约束,如何让 UI 一致性变成“系统能力”
绝大多数团队的 UI 一致性问题,表面看是“颜色不统一/圆角不统一/字号不统一”,本质是工程缺少一个可执行的契约: 到底哪些值可以自由写,哪些必须从系统里取?
如果你在 code review 里经常讨论“这个 #fff 到底能不能写”“这个 12px 是不是又散装了”,基本就说明:你缺的不是审美,而是系统约束。
如果没有 Token 工程,你会在代码里看到三种典型症状:
- 散装值:到处都是
#fff、12px、rgba(...),同一语义用 N 个写法表达。 - 暗黑模式失控:“白底黑字”靠 if/else 或手写,越改越乱;最终只能局部修补。
- 组件不可复用:Button/Card/Text 的变体越来越多,名字像口头禅,没人说得清差异。
这篇文章的目标是把问题收敛成一个系统工程:用 Design Token 分层 + 语义化 + 组件约束 + 类型提示 + 发布门禁, 把“一致性”做成可持续迭代的能力,而不是靠人肉 review。
TL;DR(30 秒讲清楚)
- Token 分层:global(原子值)→ semantic(语义映射,含暗黑)→ component(组件变体契约)。
- 消费规则:业务代码尽量只用 semantic/component,不直接用 raw hex/px。
- 工程护栏:用 lint 抓“散装值”,用类型提示减少“写错 token 名”的低级错误。
- 可回滚:Token 变更必须可 diff、可追溯,必要时能回到上一个主题快照。
问题定义(Problem Statement)
建设一套 Design Token 工程:定义 Token 分层与命名规范;支持暗黑模式与多主题演进; 在组件层提供可复用的样式契约;在工程层提供类型提示、lint 与回归验证; 让 UI 一致性从“靠人记/靠 review”升级为“靠系统约束/可追溯可回滚”。
关键伪代码(读者可复现)
1) Token 中立格式:global/semantic 分开,并允许 alias 引用
一个“能活很久”的 Token 文件,最好是中立格式:不绑定某个 UI 框架,让 Web/Native/导出链路都能消费。 常见做法是用带类型的 JSON,并允许语义 token 引用 global token(alias)。
伪代码:中立 Token(示例结构)
{
"global": {
"gray": {
"960": { "$type": "color", "$value": "#141413" }
},
"radii": {
"md": { "$type": "dimension", "$value": "6px" }
}
},
"semantic": {
"bg": {
"page": { "$type": "color", "$value": "{gray.960}" }
},
"text": {
"fg": { "$type": "color", "$value": "{gray.960}" }
}
}
}
2) Chakra Theme 消费:global 在 foundations,semantic 用 default/_dark 表达
如果你的 Web 技术栈是 Chakra UI,一种非常顺手的落地方式是:
- 把 global token 放在
colors/space/radii/fontSizes/textStyles等 foundations; - 把 semantic token 放在
semanticTokens,用default与_dark做暗黑映射; - 业务组件尽量只用 semantic token(例如
bg.page、text.fgMuted)。
伪代码:semantic colors(示例)
export const semanticColors = {
bg: {
page: { default: 'yellow.20', _dark: 'gray.900' },
panel: { default: 'whiteAlpha.0', _dark: 'gray.950' }
},
text: {
fg: { default: 'gray.960', _dark: 'gray.960' },
fgMuted: { default: 'gray.900', _dark: 'gray.350' }
},
border: {
base: { default: 'gray.200', _dark: 'gray.800' }
}
};
伪代码:组件消费(尽量不写散装值)
function ExampleCard() {
return (
<Box bg="bg.panel" borderColor="border.base" borderRadius="l3" shadow="md">
<Text textStyle="body-md-medium" color="text.fg">Hello</Text>
<Text textStyle="body-sm-normal" color="text.fgMuted">Subtext</Text>
</Box>
);
}
3) 类型提示:把自定义 token(例如 textStyles)变成 IDE 可补全
Token 工程里非常“值回票价”的一件事是:让 token 名称可补全、可类型检查。 否则你的业务代码会出现大量拼写错误(例如 body-md-meduim),并且只能靠运行时/肉眼发现。
如果你在 Chakra 上扩展了 textStyles / semanticTokens, 一个可行做法是用 Chakra CLI 从 theme 文件生成类型定义(具体落点路径可按你的工程调整)。
伪代码:生成主题类型(示例脚本)
# 把 theme 中的 tokens 生成到 Chakra 的类型声明里
npx @chakra-ui/cli tokens your-project/src/styles/theme/index.ts \
--out your-project/node_modules/@chakra-ui/.../theming.types.d.ts
4) 命名约束:用“可读的组合键”替代“随手起名”
以 textStyles 为例,一个可维护的命名方式是把关键信息编码进去:
- 语义:title/body/caption…
- 尺寸:xs/sm/md/…
- 字重:normal/medium/semibold/bold
伪代码:textStyles 键名(示例)
export const textStyles = {
'title-xl-bold': { fontSize: 'xl', lineHeight: 'xl', fontWeight: 'bold', fontFamily: 'heading' },
'body-sm-normal': { fontSize: 'sm', lineHeight: 'sm', fontWeight: 'normal', fontFamily: 'body' }
};
方案与权衡(Solution & Trade-offs)
1) 为什么要 semantic token?为什么不直接用色板/px?
global token(色板/间距/圆角)解决的是“值的统一”,semantic token 解决的是“语义的统一”。 当你要做暗黑模式、品牌升级、甚至 A/B 主题时,直接用色板会让改动面极大: 因为业务代码绑定的是“值”(gray.100),而不是“语义”(bg.subtle)。
semantic token 的核心收益是:你可以在不改业务代码的情况下重映射主题,只需要改“语义到值”的映射表。
2) 组件约束:把“样式自由度”收敛到可维护的变体集合
仅有 token 还不够:如果每个业务页面都可以随意组合 token,你会得到“看似统一、实际千人千面”的 UI。 所以需要在组件层再做一次约束:Button/Card/Input 等核心组件提供有限变体(variants),业务只在变体上选择,不直接拼装细节。
伪代码:Button 变体(示例)
export const Button = defineStyleConfig({
variants: {
primaryFilled: { bg: 'basePrimary.solid', color: 'basePrimary.contrast', shadow: 'xs' },
secondaryOutlined: { bg: 'transparent', borderColor: 'baseSecondary.solid', color: 'baseSecondary.fg' }
}
});
3) “生成类型写进 node_modules”是不是很 hack?
是的,它有明显取舍:优点是见效快、IDE 立刻有提示;缺点是依赖安装目录结构、升级 Chakra 可能需要调整输出路径。 文章想表达的重点不是“必须这么做”,而是类型提示本身要成为工程能力: 你可以用更稳的方式(例如生成到自有的 d.ts 并做 module augmentation),但不要放弃“让 token 名可检查”的护栏。
真实链路:一次 Token 变更如何安全上线?
- 设计提出变更:例如“面板背景更浅、边框更弱、暗黑模式对比度增强”。
- 你先改 semantic token(例如
bg.panel、border.base),避免改动散落到业务代码。 - 如果影响到组件契约,再更新组件 variants(例如 Card/Widget 的 subtle/outline)。
- 跑类型生成:确保新增/重命名 token 在 IDE 可补全,减少拼写错误。
- 跑 lint:阻断新的散装值(新的 hex/px/rgba)进入代码库。
- 做最小验证:抽样关键页面(浅色/深色、移动端/桌面端、营销页/应用页)截图对比。
- 发布:保留变更记录(diff)与回滚手段(回退到上一个 token 版本)。
指标与验证(最小闭环)
- 散装值数量:代码库中 raw hex/rgba/px 的出现次数(目标是持续下降)。
- Token 覆盖率:核心组件(Button/Text/Card/Input)是否全部只使用 semantic/component token。
- 一致性缺陷数:“某处颜色不一致/字号不一致”的线上反馈与 bug 数(目标是下降)。
- 回归成本:一次主题升级需要修改的业务文件数(目标是尽量接近 0,主要集中在 token 映射层)。
通过标准(建议):
- 可发现:token/types 生成成功,IDE 可补全,减少拼写错误与散装值。
- 可约束:新增散装值(hex/rgba/px)能被检索或 lint 发现,且数量趋势下降。
- 可回归:抽样关键页面(Light/Dark、Mobile/Desktop、Marketing/App)无明显视觉回退。
最小可复现检查清单(示例)
# 1) 生成 token 类型提示(让 textStyle/bg/border 等有 IDE 补全)
npm run generate-theme-types
# 2) 搜索散装值(示例:抓 hex 与 rgba)
rg -n \"#[0-9a-fA-F]{3,8}\\b|rgba\\(\" your-project/src
# 3) 抽样跑几个关键页面(或截图回归)
# - Light/Dark
# - Mobile/Desktop
# - Marketing/App
npm run dev
预期与判定(建议):
- 类型提示:生成成功且能补全常用 token;重命名/废弃 token 能在编译期暴露(别靠运行时“看起来没问题”)。
- 散装值:能稳定审计散装值位置;推荐门禁口径是“新增散装值 = 0(或白名单)”,并让总量趋势下降。
- 抽样回归:关键页面在 Light/Dark、Mobile/Desktop 下无明显回退;portal 组件(Modal/Menu 等)不出现主题漂移。
不通过先查:是否存在“同语义多 token 名”导致大家乱用;semantic tokens 是否覆盖了高频语义(bg/text/border); lint 是否只统计但不阻断;以及 token 变更是否缺少“可回滚版本”(发布后很难救火)。
FAQ
Q1:semantic token 会不会太“抽象”,导致大家不知道用哪个?
抽象是成本,但“可演进”是收益。解决方法不是回到散装值,而是把 semantic token 做得可发现: 例如把语义按域分组(bg/text/border/primary/secondary),并配合类型提示与文档示例。
Q2:dark mode 一定要用 default/_dark 这种映射吗?
形式不重要,关键是“暗黑策略必须在语义层收敛”。无论你用 token 映射、CSS variables、还是 theme switch, 都应该避免业务组件手写 if/else 选择颜色。
Q3:组件 variants 会不会限制业务创新?
variants 的目标是限制“无意义的差异”,而不是限制创新。你可以允许“实验型样式”存在,但要有明确边界: 实验成功就进入组件契约,失败就删除;不要让实验样式永久散落在页面里。
Q4:梯度(gradient)、阴影(shadow)这种也要 token 化吗?
建议 token 化。因为它们的“视觉一致性”更脆弱:随手写一个 shadow/gradient,整个系统的质感会立刻变得不统一。 更现实的工程好处是:你可以集中治理“暗黑模式阴影”(例如用 CSS 变量引用统一的 alpha),而不是每处单独调参。
Q5:怎么做 token 的废弃与迁移?
- 先 alias:旧 token 暂时指向新 token(保持业务不改也不坏)。
- 再告警:lint 标记旧 token 的使用位置(但不立刻阻断)。
- 最后删除:迁移完成后移除旧 token,并在变更日志里写清影响面。
Q6:字体也是 token 的一部分吗?为什么还要写“下载字体脚本”?
字体不只是 CSS 名字,它还涉及真实字体文件与跨端一致性(例如导出链路要做字体度量/子集化)。 所以“字体列表 + 字体文件获取”也应该纳入 token 工程:它决定了你的排版是否真的可复现。
Q7:token 变更怎么灰度/回滚才安全?
把 token 当作“产品 API”对待:变更必须可 diff、可追溯、可回滚。最小可行做法是:semantic token 版本化(或快照化)+ 一套抽样回归页面; 灰度时优先让少量流量命中新主题,指标异常就切回旧版本。
Q8:semantic token 命名怎么避免越长越乱?
先分域再定粒度:域用 bg/text/border/icon 这类稳定集合,粒度保持“可复用语义”而不是“页面专属语义”。 新增 token 的门槛应该高于“随手写一个值”:需要说明它对应的场景、是否有现有 token 可复用、以及暗黑映射策略。