《互联网产品的客户端架构·规模化与架构演进》2.3 多品牌与多业务复用

四个品牌共用一套组件库,一次全量合并后,A 品牌的结算主色出现在 B 品牌的收银台,排查发现是品牌包被当成全局配置加载。多品牌复用的难点不是覆盖,而是边界与隔离。本节用 TypeScript 模块演示覆盖顺序、业务包与品牌包的权限边界、同层冲突检测和复用度计算,并给出多品牌接入前的验收判据。

四个品牌合并发布,主色跑到了别人的收银台

某集团有四个面向不同人群的品牌,共用一套 27 个组件的客户端组件库,下面挂着 12 个业务包:结算、会员、搜索、客服等。品牌差异通过 token 覆盖表达,业务差异通过业务包表达。

一次大版本合并时,构建脚本把 4 个品牌包和 12 个业务包一次性加载,生成了单一主题产物。上线后出现三个症状:

  • A 品牌的紫色结算按钮出现在 B 品牌的收银台,两个品牌的转化率同时异常。
  • 两个业务包都给 business.checkout.accent 赋了值,构建没有报错,后加载的那个静默胜出。
  • 复盘时想算「组件复用度」,发现没人能说清 27 个组件里哪些被至少两个业务使用,哪些已经被复制成私有实现。

三个症状指向同一件事:覆盖被当成了合并,边界被当成了约定。多品牌复用要解决的不是「怎么覆盖」,而是「谁有权覆盖谁、覆盖冲突怎么办、复用程度如何度量」。

四层覆盖顺序

覆盖不是把所有包平铺后按加载顺序覆盖。加载顺序受文件系统、构建工具和并发影响,今天对的顺序明天可能变。更可靠的是显式分层:

顺序层包的特征作用允许覆盖
1平台基线组件库默认值无品牌时的兜底无
2共享品牌brand: "shared" 的品牌包集团级公共调整平台基线
3品牌特定brand: "aurora" 的品牌包单品牌识别共享品牌
4共享业务brand: "shared" 的业务包跨品牌业务约定品牌层
5业务特定brand: "aurora" 的业务包单品牌单业务差异品牌层、共享业务

顺序由代码里的 rank 决定,不由包数组顺序决定。同一个包无论出现在数组的哪个位置,rank 相同,结果一致。这是可复现构建的前提。

品牌隔离也由这张表保证:合并 aurora 时只选择 brand === "aurora" 或 brand === "shared" 的包,nebula 的包根本不进入候选集。不是「加载后覆盖掉」,而是「一开始就不加载」。

品牌包与业务包的边界

如果两类包都能改任何 token,边界就名存实亡。边界要落到 token 层级上:

包类型可覆盖 semantic可覆盖 component可覆盖 primitive可新增 business.*
品牌包是是否否
业务包否是否是

品牌包代表品牌识别,可以改主操作色、画布、圆角;但不能改原始色板,否则同一品牌在不同组件里会出现无法解释的色差。

业务包代表局部需求,可以改组件尺寸、密度和业务专属 token;但不能改语义层的主操作色,否则业务会劫持品牌。业务专属 token 统一放在 business. 命名空间下,避免和组件库的 token 撞名。

校验必须发生在合并前。非法包不是「警告后继续」,而是「拒绝并入并报告原因」。一个业务包偷偷改了 color.action.primary,如果只警告,问题会在下一个品牌接入时才爆发。

冲突与复用度

同层不同值的覆盖是冲突,必须报告;不同层的覆盖是预期行为,只记录被谁覆盖。两类信息要分开,否则日志会被正常覆盖淹没:

情况处理记录
不同 rank,值不同高层胜出shadowed:谁覆盖了谁
同 rank,值相同幂等,保留一次不记录
同 rank,值不同冲突,保留先应用者并报告conflicts:所有竞争者
非法 token 或非法层级拒绝整个包rejected:具体错误

冲突保留「先应用者」而不是「后应用者」,是为了让结果确定:包按 id 排序,id 小者先应用。冲突本身仍然要求人工解决,确定性只是让构建可复现,不是让冲突合法化。

