Artikel
Architekturmuster für mehrere Layouts und Themes
Wie lässt sich ein Frontend für mehr als 100 Themes und mehrere Layouts auslegen, ohne dass kundenspezifische Logik in gemeinsame Komponenten durchsickert? Dieser Artikel erläutert die Schichten core / view / theme leaf, typisierte Konfiguration, Tests auf Vollständigkeit der Theme-Familie und das bedarfsgesteuerte Laden von Theme-CSS sowie die jeweiligen Abwägungen.
· Aktualisiert · 9 Min. Lesezeit
Ich war einmal für die Architekturüberarbeitung einer großen Nx- / Angular-Frontendplattform verantwortlich. Sie musste mehr als 100 Themes und mehrere Layouts unterstützen. Außerdem kam es häufig vor, dass andere Teammitglieder Teilaufgaben zu mehreren Layouts und Themes auf eine Weise umsetzten, die ihren eigenen Entwicklungsgewohnheiten entsprach. Mit der Zeit wurde daraus technische Schuld: Für dieselbe Anforderung existierten gleichzeitig mehrere Implementierungslogiken.
Dieser Artikel erläutert die zentralen Entwurfsentscheidungen von damals: Wie wird geschichtet? Wie bleiben mehr als hundert Themes vollständig? Wie lädt der Browser nur das CSS herunter, das tatsächlich gebraucht wird? Der ursprüngliche production-Code darf nicht veröffentlicht werden. Deshalb habe ich die Architektur mit eigenem Code und drei fiktiven Themes neu umgesetzt und in frontend-engineering-case-studies veröffentlicht. Dazu gibt es auch eine direkt nutzbare Online-Demo. Der Code im Artikel stammt aus dieser öffentlichen Version.
Das Fazit vorweg: Trenne „wie es aussieht“ von „was es tut“ und lass anschließend Compiler und Tests die Konsistenz von mehr als hundert Themes sicherstellen.
Das Problem ist nicht der Farbwechsel#
Der häufigste Einstieg bei mehreren Themes ist, Bedingungen in eine gemeinsame Komponente einzubauen. Zum Beispiel braucht Theme A eine zusätzliche Schaltfläche, während Theme B während der Aktion auf eine andere Seite weiterleiten soll:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Bei nur drei Themes ist dieser Ansatz schnell umgesetzt. Bei hundert Themes trägt jede gemeinsame Komponente eine Vielzahl von Verzweigungen mit sich herum, die „nur für ein bestimmtes Theme gelten“. Die Folgen:
- Eine Anforderung für Theme A kann unbemerkt Theme B beschädigen, ohne dass jemand dafür zuständig ist, B zu prüfen.
- Dieselbe Art von Abweichung wird in verschiedenen Komponenten jeweils neu und auf unterschiedliche Weise umgesetzt.
- Wenn ein neues Theme hinzukommt, kann niemand sicher sein, dass „alle nötigen Stellen ergänzt wurden“.
Zu lösen ist also nicht nur der Farbwechsel, sondern: Jedes Theme darf sich in Aussehen und Produkt unterscheiden, während die gesamte Theme-Familie vollständig und vorhersehbar bleibt.
Zuerst unterscheiden: Layout und Theme sind zwei verschiedene Dinge#
Ein Haus ist der beste Vergleich:
- Struktur und Leitungen: tragende Säulen sowie Wasser- und Stromversorgung. Sie sind in jeder Wohnung gleich und sollten sich nicht nach den Vorlieben der Bewohner richten.
- Layout: der Grundriss. Zwei oder drei Zimmer, die Lage des Wohnzimmers. Es bestimmt, „wie die Dinge angeordnet sind“.
- Theme: die Einrichtung. Wandfarben, Materialien und die Größe der abgerundeten Ecken. Es bestimmt, „wie die Dinge aussehen“.
Im Frontend entsprechen diese drei Dinge jeweils eigenen Schichten. Werden sie vermischt, kommt es dazu, dass „für eine Farbänderung der Datenfluss angepasst werden muss“.
Vier Schichten#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Von unten nach oben:
core: nur für die Darstellung zuständig#
core-Komponenten erhalten nur Daten, die „bereits vorbereitet und direkt renderbar“ sind, und geben ausschließlich die Absicht des Nutzers (intent) nach außen weiter. Sie wissen nicht, welches Theme gerade aktiv ist, kümmern sich weder um routing noch um das Format von API-Antworten und referenzieren keine Markenressourcen.
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 wird von allen Themes gemeinsam genutzt und hat deshalb die größte Reichweite. Entsprechend streng sind seine Regeln: Als Eingabe gibt es nur das Nötige, als Ausgabe nur die Absicht.
view: zuständig für die Koordination#
view ist die Vermittlungsschicht. Sie bezieht Daten vom data service und bringt sie in die Form, die core benötigt. Wenn core eine Absicht ausgibt, übergibt view sie zur Verarbeitung an die 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 { /* ... */ }
Die Herkunft der Daten (data service) und die Reaktion auf einen Klick (actions facade) haben jeweils klar definierte Grenzen. Sie verteilen sich nicht über die UI-Komponenten.
Das Layout passt auf diese Ebene. Unterschiedliche Grundrisse können als unterschiedliche shared views umgesetzt werden. Sie nutzen denselben data service und dieselbe facade und setzen core lediglich unterschiedlich zusammen. Die öffentliche Demo hat der Einfachheit halber nur eine shared view; bei der Überarbeitung für production wurden 26 eingeführt.
theme leaf: ein Blattknoten pro Theme#
Jedes Theme ist eine sehr schlanke Komponente, die genau eine Aufgabe hat: eine vollständige Darstellungskonfiguration auswählen und an die shared view übergeben.
@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;
}
Sie enthält keinerlei Geschäftslogik. In production umfasste ein repräsentativer, vollständiger theme leaf nach dem Refactoring 23 Zeilen; derselbe Pfad hatte in der alten Implementierung mehrere hundert Zeilen.
selector: die Mitglieder der Familie explizit aufführen#
selector rendert anhand der runtime-Theme-ID den zugehörigen leaf:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Das wirkt zunächst unspektakulär. Entscheidend ist die Explizitheit: Welche Themes es gibt und welchem leaf sie jeweils zugeordnet sind, ist auf einen Blick erkennbar und kann einzeln getestet werden.
Der Compiler soll fehlende Einträge auflisten#
Bei vielen Themes ist die größte Gefahr, etwas zu vergessen: Ein neues Theme kommt hinzu, aber seine Konfiguration wird nicht ergänzt; das Format der Konfiguration ändert sich, aber einige ältere Themes ziehen nicht nach.
Die Lösung: Zuerst die Theme-Liste als einzige Quelle festlegen und dann mit TypeScript und satisfies verlangen, dass jedes Theme eine vollständige Konfiguration hat:
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>;
Kommt mit DEMO_THEMES ein Theme hinzu und wird THEME_APPEARANCES nicht ergänzt, schlägt die Kompilierung fehl. Auch ein falsch geschriebener Feldname oder ein ungültiges CSS-Pfadformat führt zum Fehler.
satisfies hat gegenüber einer direkten Typannotation den Vorteil, dass es die Struktur prüft und zugleich den konkreten Typ jedes Werts erhält. Bei der späteren Verwendung von THEME_APPEARANCES.aurora wird der Typ dadurch nicht zu einem allgemeinen ThemeAppearance erweitert.
Tests halten die gesamte Familie intakt#
Der Compiler kann die Konfiguration prüfen, aber nicht sicherstellen, dass „dieses Theme tatsächlich gerendert werden kann“. Dafür gibt es Tests auf Vollständigkeit der Theme-Familie, die jedes deklarierte Theme einzeln durchgehen:
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();
});
Auch die Konfigurationsseite hat einen entsprechenden Test: Das Feld id jeder Theme-Konfiguration muss mit ihrem Schlüssel in der Konfigurationstabelle übereinstimmen. Auch der CSS-Pfad muss den Regeln der Theme-Familie entsprechen.
Zusammen machen diese beiden Prüfungen aus der bisher manuellen Review-Frage „Fehlt ein sibling-Theme?“ einen Fehler, den CI abfängt. Bei mehr als hundert Themes kann kein Mensch alles mit den Augen überblicken.
Lade nur das CSS des Themes, das du gerade verwendest#
Die Schichtung löst Wartungsprobleme. Multi-Theme-Architekturen verursachen aber auch direkte Kosten für die Nutzer: CSS.
Ursprünglich wurden alle 136 Theme-Stylesheets zusammen gebündelt. Unabhängig davon, welches Theme jemand nutzte, musste der Browser alles herunterladen und parsen. Nach der Umstellung auf bedarfsgesteuertes Laden sank die Ressourcengröße pro Theme von etwa 2.4 MB auf 17 KB, also um rund 99%.
Der Mechanismus ist einfach: Jede Theme-Konfiguration deklariert ihr eigenes stylesheetHref. Ein einziger loader verwaltet das eine Theme-<link> auf der Seite:
@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);
}
}
Die Plattform ruft ihn beim Theme-Wechsel auf:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
„Einziger“ ist hier entscheidend. Wenn jede Stelle selbst ein <link> einfügt, bleiben nach wiederholten Wechseln Styles des alten Themes zurück oder zwei CSS-Dateien überschreiben sich gegenseitig. Der Test prüft, dass nach mehreren Wechseln weiterhin nur ein verwaltetes stylesheet auf der Seite vorhanden ist.
Das Theme-CSS selbst definiert nur design token (CSS-Variablen); die Styles von core lesen ausschließlich diese Variablen:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Ein Theme-Wechsel tauscht also nur eine Variablentabelle aus. Die Styles von core bleiben unverändert. Der Hell-Dunkel-Modus verwendet eine weitere Variablengruppe in derselben Datei.
Abwägungen#
Dieser Entwurf ist nicht kostenlos:
- Mehr Dateien. Ein leaf pro Theme und ein view pro Layout erhöhen die Anzahl kleiner Dateien. Dafür ist die Zuständigkeit jeder Datei sofort erkennbar und ihre Vollständigkeit lässt sich prüfen.
- config darf nicht zur Programmiersprache werden. config eignet sich, um das Aussehen und klar abgegrenzte Abweichungen zu beschreiben. Sobald Bedingungen und Abläufe in config stehen, wird das ursprüngliche if/else nur an einem anderen Ort versteckt.
- core braucht stärkere Vertragstests. core wird von allen Themes gemeinsam genutzt, daher wirkt sich eine Änderung auf mehr als hundert Themes aus.
- Für runtime-CSS-Wechsel braucht es einen einzigen Owner. Nicht koordinierte stylesheet-Anfragen verursachen Flackern oder hinterlassen alte Styles. Deshalb muss es genau einen loader geben.
- Cache bleibt wichtig. Selbst wenn nur ein Theme geladen wird, bestimmen stabile Dateinamen und der cache header weiterhin die Geschwindigkeit bei späteren Besuchen.
Wann sich dieser Ansatz lohnt#
Wenn dein Produkt nur einen Hell- und einen Dunkelmodus hat, reichen CSS-Variablen aus; die ganze Architektur brauchst du nicht.
Sobald du auf diese Situationen triffst, lohnt es sich, den Ansatz in Betracht zu ziehen:
- Es gibt mehr als zehn Themes oder Kundenversionen, und die Zahl wächst weiter.
- Die Unterschiede zwischen den Themes betreffen nicht nur Farben, sondern auch Layouts, Funktionsschalter und Texte.
- Anforderungen für ein Theme wirken sich häufig unbeabsichtigt auf ein anderes aus.
- Wenn ein Theme hinzukommt, weiß niemand sicher, an welchen Stellen Ergänzungen nötig sind.
Grenzen der Belege#
Bei den in diesem Artikel genannten production-Zahlen muss der Umfang klar sein:
- 23 Zeilen bezeichnet einen repräsentativen, vollständigen theme leaf nach dem Refactoring. Es bedeutet nicht, dass jeder leaf und jede Datei 23 Zeilen hat.
- Bei dieser Migration wurden 70 Module der neuen Architektur eingeführt (24 facades, 10 data services, 10 Theme-Konfigurationen und 26 shared views). Das beschreibt den Umfang der Umgestaltung, nicht eine Leistungssteigerung oder Code-Reduzierung.
- 2.4 MB → 17 KB ist ein Messwert aus production. Die drei kleinen CSS-Dateien der öffentlichen Demo reproduzieren diesen Wert nicht.
- Im selben Qualitätsprojekt stieg der Lighthouse Performance-Wert einer wichtigen Messseite von 50+ auf 90+. Das war jedoch das Ergebnis mehrerer Verbesserungen zusammen und nicht allein des bedarfsgesteuerten CSS-Ladens.
Das öffentliche Repo belegt die Architekturmechanismen selbst: typisierte Verträge, runtime-Wechsel, Tests auf Vollständigkeit der Theme-Familie, bedarfsgesteuertes CSS und die CI, die diese Prüfungen ausführt. Eine vollständige Gegenüberstellung der Aussagen mit ihren Belegen findet sich im Repo unter Audit der Beleggrenzen.
Wenn du den Effekt direkt sehen möchtest, öffne die Online-Demo, wechsle zwischen den drei Themes, Sprachen und Hell- beziehungsweise Dunkelmodus und vergleiche das mit dem Quellcode auf GitHub.