《互联网产品的客户端架构·规模化与架构演进》2.1 设计 token 与主题化

同一条按钮 token 在暗色主题下解析成透明文字,往往不是样式写错,而是三层 token 的解析链断了。本节用可运行的 TypeScript 模块演示 primitive、semantic、component 三层解析、主题覆盖优先级、别名环检测与缺失回退,并给出组件库接入主题前的验收判据。

暗色主题上线后,十七个页面出现透明按钮

某会员中心同时跑在 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 互相引用。

判据与反例

接入主题前,用下面五条判据检查,而不是靠截图对比:

  1. 全量可解析。 三个主题下每个组件 token 都能解析出值,不能出现 unresolved。
  2. 无环。 构建期 detectCycle 必须返回 null;返回路径时直接失败,不允许运行时兜底。
  3. 缺失显式。 解析失败返回结构化原因,UI 使用明确的回退值,不能输出空字符串。
  4. 层级纯净。 组件层不出现未登记的十六进制字面值;出现即视为绕过语义层。
  5. 覆盖可解释。 任意 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 名为键,组件名会随重构变化,导致覆盖失效却无人察觉。
  • 只验证亮色主题。 亮色通过率不说明问题,暗色与高对比才是解析链和对比度的压力测试。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查