设计系统 Token 工程化:从定义到 CI 校验的完整链路
一、Token 管理的混乱现状
设计 Token 是设计系统的最小原子单元。颜色、间距、字体、圆角等都属于 Token。在团队规模扩大后,Token 管理会出现三个典型问题:
- 定义分散。设计师在 Figma 定义了一套,开发在代码里维护了另一套,两边不同步。
- 使用混乱。开发者直接写硬编码颜色值,Token 形同虚设。
- 变更无追溯。设计师改了主色,开发不知情,上线后发现色差。
这些问题的根因是 Token 缺少一个单一的源(Single Source of Truth)和自动化校验机制。一个不成体系的 Token 管理方式,在 3 人以下团队勉强可用。超过 5 人时,维护成本指数级上升。
flowchart TB subgraph 混乱状态 A1[Figma 本地样式] --> A2[手动导出] A2 --> A3[开发手动录入代码] A3 --> A4[各组件独立使用] A4 --> A5[不一致的设计呈现] end subgraph 理想状态 B1[Token 定义文件 - JSON/YAML] --> B2[CI 校验] B1 --> B3[生成 CSS 变量] B1 --> B4[生成 Figma 插件数据] B2 --> B5[阻止不合规代码合入] B3 --> B6[各组件统一引用] B4 --> B6 B6 --> B7[一致性设计呈现] end二、Token 的分层定义策略
Token 的定义需要分层。单一层级的 Token 无法同时满足语义化和灵活性需求。
采用三层 Token 体系:
原始 Token(Primitive Token):最底层的原子值,不包含语义。例如#1a73e8、16px、0.5。
语义 Token(Semantic Token):将原始值赋予业务含义。例如color-primary: {primitive.blue.500}。
组件 Token(Component Token):针对具体组件的 Token。例如button-primary-bg: {semantic.color-primary}。
flowchart LR subgraph 原始层 P1[blue-500: #1a73e8] P2[spacing-4: 16px] P3[radius-md: 8px] end subgraph 语义层 S1[color-primary → blue-500] S2[spacing-component-gap → spacing-4] S3[radius-button → radius-md] end subgraph 组件层 C1[Button: bg → color-primary] C2[Card: padding → spacing-component-gap] C3[Input: radius → radius-button] end P1 --> S1 P2 --> S2 P3 --> S3 S1 --> C1 S2 --> C2 S3 --> C3定义文件推荐使用 JSON 格式,便于程序化处理和 CI 解析:
{ "primitive": { "color": { "blue": { "50": "#e8f0fe", "100": "#d2e3fc", "500": "#1a73e8", "700": "#1967d2" }, "neutral": { "0": "#ffffff", "100": "#f8f9fa", "900": "#202124" } }, "spacing": { "1": "4px", "2": "8px", "4": "16px", "6": "24px", "8": "32px" }, "radius": { "sm": "4px", "md": "8px", "lg": "12px", "full": "9999px" } }, "semantic": { "color": { "primary": "{primitive.color.blue.500}", "primary-hover": "{primitive.color.blue.700}", "background": "{primitive.color.neutral.0}", "text-primary": "{primitive.color.neutral.900}" }, "spacing": { "component-gap": "{primitive.spacing.4}", "section-padding": "{primitive.spacing.8}" } }, "component": { "button": { "bg-primary": "{semantic.color.primary}", "bg-primary-hover": "{semantic.color.primary-hover}", "radius": "{primitive.radius.md}" } } }三层分层的实际收益
没有分层时,如果需要将主色调从蓝色改为绿色,需要修改所有引用了蓝色的组件 Token。有了三层分层,只需修改语义 Token 的引用指向:
// 修改前 "semantic": { "color": { "primary": "{primitive.color.blue.500}", "primary-hover": "{primitive.color.blue.700}" } } // 修改后(只需改两行) "semantic": { "color": { "primary": "{primitive.color.green.500}", "primary-hover": "{primitive.color.green.700}" } }所有组件自动继承新的主色,无需逐个修改组件 Token。这对主题切换(暗黑模式、品牌色切换)场景尤其有价值。例如,实现暗黑模式只需准备两套语义 Token(light 和 dark),组件层无需任何修改。
实际项目中的 Token 组织策略
在超过 50 个组件的中型项目中,Token 定义文件可能超过 500 行。推荐按模块拆分到多个文件:
tokens/ primitive.json # 原始 Token semantic.json # 语义 Token components/ button.json # 按钮组件 Token input.json # 输入框组件 Token card.json # 卡片组件 Token这种按文件拆分的策略,让不同开发者可以并行修改不同组件的 Token,避免 Git 冲突。
三、CI 校验管道的实现
Token 定义文件是整个系统的核心资产。任何对它的修改都必须经过自动化校验。
校验规则设计:
引用完整性。语义 Token 引用的原始 Token 必须存在,组件 Token 引用的语义 Token 必须存在。不允许悬空引用。
值类型一致。色值引用不能指向间距值。例如
button-bg引用的 Token 必须是颜色类型。命名规范。Token 名称必须符合
[category]-[property]-[variant]格式(kebab-case)。禁止未使用的 Token。定义了但从未被任何组件引用的 Token 应标记为告警。
校验规则的实际实现细节
引用完整性校验是最关键的规则。实现时需要注意循环引用的问题:语义 Token A 引用了语义 Token B,而语义 Token B 又引用了语义 Token A。这种情况会导致解析时无限递归。需要在解析方法中加入 visited 集合来检测循环引用:
function resolveTokenValue( value: string, tokens: TokenDefinition, visited: Set<string> = new Set() ): string { const ref = parseReference(value); if (!ref) return value; // 检测循环引用 if (visited.has(value)) { throw new Error(`检测到循环引用: ${value}`); } visited.add(value); if (ref.type === 'primitive') { return resolvePrimitive(tokens.primitive, ref.path) ?? value; } if (ref.type === 'semantic') { const semanticValue = resolveSemantic(tokens.semantic, ref.path); if (!semanticValue) return value; return resolveTokenValue(semanticValue, tokens, visited); } return value; }值类型一致性校验需要为原始 Token 增加类型标注。修改 Token 定义文件的结构:
{ "primitive": { "color": { "blue": { "500": { "value": "#1a73e8", "type": "color" } } }, "spacing": { "4": { "value": "16px", "type": "spacing" } } } }在校验时,检查组件 Token 引用的语义 Token 的类型是否匹配。例如,按钮的bg属性必须引用类型为color的 Token。
未使用 Token 检测需要配合构建流程。在 CI 中,先生成 CSS 变量文件,然后使用 Stylelint 或 PostCSS 插件扫描所有组件的样式文件,提取实际使用的 CSS 变量列表,与生成的变量列表对比。未被引用的 Token 产生 warning 级别告警。
function findUnusedTokens( tokens: TokenDefinition, usedVariables: Set<string> ): ValidationError[] { const errors: ValidationError[] = []; const allGeneratedVars = generateCSSVariables(tokens); for (const cssVar of allGeneratedVars) { if (!usedVariables.has(cssVar)) { errors.push({ path: cssVar, message: `Token "${cssVar}" 未被任何组件使用`, severity: 'warning', }); } } return errors; }三、CI 校验管道的实现
Token 定义文件是整个系统的核心资产。任何对它的修改都必须经过自动化校验。
校验规则设计:
引用完整性。语义 Token 引用的原始 Token 必须存在,组件 Token 引用的语义 Token 必须存在。不允许悬空引用。
值类型一致。色值引用不能指向间距值。例如
button-bg引用的 Token 必须是颜色类型。命名规范。Token 名称必须符合
[category]-[property]-[variant]格式(kebab-case)。禁止未使用的 Token。定义了但从未被任何组件引用的 Token 应标记为告警。
import { readFileSync } from 'fs'; interface TokenDefinition { primitive: Record<string, Record<string, Record<string, string>>>; semantic: Record<string, Record<string, string>>; component: Record<string, Record<string, string>>; } interface ValidationError { path: string; message: string; severity: 'error' | 'warning'; } function validateTokenReferences(tokens: TokenDefinition): ValidationError[] { const errors: ValidationError[] = []; // 校验语义 Token 引用 for (const [category, values] of Object.entries(tokens.semantic)) { for (const [key, value] of Object.entries(values)) { const ref = parseReference(value); if (!ref) continue; const resolved = resolvePrimitive(tokens.primitive, ref.path); if (!resolved) { errors.push({ path: `semantic.${category}.${key}`, message: `引用的原始 Token "${ref.raw}" 不存在`, severity: 'error', }); } } } // 校验组件 Token 引用 for (const [component, values] of Object.entries(tokens.component)) { for (const [key, value] of Object.entries(values)) { const ref = parseReference(value); if (!ref) { // 也允许直接使用原始值 continue; } if (ref.type === 'semantic') { const resolved = resolveSemantic(tokens.semantic, ref.path); if (!resolved) { errors.push({ path: `component.${component}.${key}`, message: `引用的语义 Token "${ref.raw}" 不存在`, severity: 'error', }); } } } } return errors; } function parseReference(value: string): { type: string; path: string; raw: string } | null { const match = value.match(/^\{(\w+)\.(.+)\}$/); if (!match) return null; return { type: match[1], path: match[2], raw: value }; } function resolvePrimitive( primitives: TokenDefinition['primitive'], path: string ): string | null { const parts = path.split('.'); let current: unknown = primitives; for (const part of parts) { if (typeof current !== 'object' || current === null) return null; current = (current as Record<string, unknown>)[part]; } return typeof current === 'string' ? current : null; } function resolveSemantic( semantics: TokenDefinition['semantic'], path: string ): string | null { const parts = path.split('.'); let current: unknown = semantics; for (const part of parts) { if (typeof current !== 'object' || current === null) return null; current = (current as Record<string, unknown>)[part]; } return typeof current === 'string' ? current : null; } // 主入口 function main(): void { try { const raw = readFileSync('tokens.json', 'utf-8'); const tokens: TokenDefinition = JSON.parse(raw); const errors = validateTokenReferences(tokens); if (errors.filter(e => e.severity === 'error').length > 0) { console.error('Token 校验失败:'); errors.forEach(e => console.error(` [${e.severity}] ${e.path}: ${e.message}`)); process.exit(1); } const warnings = errors.filter(e => e.severity === 'warning'); if (warnings.length > 0) { console.warn('Token 校验告警:'); warnings.forEach(w => console.warn(` [${w.severity}] ${w.path}: ${w.message}`)); } console.log('Token 校验通过'); } catch (error) { console.error('Token 文件解析失败:', error instanceof Error ? error.message : error); process.exit(1); } } main();在 CI 管线中集成:
# .github/workflows/token-check.yml name: Token Validation on: pull_request: paths: - 'tokens.json' jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - name: Validate Tokens run: npx tsx scripts/validate-tokens.ts四、生成产物与开发体验
Token 定义文件校验通过后,下一步是生成各平台可用的产物。
CSS 变量生成:
function generateCSSVariables(tokens: TokenDefinition): string { const lines: string[] = [':root {']; for (const [category, values] of Object.entries(tokens.semantic)) { for (const [key, value] of Object.entries(values)) { const cssVar = `--${category}-${key}`; const resolved = resolveTokenValue(value, tokens); lines.push(` ${cssVar}: ${resolved};`); } } lines.push('}'); return lines.join('\n'); } function resolveTokenValue(value: string, tokens: TokenDefinition): string { const ref = parseReference(value); if (!ref) return value; if (ref.type === 'primitive') { return resolvePrimitive(tokens.primitive, ref.path) ?? value; } if (ref.type === 'semantic') { const semanticValue = resolveSemantic(tokens.semantic, ref.path); if (!semanticValue) return value; return resolveTokenValue(semanticValue, tokens); } return value; }生成结果示例:
:root { --color-primary: #1a73e8; --color-primary-hover: #1967d2; --color-background: #ffffff; --color-text-primary: #202124; --spacing-component-gap: 16px; --spacing-section-padding: 32px; }生成的 CSS 变量文件作为构建产物输出,前端代码中统一通过 CSS 变量引用,而非直接使用硬编码值。这样在 Token 变更时,只需重新生成 CSS 变量文件即可全局生效。
为确保开发者不使用硬编码值,可以配合 Stylelint 规则:
// stylelint.config.js module.exports = { rules: { 'color-no-hex': [true, { severity: 'warning', }], 'declaration-property-value-disallowed-list': { '/^color/': ['/^#[0-9a-fA-F]{3,8}$/'], '/^background/': ['/^#[0-9a-fA-F]{3,8}$/'], }, }, };五、总结
Token 工程化的核心是建立单一数据源和自动化校验链路。三层 Token 体系(原始层、语义层、组件层)兼顾了灵活性和语义化。CI 校验确保引用完整性和类型一致性,防止悬空引用和类型错配。配合 CSS 变量生成和 Stylelint 规则,可以强制开发者使用 Token 而非硬编码值。这套方案的投入约 2-3 天,对 5 人以上团队的设计一致性有明显收益。