复用度则回答另一个问题:哪些组件值得留在平台,哪些应该下放或分叉。用「使用该组件的业务数 / 总业务数」计算复用度,低于阈值的组件进入分叉候选,高于阈值的组件必须保持向后兼容。

把合并与复用写成代码

下面这个模块把分层、边界校验、冲突报告、品牌隔离和复用度计算都做成纯函数:

// file: src/design/multi-brand.ts
// tier: L1
// verify: node --test test/design/multi-brand.test.ts

import { ALL_TOKENS } from "./design-tokens.ts";
import type { TokenTier } from "./design-tokens.ts";

export type PackageKind = "brand" | "business";
export type LayerName = "brand" | "business";

export type BrandPackage = {
  id: string;
  brand: string;
  kind: PackageKind;
  overrides: Record<string, string>;
};
export type MergeConflict = {
  token: string;
  layer: LayerName;
  contenders: { packageId: string; value: string }[];
};
export type ShadowedOverride = { token: string; from: string; by: string };
export type RejectedPackage = { packageId: string; errors: string[] };
export type MergeResult = {
  brand: string;
  values: Record<string, string>;
  applied: string[];
  rejected: RejectedPackage[];
  conflicts: MergeConflict[];
  shadowed: ShadowedOverride[];
};
export type UsageRecord = { business: string; component: string };
export type ReuseScore = { component: string; businesses: string[]; reuse: number };

export const PLATFORM_BASE: Record<string, string> = {
  "color.bg.canvas": "#ffffff",
  "color.text.primary": "#111827",
  "color.action.primary": "#3b82f6",
  "button.radius": "4px",
  "button.height.md": "40px",
  "business.checkout.accent": "#3b82f6",
  "business.profile.accent": "#3b82f6",
};

export const SAMPLE_PACKAGES: BrandPackage[] = [
  {
    id: "brand-shared",
    brand: "shared",
    kind: "brand",
    overrides: { "button.radius": "6px" },
  },
  {
    id: "brand-aurora",
    brand: "aurora",
    kind: "brand",
    overrides: {
      "color.action.primary": "#7c3aed",
      "button.radius": "8px",
      "button.height.md": "48px",
    },
  },
  {
    id: "brand-nebula",
    brand: "nebula",
    kind: "brand",
    overrides: { "color.action.primary": "#0ea5e9" },
  },
  {
    id: "aurora-checkout",
    brand: "aurora",
    kind: "business",
    overrides: {
      "business.checkout.accent": "#7c3aed",
      "button.height.md": "44px",
    },
  },
  {
    id: "aurora-profile",
    brand: "aurora",
    kind: "business",
    overrides: { "business.profile.accent": "#7c3aed" },
  },
  {
    id: "nebula-checkout",
    brand: "nebula",
    kind: "business",
    overrides: { "business.checkout.accent": "#0ea5e9" },
  },
];

const TIER_BY_NAME: Map<string, TokenTier> = new Map(
  ALL_TOKENS.map((token) => [token.name, token.tier]),
);

export function packageRank(pkg: BrandPackage): number {
  if (pkg.kind === "brand") {
    return pkg.brand === "shared" ? 1 : 2;
  }
  return pkg.brand === "shared" ? 3 : 4;
}

export function packageLayer(pkg: BrandPackage): LayerName {
  return pkg.kind;
}

export function validatePackage(pkg: BrandPackage): string[] {
  const errors: string[] = [];
  if (pkg.id.trim() === "") {
    errors.push("package id 不能为空");
  }
  if (pkg.kind === "brand" && pkg.brand.trim() === "") {
    errors.push("品牌包必须声明 brand");
  }
  for (const token of Object.keys(pkg.overrides)) {
    const tier = TIER_BY_NAME.get(token);
    if (pkg.kind === "brand" && tier === "primitive") {
      errors.push(`品牌包不得覆盖原始 token: ${token}`);
    }
    if (pkg.kind === "business") {
      if (tier === "primitive" || tier === "semantic") {
        errors.push(`业务包不得覆盖基础 token: ${token}`);
      }
      if (tier === undefined && !token.startsWith("business.")) {
        errors.push(`业务包只能扩展 business.* token: ${token}`);
      }
    }
  }
  return errors;
}

