---
title: "多版型與多主題的架構設計模式"
description: "一套前端要撐起 100+ 主題與多種版型，又不讓客製邏輯滲進共用元件，該怎麼切？這篇拆解 core / view / theme leaf 的分層、型別化設定、家族完整性測試與主題 CSS 按需載入，以及每一層各自的取捨。"
url: https://stephen.taipei/articles/multi-layout-multi-theme-architecture
author: "Stephen Chuang"
date: 2026-09-26T18:00:00+08:00
updated: 2026-10-04T00:00:00+08:00
tags: [software-architecture, angular, typescript, web-platform]
lang: zh-TW
---

# 多版型與多主題的架構設計模式

我曾經負責一個大型 Nx / Angular 前端平台的架構重整。它要支援 100 多個主題、好幾種版型，而且經常有其他成員，以符合自己開發習慣的方式，完成該次多版型、多主題的子任務，長久累積，就會成為技術債，同一需求，同時有多種實作邏輯的存在。

這篇把當時的核心設計講清楚：怎麼分層、怎麼讓一百多個主題都保持完整、怎麼讓瀏覽器只下載用得到的 CSS。原始的 production 程式碼不能公開，所以我另外用原創程式碼和三個虛構主題重新實作了一次，放在 [frontend-engineering-case-studies](https://github.com/stephen-taipei/frontend-engineering-case-studies)，也有可以直接操作的[線上 Demo](https://stephen-taipei.github.io/frontend-engineering-case-studies/)。文中的程式碼都來自這個公開版本。

先講結論：**把「長什麼樣子」和「做什麼事」拆開，再讓編譯器和測試替你守住一百多個主題的一致性。**

## 問題不是換顏色

多主題最常見的起點，是在共用元件裡加條件。例如 A 主題要多一個按鈕，B 主題活動期間要換成另一個導頁：

```ts
if (theme === 'a') {
  showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
  redirectTo = '/campaign';
}
```

主題只有三個時，這樣寫很快。主題變成一百個之後，每個共用元件都背著一堆「只有某個主題才成立」的分支。後果是：

- 改 A 主題的需求，可能悄悄弄壞 B 主題，而沒人負責檢查 B。
- 同一種差異在不同元件裡各寫一次，寫法還不一樣。
- 新增一個主題時，沒有人能確定「該補的地方都補齊了」。

所以要解的不只是換色，而是：**允許每個主題在外觀和產品上有差異，同時讓整個主題家族保持完整、可預期。**

## 先分清楚：版型和主題是兩件事

用房子來比喻最好懂：

- **結構與管線**：柱子、水電。每一戶都一樣，也不該因為住戶喜好就改。
- **版型**：格局。兩房還是三房、客廳在哪。決定「東西怎麼排」。
- **主題**：裝潢。牆面顏色、材質、圓角大小。決定「東西看起來怎樣」。

在前端裡，這三件事分別對應到不同的層。混在一起，就會出現「為了換個顏色去改資料流」的狀況。

## 四層結構

```text
Runtime theme id
      │
      ▼
  selector ──► theme leaf (complete typed config)
                   │
                   ▼
              shared view ◄── data service
                   │     └──► actions facade
                   ▼
               pure core
```

由下往上看：

### core：只管畫出來

core 元件只接收「已經準備好、可以直接渲染」的資料，只往外丟出使用者的意圖（intent）。它不知道現在是哪個主題，不碰 routing、不碰 API 回傳格式，也不引用任何品牌素材。

```ts
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 處理。

```ts
@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。

```ts
@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：

```ts
@switch (theme()) {
  @case ('aurora') { <case-study-theme-aurora /> }
  @case ('summit') { <case-study-theme-summit /> }
  @default { <case-study-theme-default /> }
}
```

它看起來很普通，重點在「明確」：有哪些主題、各自對到哪個 leaf，一眼就看得到，也能被測試逐一檢查。

## 讓編譯器幫你點名

主題一多，最怕的是漏。新增一個主題，忘了補設定；改了設定格式，有幾個舊主題沒跟上。

做法是先把主題清單寫成單一來源，再用 TypeScript 的 `satisfies` 要求每個主題都有一份完整設定：

```ts
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`。

## 讓測試守住整個家族

編譯器管得到設定，管不到「這個主題真的渲染得出來」。這部分交給家族完整性測試，逐一列舉每個已宣告的主題：

```ts
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>`：

```ts
@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);
  }
}
```

平台在主題切換時呼叫它：

```ts
effect(() => {
  const appearance = THEME_APPEARANCES[this.selectedTheme()];
  this.#stylesheetLoader.load(appearance.stylesheetHref);
});
```

「唯一」是重點。如果每個地方都自己插 `<link>`，反覆切換後就會殘留舊主題的樣式，或是兩份 CSS 互相蓋來蓋去。測試會確認切換好幾次之後，頁面上仍然只有一個受管理的 stylesheet。

主題 CSS 本身只定義 design token（CSS 變數），core 的樣式只讀這些變數：

```css
: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 的[證據邊界稽核](https://github.com/stephen-taipei/frontend-engineering-case-studies/blob/main/docs/evidence-boundaries.zh-TW.md)。

想直接看效果，可以打開[線上 Demo](https://stephen-taipei.github.io/frontend-engineering-case-studies/) 切換三個主題、語言與亮暗模式，再對照 [GitHub 上的原始碼](https://github.com/stephen-taipei/frontend-engineering-case-studies)。
