Artykuły
Wzorce projektowe architektury wielu układów i wielu motywów
Jak podzielić frontend, który ma obsłużyć ponad 100 motywów i wiele układów, tak by logika dostosowań nie przeciekała do wspólnych komponentów? Ten tekst rozbiera podział na warstwy core / view / theme leaf, typowaną konfigurację, testy kompletności rodziny i ładowanie CSS motywu na żądanie, a także kompromisy każdej warstwy.
· Zaktualizowano · 8 min czytania
Kiedyś odpowiadałem za przebudowę architektury dużej platformy frontendowej w Nx / Angular. Miała obsługiwać ponad 100 motywów i kilka układów, a inni członkowie zespołu często realizowali swoje podzadania związane z wieloma układami i motywami w sposób zgodny z własnymi nawykami programistycznymi. Z czasem to się kumulowało w dług techniczny: to samo wymaganie miało jednocześnie wiele różnych implementacji.
W tym tekście wyjaśniam kluczowe założenia projektu z tamtego czasu: jak podzielić warstwy, jak sprawić, by ponad sto motywów pozostawało kompletnych, i jak sprawić, by przeglądarka pobierała tylko potrzebny CSS. Oryginalnego kodu produkcyjnego nie wolno ujawniać, więc zaimplementowałem to od nowa na własnym, oryginalnym kodzie i trzech fikcyjnych motywach, w repozytorium frontend-engineering-case-studies; dostępne jest też demo online, w którym można od razu wszystko wypróbować. Cały kod w tekście pochodzi z tej publicznej wersji.
Najpierw wniosek: rozdziel „jak to wygląda” od „co to robi”, a potem niech kompilator i testy pilnują spójności ponad stu motywów.
Problem to nie zmiana kolorów#
Najczęstszy początek wielu motywów to dodawanie warunków we wspólnym komponencie. Na przykład motyw A ma mieć dodatkowy przycisk, a motyw B w czasie kampanii ma przekierowywać na inną stronę:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Przy trzech motywach tak pisze się szybko. Gdy motywów jest sto, każdy wspólny komponent dźwiga stos gałęzi, które „obowiązują tylko dla jednego motywu”. Skutki:
- Zmiana wymagania dla motywu A może po cichu zepsuć motyw B, a nikt nie odpowiada za sprawdzenie B.
- Ta sama różnica jest zapisana osobno w różnych komponentach, w dodatku na różne sposoby.
- Przy dodawaniu nowego motywu nikt nie ma pewności, że „uzupełniono wszystkie potrzebne miejsca”.
Do rozwiązania jest więc nie tylko zmiana kolorów, ale to, jak pozwolić każdemu motywowi różnić się wyglądem i produktem, a jednocześnie zachować kompletność i przewidywalność całej rodziny motywów.
Najpierw rozróżnij: układ i motyw to dwie różne sprawy#
Najłatwiej zrozumieć to na przykładzie domu:
- Struktura i instalacje: słupy, woda i prąd. Takie same w każdym mieszkaniu i nie powinny się zmieniać z powodu upodobań mieszkańców.
- Układ: rozkład pomieszczeń. Dwa pokoje czy trzy, gdzie jest salon. Decyduje o tym, „jak elementy są ułożone”.
- Motyw: wykończenie. Kolor ścian, materiały, wielkość zaokrągleń. Decyduje o tym, „jak elementy wyglądają”.
We froncie te trzy sprawy odpowiadają różnym warstwom. Gdy się je pomiesza, pojawia się sytuacja, w której „żeby zmienić kolor, trzeba zmieniać przepływ danych”.
Struktura czterech warstw#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Patrząc od dołu do góry:
core: tylko rysuje#
Komponent core otrzymuje wyłącznie dane „już przygotowane i gotowe do wyrenderowania” i wypuszcza na zewnątrz tylko intencje (intent) użytkownika. Nie wie, jaki to motyw, nie dotyka routingu ani formatu odpowiedzi API i nie odwołuje się do żadnych zasobów marki.
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 jest współdzielony przez wszystkie motywy i ma największy zasięg wpływu, dlatego jego reguły są najsurowsze: na wejściu tylko to, co niezbędne, na wyjściu wyłącznie intencje.
view: koordynuje#
View jest pośrednikiem. Pobiera dane z data service i przekształca je do postaci, jakiej potrzebuje core; gdy core wyrzuca intencję, przekazuje ją do 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 { /* ... */ }
Skąd pochodzą dane (data service) i co ma się stać po kliknięciu (actions facade) mają własne, wyraźne granice i nie rozpraszają się po komponentach widoku.
Układy dobrze pasują do tej warstwy. Różne rozkłady mogą być różnymi shared view, które korzystają z tego samego data service i facade, a tylko składają core w inny sposób. Publiczne demo ma dla prostoty jeden shared view; produkcyjna przebudowa wprowadziła ich 26.
theme leaf: jeden liść na motyw#
Każdy motyw to bardzo cienki komponent, który robi tylko jedno: wybiera kompletną konfigurację wyglądu i przekazuje ją do 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;
}
W środku nie ma żadnych warunków biznesowych. W produkcji jeden reprezentatywny, kompletny theme leaf miał po refaktoryzacji 23 linie; ta sama ścieżka w starym zapisie miała kilkaset linii.
selector: jawnie wylicza członków rodziny#
Selector renderuje odpowiedni leaf na podstawie runtime'owego id motywu:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
Wygląda zwyczajnie, ale najważniejsza jest „jawność”: widać na pierwszy rzut oka, jakie są motywy i do którego leaf każdy się odwołuje, i można to sprawdzić testami jeden po drugim.
Niech kompilator robi apel#
Gdy motywów jest dużo, najgorsze są braki. Dodajesz motyw i zapominasz o konfiguracji; zmieniasz format konfiguracji i kilka starych motywów za tym nie nadąża.
Rozwiązanie polega na tym, by najpierw zapisać listę motywów jako jedno źródło prawdy, a potem użyć satisfies z TypeScriptu, by wymusić kompletną konfigurację dla każdego motywu:
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>;
Jeśli w DEMO_THEMES przybędzie motyw, a w THEME_APPEARANCES go zabraknie, kompilacja się nie powiedzie. Tak samo przy literówce w polu czy złym formacie ścieżki CSS.
Zaletą satisfies w porównaniu z bezpośrednim oznaczeniem typu jest to, że sprawdza kształt, ale zachowuje konkretny typ każdej wartości, więc późniejsze użycie THEME_APPEARANCES.aurora nie jest rozszerzane do ogólnego ThemeAppearance.
Niech testy pilnują całej rodziny#
Kompilator obejmuje konfigurację, ale nie obejmuje tego, czy „ten motyw faktycznie da się wyrenderować”. Tym zajmuje się test kompletności rodziny, który wylicza po kolei każdy zadeklarowany motyw:
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();
});
Po stronie konfiguracji też są odpowiednie testy: pole id każdej konfiguracji motywu musi być równe jej kluczowi w tabeli konfiguracji, a ścieżka CSS musi spełniać regułę rodziny.
Razem te dwie kontrole zamieniają ręczny review „czy sibling motywy czegoś nie pominęły” w błąd, który zatrzyma CI. Przy ponad stu motywach oko ludzkie tego nie ogarnie.
Pobieraj tylko CSS motywu, którego używasz#
Podział na warstwy rozwiązuje problem utrzymania, ale wiele motywów ma też koszt, który uderza bezpośrednio w użytkownika: CSS.
Pierwotnie wszystkie 136 arkuszy stylów motywów były pakowane razem, więc użytkownik niezależnie od motywu pobierał i parsował wszystko. Po przejściu na ładowanie na żądanie zasób pojedynczego motywu zmalał z ok. 2,4 MB do 17 KB, czyli o około 99%.
Sam mechanizm jest prosty: każda konfiguracja motywu deklaruje swój stylesheetHref, a jeden jedyny loader zarządza jedynym <link> motywu na stronie:
@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);
}
}
Platforma wywołuje go przy zmianie motywu:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
Kluczowe jest słowo „jedyny”. Jeśli każde miejsce wstawia własny <link>, po wielokrotnym przełączaniu zostają style starego motywu albo dwa pliki CSS nadpisują się nawzajem. Test sprawdza, że po kilkukrotnym przełączeniu na stronie nadal jest tylko jeden zarządzany arkusz stylów.
Sam CSS motywu definiuje wyłącznie design token (zmienne CSS), a style core czytają tylko te zmienne:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Zmiana motywu to więc tylko zmiana tabeli zmiennych, a styli core w ogóle nie trzeba ruszać. Tryb jasny i ciemny to kolejny zestaw zmiennych w tym samym pliku.
Kompromisy#
Ten projekt nie jest za darmo:
- Plików przybywa. Jeden leaf na motyw, jeden view na układ, więc liczba małych plików rośnie. W zamian odpowiedzialność każdego pliku widać od razu, a kompletność można sprawdzać.
- Konfiguracja nie może stać się językiem programowania. Config nadaje się do opisu wyglądu i ograniczonych różnic. Gdy zaczynasz pisać w konfiguracji warunki i przepływy, to tylko przenosisz dawne if/else w inne miejsce.
- Core potrzebuje silniejszych testów kontraktowych. Jest współdzielony przez wszystkie motywy, więc jedna zmiana wpływa na ponad sto motywów.
- Przełączanie CSS w runtime wymaga jednego właściciela. Nieskoordynowane żądania arkuszy stylów powodują migotanie lub pozostałości, dlatego loader musi być jeden.
- Cache nadal jest ważny. Nawet gdy ładuje się tylko jeden motyw, stabilne nazwy plików i nagłówki cache nadal decydują o szybkości przy ponownej wizycie.
Kiedy warto to robić#
Jeśli twój produkt ma tylko tryb jasny i ciemny, wystarczą zmienne CSS i nie potrzebujesz całego tego zestawu.
Gdy zaczynasz napotykać poniższe sytuacje, warto to rozważyć:
- Motywów lub wersji dla klientów jest ponad dziesięć i wciąż przybywa.
- Różnice między motywami to nie tylko kolory, ale też układ, przełączniki funkcji i teksty.
- Zmiana wymagania dla jednego motywu często niespodziewanie wpływa na inny.
- Przy dodawaniu motywu nikt nie ma pewności, co jeszcze trzeba uzupełnić.
Granice dowodów#
Liczby produkcyjne wspomniane w tym tekście mają określony zakres:
- 23 linie to jeden reprezentatywny, kompletny theme leaf po refaktoryzacji; nie znaczy to, że każdy leaf i każdy plik ma 23 linie.
- Ta migracja wprowadziła 70 modułów nowej architektury (24 facade, 10 data service, 10 konfiguracji motywów, 26 shared view). To zakres przebudowy, a nie wynik w wydajności ani redukcji kodu.
- 2,4 MB → 17 KB to pomiar produkcyjny. Trzy małe pliki CSS w publicznym demie nie odtworzą tej liczby.
- W tym samym projekcie jakościowym Lighthouse Performance kluczowych, mierzonych stron wzrósł z 50+ do 90+, ale to suma wielu usprawnień, a nie zasługa samego ładowania CSS na żądanie.
Publiczne repozytorium dowodzi samego mechanizmu architektury: typowanych kontraktów, przełączania w runtime, testów kompletności rodziny, CSS na żądanie oraz CI, które to wszystko uruchamia. Pełna tabela porównawcza twierdzeń znajduje się w audycie granic dowodów w repozytorium.
Jeśli chcesz zobaczyć efekt od razu, otwórz demo online, przełącz trzy motywy, języki i tryb jasny lub ciemny, a potem porównaj z kodem źródłowym na GitHubie.