Articoli
Pattern architetturale per layout e temi multipli
Come strutturare un frontend per supportare 100+ temi e più layout senza far finire la logica di personalizzazione nei componenti condivisi? In questo articolo analizzo la suddivisione in core / view / theme leaf, la configurazione tipizzata, i test di integrità della famiglia e il caricamento on demand del CSS dei temi, insieme ai compromessi di ciascun livello.
· Aggiornato · 9 min di lettura
In passato mi sono occupato della ristrutturazione architetturale di una grande piattaforma frontend Nx / Angular. Doveva supportare oltre cento temi e diversi layout. Spesso altri membri completavano le attività secondarie di quel lavoro seguendo le proprie abitudini di sviluppo; col tempo, questo accumulo diventava debito tecnico: la stessa esigenza finiva per avere contemporaneamente diverse logiche di implementazione.
In questo articolo spiego nel dettaglio le scelte progettuali fondamentali di allora: come suddividere i livelli, come mantenere completi oltre cento temi e come fare in modo che il browser scarichi solo il CSS necessario. Il codice production originale non può essere reso pubblico, quindi ho ricreato l'architettura da zero con codice originale e tre temi fittizi, pubblicandola in frontend-engineering-case-studies; è disponibile anche una Demo online interattiva. Tutto il codice mostrato nell'articolo proviene da questa versione pubblica.
Parto dalla conclusione: separa «come appare» da «cosa fa», poi lascia che compilatore e test mantengano coerenti oltre cento temi.
Il problema non è cambiare colore#
Il punto di partenza più comune per gestire più temi è aggiungere condizioni all’interno di un componente condiviso. Per esempio, il tema A ha bisogno di un pulsante in più, mentre il tema B deve rimandare a un’altra pagina durante la campagna:
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Con solo tre temi, è una soluzione rapida. Quando i temi diventano cento, ogni componente condiviso si porta dietro una serie di rami che «valgono solo per un certo tema». Le conseguenze sono:
- Una modifica richiesta per il tema A può compromettere di nascosto il tema B, senza che nessuno sia incaricato di controllarlo.
- La stessa differenza viene implementata più volte in componenti diversi, e ogni volta in modo diverso.
- Quando si aggiunge un tema, nessuno può essere certo di aver aggiornato tutti i punti necessari.
Quindi il problema non è solo cambiare colore: dobbiamo consentire a ogni tema di differire nell'aspetto e nel prodotto, mantenendo al tempo stesso l'intera famiglia completa e prevedibile.
Prima distinguiamo: layout e tema sono due cose diverse#
L'analogia con una casa è la più semplice:
- Struttura e impianti: pilastri, elettricità e tubature. Sono uguali in ogni appartamento e non dovrebbero cambiare in base ai gusti di chi ci abita.
- Layout: la planimetria. Due o tre camere, la posizione del soggiorno. Determina «come sono disposti gli elementi».
- Tema: l'arredamento. Colore delle pareti, materiali, dimensione degli angoli arrotondati. Determina «che aspetto hanno gli elementi».
Nel frontend, questi tre aspetti corrispondono a livelli diversi. Se li mescoliamo, finiamo per «modificare il flusso dei dati solo per cambiare un colore».
Struttura a quattro livelli#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
Dal basso verso l'alto:
core: si occupa solo del rendering#
I componenti core ricevono solo dati già pronti per essere renderizzati e inviano all'esterno soltanto le intenzioni dell'utente (intent). Non sanno quale tema è attivo, non gestiscono routing né formati delle risposte API e non fanno riferimento ad asset di alcun brand.
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 è condiviso da tutti i temi e ha quindi il raggio d'impatto più ampio; per questo le sue regole sono le più rigide: riceve solo ciò che serve e restituisce solo intent.
view: si occupa del coordinamento#
view fa da intermediario. Recupera i dati dal data service e li trasforma nella forma richiesta da core; quando core emette un intent, lo passa agli 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 { /* ... */ }
La provenienza dei dati (data service) e l'azione da eseguire al clic (actions facade) hanno confini chiari e non sono disseminate nei componenti dell'interfaccia.
È a questo livello che conviene collocare i layout. Layout diversi possono avere shared view diversi: usano lo stesso data service e facade, ma compongono core in disposizioni differenti. Per semplicità, la Demo pubblica ha un solo shared view; nella ristrutturazione production ne sono stati introdotti 26.
theme leaf: un nodo foglia per ogni tema#
Ogni tema è un componente molto sottile che fa una sola cosa: seleziona una configurazione completa dell'aspetto e la passa a 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;
}
Non contiene alcuna logica di business. In production, dopo la ristrutturazione, un theme leaf completo rappresentativo era composto da 23 righe; il vecchio approccio ne richiedeva diverse centinaia per lo stesso percorso.
selector: elenca esplicitamente i membri della famiglia#
selector renderizza il leaf corrispondente in base all'id del tema nel runtime:
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
A prima vista sembra una cosa semplice; il punto è che tutto è «esplicito»: si vedono subito i temi disponibili e il leaf associato a ciascuno, e i test possono controllarli uno per uno.
Facciamo sì che il compilatore li controlli uno per uno#
Con tanti temi, il rischio maggiore è dimenticarne qualcuno. Si aggiunge un tema e si dimentica la configurazione; si modifica il formato della configurazione e qualche tema esistente resta indietro.
Per prima cosa definiamo l'elenco dei temi come unica fonte, poi usiamo TypeScript e satisfies per richiedere una configurazione completa per ogni tema:
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>;
Se aggiungi un tema in DEMO_THEMES senza aggiornare THEME_APPEARANCES, la compilazione fallisce. Lo stesso vale per un campo scritto male o un formato errato per il percorso CSS.
Il vantaggio di satisfies rispetto a un'annotazione di tipo diretta è che controlla la struttura, ma conserva il tipo specifico di ciascun valore; così, quando si usa THEME_APPEARANCES.aurora, il tipo non viene ampliato al generico ThemeAppearance.
Lasciamo che i test proteggano l'intera famiglia#
Il compilatore può controllare la configurazione, ma non può verificare che il tema venga davvero renderizzato. A questo servono i test di integrità della famiglia, che enumerano ogni tema dichiarato:
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();
});
Anche le configurazioni hanno un test corrispondente: il campo id di ogni configurazione deve coincidere con la relativa chiave nella tabella delle configurazioni; anche i percorsi CSS devono rispettare le regole della famiglia.
Insieme, questi controlli trasformano il lavoro di verifica manuale in review per controllare che non manchi un «tema fratello» in errori che CI blocca. Una persona non può controllare a vista oltre cento temi.
Scarica solo il CSS del tema che stai usando#
La suddivisione in livelli risolve i problemi di manutenzione, ma con molti temi c'è anche un costo che ricade direttamente sugli utenti: il CSS.
In origine, tutti i 136 stylesheet dei temi venivano inclusi nello stesso bundle: qualunque tema usasse, l'utente doveva scaricarli e analizzarli tutti. Con il caricamento on demand, le risorse di un singolo tema sono passate da circa 2.4 MB a 17 KB, una riduzione di circa il 99%.
Il meccanismo è semplice: ogni configurazione del tema dichiara il proprio stylesheetHref e un unico loader gestisce l'<link> del tema attivo nella pagina:
@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);
}
}
La piattaforma lo chiama quando si cambia tema:
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
«Unico» è la parola chiave. Se ogni punto inserisce il proprio <link>, dopo vari cambi di tema possono restare gli stili del tema precedente oppure due file CSS possono sovrascriversi a vicenda. I test verificano che, anche dopo diversi cambi, nella pagina resti un solo stylesheet gestito.
Il CSS del tema definisce solo design token (variabili CSS); gli stili di core leggono soltanto queste variabili:
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Perciò cambiare tema significa solo sostituire una tabella di variabili, senza toccare gli stili di core. La modalità chiara e quella scura usano un altro gruppo di variabili nello stesso file.
Compromessi#
Questo approccio ha un costo:
- Più file. Un leaf per tema e una view per layout aumentano il numero di file piccoli. In cambio, la responsabilità di ciascun file è subito evidente e la completezza si può verificare.
- La configurazione non deve diventare un linguaggio di programmazione. config va bene per descrivere l'aspetto e differenze ben delimitate. Se iniziamo a inserirvi condizioni e flussi, stiamo solo nascondendo gli if/else da un'altra parte.
- core richiede test dei contratti più rigorosi. È condiviso da tutti i temi, quindi una modifica ha effetto su oltre cento temi.
- Il cambio del CSS nel runtime richiede un unico owner. Le richieste di stylesheet non coordinate possono causare sfarfallii o stili residui; è per questo che il loader deve essere unico.
- La cache resta importante. Anche caricando un solo tema, nomi file stabili e cache header determinano comunque la velocità delle visite successive.
Quando vale la pena adottarlo#
Se il tuo prodotto ha soltanto una modalità chiara e una scura, bastano le variabili CSS: non serve tutta questa struttura.
Vale la pena prenderla in considerazione quando inizi a incontrare situazioni come queste:
- Hai più di dieci temi o versioni per cliente, e continuano ad aumentare.
- Le differenze tra i temi non riguardano solo i colori, ma anche layout, feature flag e testi.
- Le modifiche richieste per un tema finiscono spesso per avere effetti imprevisti su un altro.
- Quando si aggiunge un tema, nessuno sa con certezza quali punti vadano aggiornati.
Limiti delle evidenze#
È importante chiarire l'ambito dei dati production citati nell'articolo:
- 23 righe si riferiscono a un theme leaf completo rappresentativo dopo la ristrutturazione, non a tutti i leaf o a tutti i file.
- Quella migration ha introdotto 70 moduli della nuova architettura (24 facade, 10 data service, 10 configurazioni di tema, 26 shared view). Questo indica l'ambito della ristrutturazione, non un risultato in termini di prestazioni o riduzione del codice.
- 2.4 MB → 17 KB è una misurazione production. I tre piccoli file CSS della Demo pubblica non riprodurranno questo dato.
- Nello stesso progetto orientato alla qualità, il punteggio Lighthouse Performance della pagina chiave misurata è passato da 50+ a 90+, ma è il risultato complessivo di diversi miglioramenti, non del solo caricamento on demand del CSS.
Il repo pubblico dimostra i meccanismi architetturali in sé: contratti tipizzati, cambio nel runtime, test di integrità della famiglia, CSS on demand e CI che esegue questi controlli. La tabella completa che mette a confronto le affermazioni con le evidenze è nell'audit dei limiti delle evidenze del repo.
Per vedere il risultato, apri la Demo online, prova i tre temi, le lingue e le modalità chiara e scura, poi confrontala con il codice sorgente su GitHub.