export function selectPackages(brand: string, packages: BrandPackage[]): BrandPackage[] {
  return packages.filter((pkg) => pkg.brand === "shared" || pkg.brand === brand);
}

export function mergeForBrand(
  brand: string,
  packages: BrandPackage[],
  base: Record<string, string> = PLATFORM_BASE,
): MergeResult {
  const selected = selectPackages(brand, packages);
  const rejected: RejectedPackage[] = [];
  const valid: BrandPackage[] = [];

  for (const pkg of selected) {
    const errors = validatePackage(pkg);
    if (errors.length > 0) {
      rejected.push({ packageId: pkg.id, errors });
    } else {
      valid.push(pkg);
    }
  }

  valid.sort(
    (a, b) =>
      packageRank(a) - packageRank(b) || a.id.localeCompare(b.id),
  );

  const values: Record<string, string> = { ...base };
  const owners = new Map<string, { rank: number; packageId: string; value: string }>();
  for (const [token, value] of Object.entries(base)) {
    owners.set(token, { rank: 0, packageId: "platform", value });
  }

  const shadowed: ShadowedOverride[] = [];
  const conflicts = new Map<string, MergeConflict>();

  for (const pkg of valid) {
    const rank = packageRank(pkg);
    const layer = packageLayer(pkg);
    for (const [token, value] of Object.entries(pkg.overrides)) {
      const existing = owners.get(token);
      if (existing === undefined) {
        owners.set(token, { rank, packageId: pkg.id, value });
        values[token] = value;
        continue;
      }
      if (existing.rank === rank) {
        if (existing.value !== value) {
          const key = `${layer}:${token}`;
          const conflict = conflicts.get(key) ?? {
            token,
            layer,
            contenders: [{ packageId: existing.packageId, value: existing.value }],
          };
          conflict.contenders.push({ packageId: pkg.id, value });
          conflicts.set(key, conflict);
        }
        continue;
      }
      if (existing.value !== value) {
        shadowed.push({ token, from: existing.packageId, by: pkg.id });
      }
      owners.set(token, { rank, packageId: pkg.id, value });
      values[token] = value;
    }
  }

  return {
    brand,
    values,
    applied: valid.map((pkg) => pkg.id),
    rejected,
    conflicts: [...conflicts.values()].sort((a, b) =>
      a.token.localeCompare(b.token),
    ),
    shadowed,
  };
}

export function reuseMatrix(usages: UsageRecord[]): Record<string, string[]> {
  const grouped = new Map<string, Set<string>>();
  for (const usage of usages) {
    if (usage.business.trim() === "" || usage.component.trim() === "") {
      continue;
    }
    const existing = grouped.get(usage.component) ?? new Set<string>();
    existing.add(usage.business);
    grouped.set(usage.component, existing);
  }
  const matrix: Record<string, string[]> = {};
  for (const component of [...grouped.keys()].sort()) {
    matrix[component] = [...(grouped.get(component) ?? new Set<string>())].sort();
  }
  return matrix;
}

export function reuseScores(usages: UsageRecord[]): ReuseScore[] {
  const matrix = reuseMatrix(usages);
  const totalBusinesses = new Set(
    usages
      .filter((usage) => usage.business.trim() !== "" && usage.component.trim() !== "")
      .map((usage) => usage.business),
  ).size;
  return Object.entries(matrix)
    .map(([component, businesses]) => ({
      component,
      businesses,
      reuse: totalBusinesses === 0 ? 0 : businesses.length / totalBusinesses,
    }))
    .sort(
      (a, b) =>
        b.reuse - a.reuse || a.component.localeCompare(b.component),
    );
}

export function sharedComponents(usages: UsageRecord[], minimum = 2): string[] {
  return reuseScores(usages)
    .filter((score) => score.businesses.length >= minimum)
    .map((score) => score.component)
    .sort();
}

