Design Token 工程:语义化 Token、主题扩展与组件约束,如何让 UI 一致性变成“系统能力”

8977
2026-02-28 15:23
18 小时前

Design Token 工程:语义化 Token、主题扩展与组件约束,如何让 UI 一致性变成“系统能力”

绝大多数团队的 UI 一致性问题,表面看是“颜色不统一/圆角不统一/字号不统一”,本质是工程缺少一个可执行的契约: 到底哪些值可以自由写,哪些必须从系统里取?

如果你在 code review 里经常讨论“这个 #fff 到底能不能写”“这个 12px 是不是又散装了”,基本就说明:你缺的不是审美,而是系统约束。

如果没有 Token 工程,你会在代码里看到三种典型症状:

  • 散装值:到处都是 #fff12pxrgba(...),同一语义用 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.pagetext.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 名可检查”的护栏。

Design Token 工程流水线:Token 源(JSON/TS)→ 生成 Theme/semanticTokens → 生成类型提示 → lint 阻断散装值 → 视觉回归与抽样页面验证 → 发布与可回滚
图:Token 不是“配色表”,它需要一条工程流水线来承载演进。

真实链路:一次 Token 变更如何安全上线?

  1. 设计提出变更:例如“面板背景更浅、边框更弱、暗黑模式对比度增强”。
  2. 你先改 semantic token(例如 bg.panelborder.base),避免改动散落到业务代码。
  3. 如果影响到组件契约,再更新组件 variants(例如 Card/Widget 的 subtle/outline)。
  4. 跑类型生成:确保新增/重命名 token 在 IDE 可补全,减少拼写错误。
  5. 跑 lint:阻断新的散装值(新的 hex/px/rgba)进入代码库。
  6. 做最小验证:抽样关键页面(浅色/深色、移动端/桌面端、营销页/应用页)截图对比。
  7. 发布:保留变更记录(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 可复用、以及暗黑映射策略。