四个品牌合并发布,主色跑到了别人的收银台
某集团有四个面向不同人群的品牌,共用一套 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 冲突只记录、不抛异常,但冲突信息进入构建报告;是否阻断发布由流水线决定,模块本身保持纯函数,便于测试和复用。第四,复用度按业务去重,同一业务重复使用同一个组件只算一次,否则使用次数多的业务会虚假抬高复用度。
判据与反例
多品牌接入前,用下面五条判据检查:
- 隔离可验证。 合并品牌 A 时,品牌 B 的包不出现在候选集,也不出现在
applied。 - 顺序可复现。 打乱输入包数组顺序,合并结果与
applied顺序不变。 - 边界有拒绝。 品牌包覆盖 primitive、业务包覆盖 semantic,都必须被
rejected。 - 冲突有报告。 同层不同值必须出现在
conflicts,不能静默取最后一个。 - 复用有数字。 每个组件的使用业务数与复用度可计算,分叉候选有明确清单。
反例一:用「后加载覆盖先加载」实现品牌切换。文件扫描顺序、动态导入顺序、并发构建都会改变结果,同一份源码在不同机器上产出不同主题。
反例二:让业务包直接改语义 token。短期看只是「这个业务想换个按钮色」,长期看品牌识别被业务蚕食,第四个业务接入时已经没人知道品牌色是什么。
反例三:把复用度等同于使用次数。一个业务在 20 个页面用了同一个组件,使用次数很高,但复用业务数只有 1,这说明它是业务私有组件,不是平台组件。
小结
多品牌复用的核心不是覆盖能力,而是边界与隔离。
- 覆盖顺序由显式 rank 决定:平台、共享品牌、品牌、共享业务、业务。
- 品牌包可改语义与组件,业务包只能改组件和
business.*,两者都不能碰 primitive。 - 同层冲突要报告并保持确定性,跨层覆盖要记录
shadowed。 - 复用度按业务去重计算,低复用组件进入分叉候选。
先划边界,再谈复用。边界不清,复用越多,污染越快。
阅读导航:上一节:2.2 组件契约与版本化 · 下一节:3.1 插件协议与宿主边界 。
常见错误与边界
- 按加载顺序覆盖。 文件扫描、动态导入和并发都会改变顺序,同一份源码可能产出不同主题。
- 品牌包覆盖 primitive。 原始色板被品牌改写后,同品牌不同组件的色差无法解释,也无法回滚。
- 业务包覆盖 semantic。 业务会劫持品牌识别,第四个业务接入时品牌色已经说不清。
- 同层冲突静默取最后一个。 构建成功但结果不可解释,应保留先应用者并输出冲突报告。
- 把非法包降级为警告。 越界覆盖会在下一个品牌接入时爆发,合并前应直接拒绝整个包。
- 复用度按使用次数算。 单业务高频使用会虚高,必须按业务去重。
- 共享包与品牌包混在一个数组里。 没有显式 rank 就无法保证顺序,应把层级作为包的结构字段。
- 没有分叉清单。 只算复用度不产出候选,低复用组件会长期占据平台维护成本。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。