Articles
Modèle d’architecture pour plusieurs mises en page et plusieurs thèmes
Comment une application frontend peut-elle prendre en charge 100+ thèmes et plusieurs mises en page sans laisser la logique de personnalisation s’infiltrer dans les composants partagés ? Cet article détaille la séparation en core / view / theme leaf, les configs typées, les tests d’intégrité de la famille et le chargement à la demande du CSS des thèmes, ainsi que les compromis propres à chaque couche.
· Mis à jour · 10 min de lecture
J’ai été chargé de refondre l’architecture d’une grande plateforme frontend Nx / Angular. Elle devait prendre en charge 100+ thèmes et plusieurs mises en page. Il arrivait souvent que d’autres membres réalisent les sous-tâches liées aux mises en page et aux thèmes selon leurs propres habitudes de développement. Avec le temps, ces choix se sont accumulés et sont devenus une dette technique : un même besoin pouvait avoir plusieurs logiques d’implémentation.
Cet article explique clairement les principes de conception retenus à l’époque : comment organiser les couches, garantir l’intégrité de plus de 100 thèmes et faire en sorte que le navigateur ne télécharge que le CSS nécessaire. Le code original de production ne pouvant pas être publié, j’ai réimplémenté le tout avec du code original et trois thèmes fictifs dans frontend-engineering-case-studies, avec aussi une Démo en ligne que vous pouvez essayer. Tout le code présenté dans l’article provient de cette version publique.
Pour résumer : séparez ce qui détermine l’apparence de ce qui détermine le comportement, puis laissez le compilateur et les tests garantir la cohérence des 100+ thèmes.
Le problème ne se résume pas à changer les couleurs#
Le point de départ le plus courant pour gérer plusieurs thèmes consiste à ajouter des conditions dans un composant partagé. Par exemple, le thème A a besoin d’un bouton supplémentaire, tandis que le thème B doit rediriger vers une autre page pendant la campagne :
if (theme === 'a') {
showExtraButton = true;
} else if (theme === 'b' && isCampaign) {
redirectTo = '/campaign';
}
Avec trois thèmes, cette approche est rapide. Quand les thèmes se comptent par centaines, chaque composant partagé se retrouve chargé d’une multitude de branches qui « ne sont valables que pour tel thème ». Résultat :
- Une demande pour le thème A peut discrètement casser le thème B, sans que personne soit chargé de vérifier B.
- La même différence est implémentée une fois dans chaque composant, et chaque fois différemment.
- Quand on ajoute un thème, personne ne peut s’assurer que « tous les endroits nécessaires ont été complétés ».
Le problème à résoudre ne consiste donc pas seulement à changer les couleurs, mais à : permettre à chaque thème de différer par son apparence et ses fonctionnalités, tout en gardant toute la famille de thèmes complète et prévisible.
Distinguer d’abord : mise en page et thème sont deux choses différentes#
L’analogie avec une maison est la plus parlante :
- Structure et réseaux : poteaux, plomberie et électricité. Ils sont identiques dans chaque logement et ne devraient pas changer selon les préférences des occupants.
- Mise en page : le plan. Deux ou trois chambres, l’emplacement du salon. Elle détermine « comment les éléments sont disposés ».
- Thème : la décoration. Couleur des murs, matériaux, taille des coins arrondis. Il détermine « l’apparence des éléments ».
Dans un frontend, ces trois aspects correspondent à des couches différentes. Si on les mélange, on finit par « modifier le flux de données pour changer une couleur ».
Structure à quatre couches#
Runtime theme id
│
▼
selector ──► theme leaf (complete typed config)
│
▼
shared view ◄── data service
│ └──► actions facade
▼
pure core
De bas en haut :
core : se limite au rendu#
Les composants core ne reçoivent que des données « déjà préparées et directement affichables » et n’émettent que les intentions (intent) de l’utilisateur. Ils ignorent le thème actif, ne touchent ni au routing ni au format des réponses API et ne référencent aucune ressource de marque.
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>();
}
Comme core est partagé entre tous les thèmes et que sa portée est la plus large, ses règles sont les plus strictes : il ne reçoit que les entrées nécessaires et n’émet que des intentions.
view : se charge de la coordination#
view joue le rôle d’intermédiaire. Il récupère les données auprès du data service et les convertit au format attendu par core ; lorsque core émet une intention, il la transmet à 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 { /* ... */ }
L’origine des données (data service) et l’action déclenchée par un clic (actions facade) ont chacune une frontière clairement définie : elles ne sont pas dispersées dans les composants d’affichage.
La mise en page a sa place à ce niveau. Des agencements différents peuvent prendre la forme de différents shared view. Ils s’appuient sur le même data service et le même facade ; seule la composition de core change. Pour simplifier, la Démo publique n’a qu’un shared view ; la refonte production en a introduit 26.
theme leaf : une feuille par thème#
Chaque thème est un composant très léger, qui ne fait qu’une seule chose : choisir une config d’apparence complète et la transmettre au 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;
}
Il ne contient aucune logique métier. Dans production, après refonte, un theme leaf complet représentatif comptait 23 lignes ; l’ancien code sur le même parcours en comptait plusieurs centaines.
selector : liste explicitement les membres de la famille#
selector rend le leaf correspondant à partir de l’identifiant de thème du runtime :
@switch (theme()) {
@case ('aurora') { <case-study-theme-aurora /> }
@case ('summit') { <case-study-theme-summit /> }
@default { <case-study-theme-default /> }
}
L’implémentation semble ordinaire ; l’essentiel est qu’elle soit « explicite » : on voit d’un coup d’œil les thèmes existants et le leaf associé à chacun, et les tests peuvent les vérifier un par un.
Laisser le compilateur vérifier la liste#
Plus les thèmes sont nombreux, plus les oublis sont à craindre. On ajoute un thème et on oublie sa config ; on modifie le format de la config et quelques anciens thèmes ne suivent pas.
La méthode consiste d’abord à définir la liste des thèmes comme source unique, puis à utiliser le satisfies de TypeScript pour exiger une config complète pour chaque thème :
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>;
Si DEMO_THEMES contient un thème supplémentaire et que THEME_APPEARANCES ne le renseigne pas, la compilation échoue. Même chose si un champ est mal orthographié ou si le format du chemin CSS est incorrect.
L’avantage de satisfies par rapport à une annotation de type directe est qu’il vérifie la structure tout en préservant le type concret de chaque valeur. Ainsi, l’accès ultérieur à THEME_APPEARANCES.aurora ne sera pas élargi au type générique ThemeAppearance.
Garantir l’intégrité de toute la famille par les tests#
Le compilateur peut vérifier la config, mais pas que « ce thème se rend réellement ». C’est le rôle des tests d’intégrité de la famille : ils énumèrent un à un tous les thèmes déclarés :
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();
});
La config a aussi son test correspondant : le champ id de chaque config de thème doit correspondre à la clé de cette config dans la table ; les chemins CSS doivent également respecter les règles de la famille.
Ensemble, ces deux vérifications transforment le travail qui reposait jusque-là sur une revue humaine pour vérifier si des thèmes frères avaient été oubliés en une erreur bloquée par CI. À l’échelle de plus de 100 thèmes, l’œil humain ne peut pas tout vérifier.
Ne télécharger que le CSS du thème utilisé#
La séparation en couches résout les problèmes de maintenance, mais la multiplication des thèmes a aussi un coût direct pour l’utilisateur : le CSS.
À l’origine, les 136 stylesheet des thèmes étaient regroupées dans le même bundle ; quel que soit le thème utilisé, l’utilisateur devait tout télécharger et tout analyser. Après le passage au chargement à la demande, les ressources d’un thème sont passées d’environ 2.4 MB à 17 KB, soit une réduction d’environ 99%.
Le mécanisme est simple : chaque config de thème déclare son propre stylesheetHref, et un seul loader gère l’unique <link> de thème sur la page :
@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 plateforme l’appelle lors du changement de thème :
effect(() => {
const appearance = THEME_APPEARANCES[this.selectedTheme()];
this.#stylesheetLoader.load(appearance.stylesheetHref);
});
Le mot « unique » est essentiel. Si chaque partie ajoute son propre <link>, des styles de l’ancien thème peuvent subsister après plusieurs changements, ou deux fichiers CSS peuvent se remplacer mutuellement. Les tests vérifient qu’après plusieurs changements, la page ne contient toujours qu’un seul stylesheet géré.
Le CSS du thème ne définit que des design token (variables CSS), tandis que les styles de core ne font que lire ces variables :
:root {
--demo-surface: #fffaf1;
--demo-text: #3b2620;
--demo-accent: #c65367;
--demo-radius: 30px;
}
Changer de thème revient donc à remplacer une table de variables ; les styles de core restent inchangés. Le mode clair/sombre correspond à un autre jeu de variables dans le même fichier.
Compromis#
Cette conception a un coût :
- Plus de fichiers. Un leaf par thème et un view par mise en page augmentent le nombre de petits fichiers. En contrepartie, la responsabilité de chaque fichier est évidente et son intégrité peut être vérifiée.
- La config ne doit pas devenir un langage de programmation. config décrit bien l’apparence et les différences encadrées. Dès qu’on y écrit des conditions ou des processus, on ne fait que déplacer les if/else pour les cacher ailleurs.
- core nécessite des tests de contrat plus rigoureux. Il est partagé par tous les thèmes ; une modification en affecte plus de 100.
- Le changement de CSS au runtime exige un owner unique. Des demandes de stylesheet sans coordination provoquent des clignotements ou la persistance d’anciens styles ; c’est aussi pourquoi le loader doit être unique.
- Le cache reste important. Même si un seul thème est chargé, la stabilité des noms de fichiers et le cache header déterminent toujours la vitesse lors des visites suivantes.
Dans quels cas cela vaut la peine#
Si votre produit ne propose que deux modes, clair et sombre, les variables CSS suffisent ; toute cette architecture est inutile.
Ces signes indiquent qu’il est temps d’y réfléchir :
- Vous avez plus de dix thèmes ou versions client, et leur nombre continue d’augmenter.
- Les différences entre thèmes ne concernent pas seulement les couleurs, mais aussi la mise en page, les fonctionnalités et les textes.
- Une demande concernant un thème a souvent des effets inattendus sur un autre.
- Quand vous ajoutez un thème, personne ne sait quels endroits doivent être complétés.
Périmètre des preuves#
Il faut préciser le périmètre des chiffres de production mentionnés dans cet article :
- 23 lignes désigne un theme leaf complet représentatif après refonte ; cela ne signifie pas que chaque leaf ou chaque fichier fait 23 lignes.
- Cette migration a introduit 70 modules de la nouvelle architecture (24 facade, 10 data service, 10 configs de thème, 26 shared view). Il s’agit du périmètre de la refonte, pas d’un gain de performance ou d’une réduction du code.
- 2.4 MB → 17 KB est une mesure effectuée en production. Les trois petites feuilles CSS de la Démo publique ne reproduiront pas ce chiffre.
- Dans le même projet d’amélioration de la qualité, le score Lighthouse Performance d’une page de mesure clé est passé de 50+ à 90+, mais ce résultat cumule plusieurs améliorations et ne vient pas du seul chargement à la demande du CSS.
Le dépôt public démontre les mécanismes de l’architecture elle-même : contrats typés, changement au runtime, tests d’intégrité de la famille, CSS chargé à la demande et CI qui exécute ces vérifications. Le tableau complet de correspondance entre affirmations et preuves se trouve dans l’audit des limites des preuves.
Pour voir le résultat directement, ouvrez la Démo en ligne, passez d’un des trois thèmes à l’autre, changez de langue et de mode clair/sombre, puis comparez avec le code source sur GitHub.