記事
マルチレイアウト・マルチテーマのアーキテクチャ設計パターン
フロントエンド全体で100+のテーマと複数のレイアウトを支えながら、カスタムロジックが共通コンポーネントに入り込まないようにするには、どう分割すればよいでしょうか?この記事では core / view / theme leaf のレイヤー構成、型付き設定、ファミリー完全性テスト、テーマ CSS のオンデマンド読み込み、そして各レイヤーのトレードオフを解説します。
· 更新 · 11 分で読めます
私は以前、大規模な Nx / Angular フロントエンドプラットフォームのアーキテクチャ再構築を担当しました。100+のテーマと複数のレイアウトをサポートする必要があり、他のメンバーが自分の開発スタイルに合わせて、その都度のマルチレイアウト・マルチテーマのサブタスクを実装することもよくありました。それが長期的に積み重なると技術的負債になり、同じ要件に複数の実装ロジックが共存するようになります。
この記事では、当時の設計の要点を解説します。レイヤーをどう分けるか、100+のテーマすべての完全性をどう保つか、ブラウザーが必要な CSS だけをダウンロードするようにするにはどうするか。元の production コードは公開できないため、オリジナルのコードと架空のテーマ3つを使って再実装し、frontend-engineering-case-studiesで公開しました。すぐに操作できるオンライン Demoもあります。記事中のコードはすべて、この公開版のものです。
先に結論を言うと、「見た目」と「役割」を分け、コンパイラーとテストに100+のテーマの一貫性を守らせます。
問題は色を変えることではない#
マルチテーマ対応で最もよくある出発点は、共通コンポーネントに条件分岐を追加することです。たとえば、Aテーマではボタンを1つ追加し、Bテーマではキャンペーン期間中に別のページへ遷移させます:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
テーマが3つだけなら、これで手早く書けます。テーマが100になれば、共通コンポーネントの一つひとつが、「特定のテーマでしか成立しない」分岐を大量に抱えます。その結果、次のようなことが起きます:
- Aテーマの要件を変更したら、気づかないうちにBテーマを壊してしまう可能性があります。しかもBを確認する担当者がいません。
- 同じ違いを複数のコンポーネントにそれぞれ実装し、しかも書き方まで異なります。
- 新しいテーマを追加するとき、「必要な箇所をすべて補えた」と誰も確信できません。
つまり、解決すべきなのは色の切り替えだけではありません。各テーマの外観や製品機能の違いを許容しながら、テーマファミリー全体の完全性と予測可能性を保つことです。
まず整理する:レイアウトとテーマは別物#
家にたとえると、いちばん分かりやすいでしょう:
- 構造と配管:柱や電気・水道設備。どの住戸も同じで、住人の好みで変えるべきではありません。
- レイアウト:間取り。2部屋か3部屋か、リビングをどこに置くかを決め、「ものをどう配置するか」を表します。
- テーマ:内装。壁の色や素材、角丸の大きさを決め、「ものをどう見せるか」を表します。
フロントエンドでは、この3つはそれぞれ異なるレイヤーに対応します。混同すると、「色を変えるためにデータフローを変更する」ようなことが起きます。
4層構造#
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 はすべてのテーマで共用され、影響範囲が最も大きいため、最も厳格なルールを設けます:入力は必要なものだけ、出力は intent だけです。
view:調整を担う#
view は仲介役です。data service からデータを取得して core が必要とする形に変換し、core が intent を出したら 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 が1つだけですが、production の再構築では26個を導入しました。
theme leaf:テーマごとに1つの葉ノード#
各テーマは薄いコンポーネントで、役割は1つだけです。完全な外観設定を選び、それを 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 にテーマを1つ追加したとき、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 フィールドは設定表のキーと一致し、CSS パスもファミリーのルールに沿っていなければなりません。
この2種類のチェックによって、sibling テーマの漏れを人手で review していた作業が、CI で検出されるエラーに変わります。100+のテーマを人の目だけですべて確認するのは無理です。
使用中のテーマ CSS だけをダウンロードする#
レイヤー分けで解決できるのは保守性の問題ですが、多テーマには、ユーザーに直接影響するコストもあります。CSS です。
従来の方法では136個のテーマ stylesheet をすべて一緒にバンドルしていたため、ユーザーはどのテーマを使っていても、全部をダウンロードして解析する必要がありました。オンデマンド読み込みに変更すると、1テーマあたりのリソースは約 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> を挿入すると、切り替えを繰り返した後に古いテーマのスタイルが残ったり、2つの CSS が互いを上書きしたりします。テストでは、何度か切り替えた後も、ページ上に管理対象の stylesheet が1つだけであることを確認します。
テーマ CSS が定義するのは design token(CSS 変数)だけで、core のスタイルはそれらの変数を読み取ります:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
そのため、テーマを変えると変数のセットが切り替わるだけで、core のスタイルは一切変更する必要がありません。ライトモードとダークモードも、同じファイル内の別の変数セットです。
トレードオフ#
この設計にもコストはあります:
- ファイル数が増えます。 テーマごとに1つの leaf、レイアウトごとに1つの view を作るため、小さなファイルが増えます。その代わり、各ファイルの責務が一目で分かり、完全性を検査できます。
- 設定をプログラミング言語にしてはいけません。 config は外観や境界の明確な差異を記述するのに適しています。設定に条件分岐や処理フローを書き始めると、元の if/else を別の場所に隠しただけになります。
- core には、より強い契約テストが必要です。 すべてのテーマで共用されるため、変更1つが100+のテーマに影響します。
- runtime で CSS を切り替えるには、単一の owner が必要です。 調整されていない stylesheet の読み込みは、ちらつきやスタイルの残留を引き起こします。loader を唯一にするのはそのためです。
- キャッシュも引き続き重要です。 テーマを1つだけ読み込む場合でも、安定したファイル名と cache header が再訪時の速度を左右します。
どんなときに採用する価値があるか#
製品にライトモードとダークモードしかないなら、CSS 変数だけで十分で、この仕組み全体は必要ありません。
次のような状況になったら、検討する価値があります:
- テーマや顧客向けバージョンが10を超え、さらに増え続けている。
- テーマ間の違いが色だけではなく、レイアウト、機能フラグ、文言にも及ぶ。
- あるテーマの要件を変更すると、別のテーマに予期せず影響することがよくある。
- テーマを追加するとき、どこを補えばよいか誰も確信できない。
根拠の適用範囲#
この記事で触れた production の数値については、対象範囲を明確にしておきます:
- 23行は、リファクタリング後の代表的な完全版 theme leaf 1つを指します。すべての leaf やファイルが23行という意味ではありません。
- この migration では、新しいアーキテクチャのモジュールを70個導入しました(facade 24個、data service 10個、theme config 10個、shared view 26個)。これは改修範囲を示すもので、パフォーマンスやコード削減の成果を示す数字ではありません。
- 2.4 MB → 17 KB は production での実測値です。公開 Demo にある小さな CSS 3つでは、この数字は再現されません。
- 同じ品質改善プロジェクトでは、主要な実測ページの Lighthouse Performance が50+から90+に向上しました。ただしこれは複数の改善の合計であり、CSS のオンデマンド読み込みだけによるものではありません。
公開 repo が示すのは、アーキテクチャの仕組みそのものです。型付き契約、runtime 切り替え、ファミリー完全性テスト、オンデマンド CSS、そしてそれらのチェックを実行する CI です。主張と根拠の完全な対応表は、repo の根拠範囲の監査にあります。
実際の動作を見るには、オンライン Demoを開いて3つのテーマ、言語、ライトモードとダークモードを切り替え、GitHub 上のソースコードと見比べてください。