export function forkCandidates(usages: UsageRecord[]): string[] {
  return reuseScores(usages)
    .filter((score) => score.businesses.length === 1)
    .map((score) => score.component)
    .sort();
}

export const SAMPLE_USAGE: UsageRecord[] = [
  { business: "checkout", component: "Button" },
  { business: "profile", component: "Button" },
  { business: "checkout", component: "Input" },
  { business: "profile", component: "Input" },
  { business: "search", component: "Input" },
  { business: "search", component: "Carousel" },
  { business: "checkout", component: "Carousel" },
  { business: "search", component: "Toast" },
  { business: "checkout", component: "Button" },
];

模块里有四个关键决策。第一,packageRank 把「共享/特定」和「品牌/业务」编码成 1 到 4,排序不依赖输入数组顺序;共享品牌包先于品牌包,品牌包先于业务包,业务包内部再按 id 稳定排序。第二,平台基线作为 rank 0 的虚拟所有者加入 owners,这样品牌覆盖平台时也会产生 shadowed 记录,评审时能看到「这个值从哪来、被谁改过」。

第三,同 rank 冲突只记录、不抛异常,但冲突信息进入构建报告;是否阻断发布由流水线决定,模块本身保持纯函数,便于测试和复用。第四,复用度按业务去重,同一业务重复使用同一个组件只算一次,否则使用次数多的业务会虚假抬高复用度。

判据与反例

多品牌接入前,用下面五条判据检查:

  1. 隔离可验证。 合并品牌 A 时,品牌 B 的包不出现在候选集,也不出现在 applied。
  2. 顺序可复现。 打乱输入包数组顺序,合并结果与 applied 顺序不变。
  3. 边界有拒绝。 品牌包覆盖 primitive、业务包覆盖 semantic,都必须被 rejected。
  4. 冲突有报告。 同层不同值必须出现在 conflicts,不能静默取最后一个。
  5. 复用有数字。 每个组件的使用业务数与复用度可计算,分叉候选有明确清单。

反例一:用「后加载覆盖先加载」实现品牌切换。文件扫描顺序、动态导入顺序、并发构建都会改变结果,同一份源码在不同机器上产出不同主题。

反例二:让业务包直接改语义 token。短期看只是「这个业务想换个按钮色」,长期看品牌识别被业务蚕食,第四个业务接入时已经没人知道品牌色是什么。

反例三:把复用度等同于使用次数。一个业务在 20 个页面用了同一个组件,使用次数很高,但复用业务数只有 1,这说明它是业务私有组件,不是平台组件。

小结

多品牌复用的核心不是覆盖能力,而是边界与隔离。

  • 覆盖顺序由显式 rank 决定:平台、共享品牌、品牌、共享业务、业务。
  • 品牌包可改语义与组件,业务包只能改组件和 business.*,两者都不能碰 primitive。
  • 同层冲突要报告并保持确定性,跨层覆盖要记录 shadowed。
  • 复用度按业务去重计算,低复用组件进入分叉候选。

先划边界,再谈复用。边界不清,复用越多,污染越快。

阅读导航:上一节:2.2 组件契约与版本化 · 下一节:3.1 插件协议与宿主边界 。

常见错误与边界

  • 按加载顺序覆盖。 文件扫描、动态导入和并发都会改变顺序,同一份源码可能产出不同主题。
  • 品牌包覆盖 primitive。 原始色板被品牌改写后,同品牌不同组件的色差无法解释,也无法回滚。
  • 业务包覆盖 semantic。 业务会劫持品牌识别,第四个业务接入时品牌色已经说不清。
  • 同层冲突静默取最后一个。 构建成功但结果不可解释,应保留先应用者并输出冲突报告。
  • 把非法包降级为警告。 越界覆盖会在下一个品牌接入时爆发,合并前应直接拒绝整个包。
  • 复用度按使用次数算。 单业务高频使用会虚高,必须按业务去重。
  • 共享包与品牌包混在一个数组里。 没有显式 rank 就无法保证顺序,应把层级作为包的结构字段。
  • 没有分叉清单。 只算复用度不产出候选,低复用组件会长期占据平台维护成本。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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