暗色主题上线后,十七个页面出现透明按钮
某会员中心同时跑在 Web、iOS、Android 和小程序四个端上,共享 43 个组件 token、3 套主题。暗色主题的改动只写了 5 行覆盖:画布、正文、边框、主操作色和反色文字。评审时看起来风险很低,因为「只改了颜色」。
上线后的问题却很具体:会员权益页的次要按钮文字看不见,订单详情的角标变成白底白字,设置页的分组标题对比度只有 1.6:1。设计走查统计出 17 个页面不达标,而四个端表现还不一样:Web 是透明文字,iOS 是继承父级颜色,Android 直接用了默认黑色,小程序因为不支持某个 CSS 变量而退回透明。
复盘发现根因不是某个颜色值写错,而是三条解析链断了:
- 组件 token
button.text.default直接写了#ffffff,绕过了语义层color.text.inverse,暗色覆盖对它无效。 - 主题覆盖表里有一条
button.text.on-brand,但组件库里没有这个 token,解析结果为空,CSS 输出了color: ;。 - 两个语义 token 互相别名,亮色主题下恰好命中缓存,暗色主题首次解析就进入死循环,构建脚本超时。
三条链分别对应三个能力:分层、缺失回退、环检测。缺一个,主题化就只能靠人肉扫页面。
三层 token 不是三个文件夹
很多团队把 token 分成三个 JSON 文件,就认为完成了分层。真正的分层约束是「谁能引用谁」:
| 层级 | 定义什么 | 允许引用 | 禁止 |
|---|---|---|---|
primitive | 原始值:色值、间距、字号、圆角 | 不引用其他 token | 承载业务语义 |
semantic | 角色意图:画布、正文、主操作、边框 | primitive | 绑定某个组件 |
component | 组件对外暴露的样式接口 | semantic,必要时 primitive | 绕过语义层写死品牌色 |
一个典型链路是 button.bg.default -> color.action.primary -> color.blue.500 -> #3b82f6。组件层只表达「按钮默认背景」,语义层表达「主操作」,原始层才是具体色值。品牌换色时只改语义层或原始层,组件层不动;暗色主题只换语义映射,组件层也不动。
反例是把组件层写成 button.bg.default = #3b82f6。它看起来少了一层,实际把品牌色固化进了组件 API。第二个品牌接入时,这个 token 必须被覆盖;第三个品牌接入时,覆盖表开始互相冲突。
分层的判据不是文件数量,而是删除任意一层后,另外两层是否还能独立演化。如果删掉语义层后组件层立刻失效,说明语义层只是摆设。
解析链与覆盖优先级
解析一个 token 时,按别名一路走到底,每到一个节点先看当前主题是否覆盖了它。覆盖也按层级存放,因此「更具体的层」天然先被命中:
| 覆盖位置 | 生效时机 | 影响范围 | 例子 |
|---|---|---|---|
| component 覆盖 | 解析从组件 token 开始时最先命中 | 单个组件 | 暗色下按钮背景改用 color.blue.500 |
| semantic 覆盖 | 组件 token 没有覆盖、继续走别名时命中 | 一组角色 | 暗色下画布改为 color.gray.900 |
| primitive 覆盖 | 语义层也没有覆盖、走到原始值前命中 | 全局原始值 | 高对比下把蓝色替换为高饱和值 |
覆盖不是「后写覆盖先写」,而是「先命中的层胜出」。这能解释一个常见疑问:为什么改了语义层的主操作色,按钮却没变?因为暗色主题在组件层给按钮写了覆盖,解析在组件层就停了。要判断一次覆盖为什么没生效,必须把解析链打印出来,而不是盯着覆盖表猜。
覆盖表还要按主题隔离。light、dark、high-contrast 各自维护,不能用一个 dark 布尔值在运行时拼字符串。主题是有限的枚举,有限枚举才能被穷举测试。
把解析链写成代码
下面这个模块把三层定义、主题覆盖、别名解析、环检测和缺失回退都做成纯函数。它不依赖 DOM,也不依赖任何第三方包,可以直接在构建脚本里跑:
// file: src/design/design-tokens.ts
// tier: L1
// verify: node --test test/design/design-tokens.test.ts
export type TokenTier = "primitive" | "semantic" | "component";
export type ThemeName = "light" | "dark" | "high-contrast";
export type TokenDef = {
name: string;
tier: TokenTier;
value?: string;
alias?: string;
};
export type ThemeOverrides = Record<
ThemeName,
Partial<Record<TokenTier, Record<string, string>>>
>;
export type ResolveOk = {
ok: true;
value: string;
tier: TokenTier;
chain: string[];
};
export type ResolveFail = {
ok: false;
reason: "missing" | "cycle" | "no-value";
chain: string[];
};
export type ResolveResult = ResolveOk | ResolveFail;
export const PRIMITIVE_TOKENS: TokenDef[] = [
{ name: "color.blue.500", tier: "primitive", value: "#3b82f6" },
{ name: "color.blue.700", tier: "primitive", value: "#1d4ed8" },
{ name: "color.gray.50", tier: "primitive", value: "#f9fafb" },
{ name: "color.gray.700", tier: "primitive", value: "#374151" },
{ name: "color.gray.900", tier: "primitive", value: "#111827" },
{ name: "color.white", tier: "primitive", value: "#ffffff" },
{ name: "color.black", tier: "primitive", value: "#000000" },
{ name: "color.yellow.400", tier: "primitive", value: "#facc15" },
{ name: "space.2", tier: "primitive", value: "8px" },
{ name: "space.4", tier: "primitive", value: "16px" },
{ name: "radius.sm", tier: "primitive", value: "4px" },
{ name: "radius.none", tier: "primitive", value: "0px" },
{ name: "font.body", tier: "primitive", value: "16px" },
];
export const SEMANTIC_TOKENS: TokenDef[] = [
{ name: "color.bg.canvas", tier: "semantic", alias: "color.white" },
{ name: "color.text.primary", tier: "semantic", alias: "color.gray.900" },
{ name: "color.text.inverse", tier: "semantic", alias: "color.white" },
{ name: "color.action.primary", tier: "semantic", alias: "color.blue.500" },
{ name: "color.border.default", tier: "semantic", alias: "color.gray.700" },
{ name: "space.gap.md", tier: "semantic", alias: "space.4" },
{ name: "font.text.body", tier: "semantic", alias: "font.body" },
];
export const COMPONENT_TOKENS: TokenDef[] = [
{ name: "button.bg.default", tier: "component", alias: "color.action.primary" },
{ name: "button.text.default", tier: "component", alias: "color.text.inverse" },
{ name: "button.bg.hover", tier: "component", alias: "color.blue.700" },
{ name: "button.radius", tier: "component", alias: "radius.sm" },
{ name: "button.height.md", tier: "component", alias: "space.4" },
{ name: "button.padding.inline", tier: "component", alias: "space.gap.md" },
{ name: "button.font.size", tier: "component", alias: "font.text.body" },
{ name: "card.bg.default", tier: "component", alias: "color.bg.canvas" },
];
export const ALL_TOKENS: TokenDef[] = [
...PRIMITIVE_TOKENS,
...SEMANTIC_TOKENS,
...COMPONENT_TOKENS,
];
export const EMPTY_OVERRIDES: ThemeOverrides = {
light: {},
dark: {},
"high-contrast": {},
};
export const THEME_OVERRIDES: ThemeOverrides = {
light: {},
dark: {
semantic: {
"color.bg.canvas": "color.gray.900",
"color.text.primary": "color.gray.50",
"color.text.inverse": "color.black",
"color.border.default": "color.gray.700",
"color.action.primary": "color.blue.700",
},
component: {
"button.bg.default": "color.blue.500",
"button.bg.hover": "color.blue.500",
},
},
"high-contrast": {
semantic: {
"color.bg.canvas": "color.black",
"color.text.primary": "color.white",
"color.text.inverse": "color.black",
"color.action.primary": "color.yellow.400",
"color.border.default": "color.white",
},
component: {
"button.radius": "radius.none",
},
},
};
export function tokenIndex(defs: TokenDef[] = ALL_TOKENS): Map<string, TokenDef> {
const index = new Map<string, TokenDef>();
for (const def of defs) {
index.set(def.name, def);
}
return index;
}
export function resolveToken(
name: string,
theme: ThemeName = "light",
defs: TokenDef[] = ALL_TOKENS,
overrides: ThemeOverrides = THEME_OVERRIDES,
): ResolveResult {
const index = tokenIndex(defs);
const chain: string[] = [];
const seen = new Set<string>();
let current = name;
while (true) {
if (seen.has(current)) {
return { ok: false, reason: "cycle", chain: [...chain, current] };
}
seen.add(current);
chain.push(current);
const def = index.get(current);
if (def === undefined) {
return { ok: false, reason: "missing", chain };
}
const override = overrides[theme]?.[def.tier]?.[current];
if (override !== undefined) {
if (index.has(override)) {
current = override;
continue;
}
return { ok: true, value: override, tier: def.tier, chain };
}
if (def.value !== undefined) {
return { ok: true, value: def.value, tier: def.tier, chain };
}
if (def.alias !== undefined) {
if (!index.has(def.alias)) {
return { ok: false, reason: "missing", chain: [...chain, def.alias] };
}
current = def.alias;
continue;
}
return { ok: false, reason: "no-value", chain };
}
}
export function resolveOrDefault(
name: string,
fallback: string,
theme: ThemeName = "light",
): string {
const result = resolveToken(name, theme);
return result.ok ? result.value : fallback;
}
export function overrideLayer(
name: string,
theme: ThemeName,
defs: TokenDef[] = ALL_TOKENS,
overrides: ThemeOverrides = THEME_OVERRIDES,
): TokenTier | null {
const def = tokenIndex(defs).get(name);
if (def === undefined) {
return null;
}
const value = overrides[theme]?.[def.tier]?.[name];
return value === undefined ? null : def.tier;
}
export function detectCycle(defs: TokenDef[] = ALL_TOKENS): string[] | null {
const index = tokenIndex(defs);
const state = new Map<string, "visiting" | "done">();
const stack: string[] = [];
function visit(name: string): string[] | null {
const status = state.get(name);
if (status === "visiting") {
const start = stack.indexOf(name);
return [...stack.slice(start), name];
}
if (status === "done") {
return null;
}
state.set(name, "visiting");
stack.push(name);
const target = index.get(name)?.alias;
if (target !== undefined && index.has(target)) {
const found = visit(target);
if (found !== null) {
return found;
}
}
stack.pop();
state.set(name, "done");
return null;
}
for (const def of defs) {
const found = visit(def.name);
if (found !== null) {
return found;
}
}
return null;
}
export function duplicateNames(defs: TokenDef[] = ALL_TOKENS): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const def of defs) {
if (seen.has(def.name)) {
duplicates.add(def.name);
}
seen.add(def.name);
}
return [...duplicates].sort();
}
export function themeSnapshot(theme: ThemeName): Record<string, string> {
const snapshot: Record<string, string> = {};
for (const def of COMPONENT_TOKENS) {
const result = resolveToken(def.name, theme);
snapshot[def.name] = result.ok ? result.value : `unresolved:${result.reason}`;
}
return snapshot;
}
模块里有三个值得注意的决策。
第一,别名目标不存在时返回 missing,而不是把目标名当字面值返回。后者会让 color: ghost.token 这类非法 CSS 悄悄进入产物。
第二,覆盖值先查索引:命中已知 token 就继续沿链解析,否则当作字面值。这样覆盖表既能写 color.blue.700,也能写 #000000,但两者不会混淆。
第三,环检测用三色标记而不是递归深度上限。深度上限只能告诉你「太深了」,三色标记能给出闭环路径,直接定位到哪两个 token 互相引用。
判据与反例
接入主题前,用下面五条判据检查,而不是靠截图对比:
- 全量可解析。 三个主题下每个组件 token 都能解析出值,不能出现
unresolved。 - 无环。 构建期
detectCycle必须返回null;返回路径时直接失败,不允许运行时兜底。 - 缺失显式。 解析失败返回结构化原因,UI 使用明确的回退值,不能输出空字符串。
- 层级纯净。 组件层不出现未登记的十六进制字面值;出现即视为绕过语义层。
- 覆盖可解释。 任意 token 的最终值都能打印出完整解析链,评审时能指出是哪一层覆盖的。
一个常见反例是「用 CSS 变量名当 token 名」。比如把 token 命名为 --brand-primary-dark-hover,名字里同时编码了品牌、主题和状态。这样的 token 无法被三个主题复用,覆盖表也会迅速膨胀。token 名应该描述角色,主题差异放进覆盖表,而不是名字里。
另一个反例是「只测亮色」。亮色是默认主题,绝大多数 token 在亮色下都能解析,测试通过率没有信息量。真正会暴露问题的是暗色和高对比:覆盖写错、别名指向不存在的 token、对比度不足,都在这两套主题下出现。
小结
主题化不是换一组颜色,而是维护一条可解释的解析链。
- 三层 token 的约束是引用方向:primitive 不引用,semantic 引用 primitive,component 引用 semantic。
- 覆盖优先级由解析链决定:先命中的层胜出,组件层覆盖会短路语义层。
- 环检测和缺失回退必须在构建期完成,不能留给运行时。
先把解析链跑通,再谈主题数量。链断了,主题越多,出错页面越多。
阅读导航:上一节:1.3 能力探测与渐进增强 · 下一节:2.2 组件契约与版本化 。
常见错误与边界
- 组件层直接写十六进制。 单品牌时看不出问题,第二品牌接入时必须逐组件覆盖,成本随组件数线性增长。
- 覆盖表引用不存在的 token。 解析返回缺失,CSS 得到空值;浏览器不会报错,只会静默继承或透明。
- 别名环只在运行时暴露。 亮色路径可能恰好不经过环,暗色首次解析才死循环,构建期检测更可靠。
- 缺失时回退到
inherit。 继承值取决于父级,跨端表现不可预测;应回退到明确的哨兵值并在测试中断言。 - 主题只覆盖颜色。 间距、圆角、字号在暗色下同样可能需要调整,覆盖表要按 token 类型而不是按肉眼可见度决定。
- 把主题写成运行时字符串拼接。
dark-${brand}这类名字无法穷举,测试只能覆盖部分组合;主题应是有限枚举。 - 用组件名组织覆盖表。 覆盖应以 token 名为键,组件名会随重构变化,导致覆盖失效却无人察觉。
- 只验证亮色主题。 亮色通过率不说明问题,暗色与高对比才是解析链和对比度的压力测试。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。