文章
多版面配置與多主題的架構設計模式
一套前端要支援 100+ 個主題和多種版面配置,又不讓自訂邏輯滲入共用元件,應該怎樣劃分?本文拆解 core / view / theme leaf 的分層、型別化設定、主題家族完整性測試和按需要載入主題 CSS,以及每一層各自的取捨。
· 更新於 · 8 分鐘閱讀
我曾經負責一個大型 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 上的原始碼。