文章
多版型與多主題的架構設計模式
一套前端要撐起 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「sibling 主題有沒有漏」的工作,變成 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 上的原始碼。