文章
多布局与多主题的架构设计模式
一套前端要支撑 100+ 个主题和多种布局,又不让定制逻辑渗入共享组件,该怎么拆分?本文拆解 core / view / theme leaf 的分层、类型化配置、主题家族完整性测试和主题 CSS 按需加载,以及每一层各自的取舍。
· 更新于 · 7 分钟阅读
我曾负责一个大型 Nx / Angular 前端平台的架构重构。它需要支持 100 多个主题和多种布局,而且团队成员经常按照各自的开发习惯完成多布局、多主题相关的子任务。长期积累下来就会形成技术债:同一个需求同时存在多套实现逻辑。
本文会讲清楚当时的核心设计:如何分层、如何让一百多个主题都保持完整、如何让浏览器只下载用得到的 CSS。原始的 production 代码不能公开,所以我另外用原创代码和三个虚构主题重新实现了一次,放在 frontend-engineering-case-studies,也有可以直接操作的在线 Demo。文中的代码都来自这个公开版本。
先说结论:把“长什么样”和“做什么事”拆开,再让编译器和测试帮你守住一百多个主题的一致性。
问题不是换颜色#
支持多主题最常见的起点,是在共用组件里加条件。例如,A 主题需要多一个按钮,B 主题则要在活动期间跳转到另一个页面:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
主题只有三个时,这样写很快。主题变成一百个之后,每个共享组件都背着一堆“只有某个主题才成立”的分支。后果是:
- 改 A 主题的需求,可能悄悄弄坏 B 主题,而没人负责检查 B。
- 同一种差异在不同组件里各写一次,写法还不一样。
- 新增一个主题时,没人能确定“该补的地方都补齐了”。
所以要解决的不只是换颜色,而是:允许每个主题在外观和产品上有差异,同时让整个主题家族保持完整、可预期。
先分清楚:布局和主题是两回事#
用房子来比喻最好懂:
- 结构与管线:柱子、水电管线。每一户都一样,也不该因为住户喜好而改。
- 布局:户型。两居室还是三居室、客厅在哪里。决定“东西怎么摆”。
- 主题:装修。墙面颜色、材质、圆角大小。决定“东西看起来怎么样”。
在前端里,这三件事分别对应不同的层。混在一起,就会出现“为了换个颜色去改数据流”的情况。
四层结构#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
从下往上看:
core:只负责渲染#
core 组件只接收“已经准备好、可以直接渲染”的数据,只向外抛出用户的意图(intent)。它不知道当前是什么主题,不处理 routing、不处理 API 返回格式,也不引用任何品牌素材。
export class ThemeCardCoreComponent {
readonly appearance = input.required<ThemeAppearance>();
readonly content = input.required<ThemeContent>();
readonly items = input.required<readonly ShowcaseItem[]>();
readonly selectedId = input<string | null>(null);
readonly itemSelected = output<ShowcaseItem>();
readonly selectionCleared = output<void>();
}
core 被所有主题共用,影响范围最大,所以它的规则最严格:输入只有必要的,输出只有意图。
view:负责协调#
view 是中间层。它从 data service 获取数据,转换成 core 所需的形状;core 抛出意图时,则交给 actions facade 处理。
@Component({
selector: 'case-study-theme-card-view',
providers: [FeatureActionsFacade, FeatureDataService],
template: `
<case-study-theme-card-core
[appearance]="appearance()"
[content]="themeContent()"
[items]="data.items()"
[selectedId]="actions.selectedId()"
(itemSelected)="select($event)"
(selectionCleared)="actions.reset()"
/>
`,
})
export class ThemeCardViewComponent { /* ... */ }
数据从哪里来(data service)、点击后要做什么(actions facade),都有各自明确的边界,不会散落在页面组件里。
布局适合放在这一层。 不同的户型可以是不同的 shared view,它们接入同一套 data service 和 facade,只是把 core 组合成不同的排列方式。公开 Demo 为了简单只有一个 shared view;production 的那次重构则引入了 26 个。
theme leaf:每个主题一个叶节点#
每个主题都是一个很薄的组件,它只做一件事:选一份完整的外观配置,交给 shared view。
@Component({
selector: 'case-study-theme-aurora',
imports: [ThemeCardViewComponent],
template:
'<case-study-theme-card-view [appearance]="appearance" data-theme-leaf="aurora" />',
})
export class ThemeAuroraComponent {
protected readonly appearance = THEME_APPEARANCES.aurora;
}
里面没有任何业务条件。在 production 中,一个有代表性的完整 theme leaf 重构后是 23 行;旧实现的同一条路径有好几百行。
selector:明确列出主题家族成员#
selector 根据 runtime 的主题 id,渲染对应的 leaf:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
它看起来很普通,重点在“明确”:有哪些主题、各自对应哪个 leaf,一眼就能看到,也可以通过测试逐一检查。
让编译器帮你逐项检查#
主题一多,最怕的就是遗漏。新增一个主题时忘了补配置;修改配置格式后,有几个旧主题没跟上。
做法是先把主题清单定义为单一数据源,再用 TypeScript 的 satisfies 要求每个主题都有一份完整配置:
export const DEMO_THEMES = ['default', 'aurora', 'summit'] as const;
export type DemoTheme = (typeof DEMO_THEMES)[number];
export interface ThemeAppearance {
readonly density: 'comfortable' | 'compact';
readonly id: DemoTheme;
readonly stylesheetHref: `themes/${string}.css`;
}
export const THEME_APPEARANCES = {
default: { id: 'default', density: 'comfortable', stylesheetHref: 'themes/default.css' },
aurora: { id: 'aurora', density: 'comfortable', stylesheetHref: 'themes/aurora.css' },
summit: { id: 'summit', density: 'compact', stylesheetHref: 'themes/summit.css' },
} satisfies Record<DemoTheme, ThemeAppearance>;
DEMO_THEMES 多一个主题,THEME_APPEARANCES 没补,编译就会失败。字段拼写错误、CSS 路径格式不正确,也一样。
satisfies 比直接标注类型的好处是:它检查结构,但保留每个值的具体类型,后续使用 THEME_APPEARANCES.aurora 时不会被拓宽成普通的 ThemeAppearance。
让测试守住整个主题家族#
编译器能检查配置,却检查不了“这个主题是否真的能渲染出来”。这部分交给主题家族完整性测试,逐一列举每个已声明的主题:
it.each(DEMO_THEMES)('renders the %s family leaf', (theme) => {
const fixture = TestBed.createComponent(ThemeSelectorComponent);
fixture.componentRef.setInput('theme', theme);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector(`[data-theme-leaf="${theme}"]`),
).toBeTruthy();
});
配置方面也有对应的测试:每份主题配置的 id 字段都要和它在配置表中的 key 相同;CSS 路径也要符合家族规则。
这两种检查加在一起,就把原本靠人工 review“同级主题有没有遗漏”的工作,变成了 CI 会拦下来的错误。一百多个主题的规模,人眼是看不过来的。
只下载当前使用的主题 CSS#
分层解决的是维护问题,但多主题还会给用户带来一项直接成本:CSS。
原来的做法是把 136 份主题 stylesheet 全部打包在一起,用户无论使用哪个主题,都得下载并解析全部文件。改成按需加载后,单个主题的资源从约 2.4 MB 降到 17 KB,减少了约 99%。
机制本身很简单:每份主题配置都声明自己的 stylesheetHref,由唯一一个 loader 管理页面上的那个主题 <link>:
@Injectable()
export class ThemeStylesheetLoader {
readonly #document = inject(DOCUMENT);
load(href: ThemeAppearance['stylesheetHref']): void {
const id = 'case-study-theme-stylesheet';
const existing = this.#document.getElementById(id) as HTMLLinkElement | null;
const link = existing ?? this.#document.createElement('link');
link.id = id;
link.rel = 'stylesheet';
if (link.getAttribute('href') !== href) link.setAttribute('href', href);
if (!existing) this.#document.head.append(link);
}
}
平台在切换主题时调用它:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
“唯一”是重点。如果每个地方都自己插入 <link>,反复切换后就会残留旧主题的样式,或者两份 CSS 相互覆盖。测试会确认切换好几次之后,页面上仍然只有一个受管理的 stylesheet。
主题 CSS 本身只定义 design token(CSS 变量),core 的样式只读取这些变量:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
所以切换主题只需换一份变量表,core 的样式完全不用动。亮暗模式则是在同一个文件里的另一组变量。
这套设计不是没有代价:
- 文件变多。 每个主题一个 leaf、每种布局一个 view,小文件数量会增加。换来的是每个文件的职责一目了然,完整性也可以检查。
- 配置不能变成编程语言。 config 适合描述外观和有边界的差异。一旦开始在配置里写条件、写流程,就只是把原来的 if/else 换个地方藏起来。
- core 需要更强的契约测试。 它被所有主题共用,一个改动会影响一百多个主题。
- runtime 切换 CSS 需要单一 owner。 未协调的 stylesheet 请求会造成闪烁或残留,这也是 loader 必须唯一的原因。
- 缓存仍然重要。 即使只加载一个主题,稳定的文件名和 cache header 仍然决定回访时的速度。
什么时候值得这样做#
如果你的产品只有亮暗两种模式,用 CSS 变量就够了,不需要整套方案。
当你开始遇到这些情况,就值得考虑:
- 主题或客户版本超过十个,而且还在增加。
- 主题之间的差异不只是颜色,还有排版、功能开关、文案。
- 修改一个主题的需求,常常意外影响到另一个主题。
- 新增一个主题时,没人能确定哪些地方需要补齐。
证据的边界#
本文提到的 production 数据,需要说明清楚适用范围:
- 23 行指的是重构后一个有代表性的完整 theme leaf,不代表每个 leaf、每个文件都是 23 行。
- 那次 migration 引入了 70 个新架构模块(24 个 facade、10 个 data service、10 份 theme config、26 个 shared view)。这是改造的范围,不是性能提升或代码缩减的成果。
- 2.4 MB → 17 KB 是 production 实测数据。公开 Demo 里的三份小 CSS 无法复现这个数字。
- 同一个质量项目中,关键实测页面的 Lighthouse Performance 从 50+ 提升到 90+,但这是多项改进的总和,不能归功于单独的 CSS 按需加载。
公开 repo 证明的是架构机制本身:类型化契约、runtime 切换、主题家族完整性测试、按需 CSS,以及运行这些检查的 CI。完整的声明对照表见 repo 中的证据边界审计。
想直接看效果,可以打开在线 Demo,切换三个主题、语言和亮暗模式,再对照 GitHub 上的源代码。