Le moteur de templates Angular a profondément changé depuis la version 17. Les anciennes directives structurelles *ngIf, *ngFor et *ngSwitch coexistent encore avec les nouveaux blocs @if, @for et @switch, mais c’est la nouvelle syntaxe qui pilote désormais les décisions d’architecture. Ce tutoriel détaille la migration et présente surtout le bloc @defer, l’outil qui découpe un bundle en morceaux chargés à la demande selon des déclencheurs précis (idle, viewport, interaction, hover, timer). À la fin de ce parcours pas à pas, vous disposerez d’un projet réel qui charge ses composants lourds uniquement quand l’utilisateur en a besoin.
Prérequis
- Node.js 20 LTS ou plus récent et Angular CLI installé (
npm install -g @angular/cli). - Un projet Angular 17 minimum (Angular 20 ou 21 recommandé pour la stabilité du contrôle de flux et de l’hydratation incrémentale).
- Une connaissance des composants standalone : si vos modules utilisent encore
NgModule, prévoyez de basculer en standalone avant de tirer parti pleinement de@defer. - Environ 45 minutes pour suivre la totalité du tutoriel et exécuter chaque commande.
Étape 1 — Mettre à jour Angular et migrer la syntaxe
Avant d’écrire la moindre ligne de code, il faut s’assurer que la version du framework supporte le nouveau contrôle de flux comme syntaxe par défaut. Le bloc @if est arrivé en preview développeur en Angular 17 puis a été stabilisé. Depuis Angular 20, il prend la priorité sur les directives historiques et la CLI propose un schematic de migration automatique. Cette migration est sûre car elle préserve la sémantique : un *ngIf="user as u" devient un @if (user; as u) et conserve l’alias dans la portée du bloc.
# Mettre à jour le projet vers la version cible
ng update @angular/core @angular/cli
# Lancer la migration du contrôle de flux sur l'ensemble du code
ng generate @angular/core:control-flow
La sortie console liste chaque fichier touché avec un compte d’occurrences converties. Lors d’un projet de taille moyenne (50 composants environ), comptez quelques secondes d’exécution. À l’issue, ouvrez un de vos templates pour observer le résultat : les *ngIf ont disparu et la lecture devient plus naturelle. Si la migration laisse des cas non convertis, un commentaire // TODO: control flow est inséré pour signaler une logique manuelle à reprendre.
Étape 2 — Conditions avec @if et @else
Le bloc @if remplace *ngIf avec une lisibilité supérieure et un support natif de @else if. Là où l’ancienne syntaxe forçait à enchaîner trois templates et un ng-container, le nouveau bloc se lit comme un if JavaScript. L’alias as reste disponible, ce qui évite les évaluations multiples d’un signal ou d’un getter coûteux.
@Component({
selector: 'app-status',
template: `
@if (user(); as currentUser) {
<p>Bonjour {{ currentUser.name }}.</p>
} @else if (loading()) {
<p>Chargement de votre profil…</p>
} @else {
<p>Aucun utilisateur connecté.</p>
}
`,
})
export class StatusComponent {
user = inject(UserStore).current;
loading = inject(UserStore).loading;
}
Trois branches, trois rendus distincts, sans le moindre ng-template. Notez l’appel user() : on travaille avec un signal Angular, son invocation déclenche la lecture réactive et inscrit le composant dans le graphe de dépendances. Le rendu se mettra à jour automatiquement quand user change. Pour vérifier que la conversion s’est bien passée, lancez ng build en mode développement et observez l’absence de warning dans la console : un warning de compilation du type NG8003 (référence à une directive sans exportAs ou import manquant) doit être traité avant de continuer.
Étape 3 — Boucles avec @for et la clé track obligatoire
Le bloc @for apporte un changement majeur : la fonction track n’est plus optionnelle. Angular refuse de compiler une boucle qui ne précise pas comment identifier ses items, et c’est une bonne nouvelle pour la performance. Sans track, le rendu d’une liste de 200 lignes recréait potentiellement chaque nœud DOM à chaque mise à jour. Avec une clé stable, seules les entrées modifiées sont retouchées.
template: `
<ul>
@for (article of articles(); track article.id; let idx = $index, isLast = $last) {
<li [class.last]="isLast">{{ idx + 1 }} — {{ article.title }}</li>
} @empty {
<li>Aucun article disponible.</li>
}
</ul>
`,
Trois éléments sont à observer ici. D’abord, track article.id indique à Angular comment relier une donnée à un nœud DOM existant ; si l’ordre change ou qu’un élément est inséré au milieu de la liste, le diff reste minimal. Ensuite, le bloc @empty remplace les anciens enchaînements *ngIf="!list.length", ce qui rend l’intention explicite. Enfin, les variables contextuelles $index, $first, $last, $even, $odd et $count peuvent être renommées avec let pour éviter les collisions de noms.
Étape 4 — Aiguillage avec @switch
Le bloc @switch remplace ngSwitch en utilisant une comparaison stricte (===) et sans fall-through implicite. C’est l’outil de choix pour rendre différentes vues selon un état fini : statut d’une commande, rôle d’un utilisateur, étape d’un onboarding. Le bloc @default joue le rôle du default JavaScript et capture toutes les valeurs non listées explicitement.
template: `
@switch (order().status) {
@case ('pending') { <app-pending /> }
@case ('shipped') { <app-shipped /> }
@case ('delivered') { <app-delivered /> }
@default { <app-unknown /> }
}
`,
Le compilateur Angular vérifie chaque branche au moment du build et signale les valeurs invalides quand le type de order().status est une union TypeScript. Cela permet d’attraper à la compilation un statut oublié quand vous ajoutez par exemple cancelled au type initial. Pour confirmer ce comportement, ajoutez temporairement cancelled à votre type, sauvez, et observez le warning du compilateur Angular qui pointe le @switch incomplet.
Étape 5 — Découper le bundle avec @defer
Le bloc @defer est la pièce maîtresse de ce tutoriel. Son rôle est de retirer un composant — et tout son arbre de dépendances — du bundle principal pour ne le charger qu’au moment opportun. Vous obtenez un fichier JavaScript séparé qui sera fetché à la demande, ce qui réduit drastiquement le poids du premier rendu. C’est particulièrement utile pour des composants riches : éditeur de texte, carte interactive, graphique avec une bibliothèque lourde, formulaire d’administration vu par peu d’utilisateurs.
template: `
<h1>Tableau de bord</h1>
<app-resume />
@defer (on viewport) {
<app-graphique-historique />
} @placeholder (minimum 500ms) {
<div class="skeleton">Préparation du graphique…</div>
} @loading (after 100ms; minimum 1s) {
<app-spinner />
} @error {
<p>Impossible de charger ce module.</p>
}
`,
Examinez les quatre sous-blocs. @placeholder apparaît avant que la condition de chargement ne soit satisfaite, avec une durée minimale d’affichage pour éviter le flash visuel. @loading s’affiche pendant le fetch effectif du chunk, en respectant un délai d’apparition (after 100ms évite de montrer un spinner pour un chargement trop rapide). @error protège contre une erreur réseau ou une rupture de bundle. Compilez le projet : ng build produit désormais un fichier chunk-XXXX.js séparé pour chaque @defer détecté, visible dans le rapport de build.
Étape 6 — Choisir le bon déclencheur
Le choix du déclencheur change radicalement l’expérience perçue. Angular propose six options principales : on idle, on viewport, on interaction, on hover, on immediate et on timer(duration). Chacune répond à un cas d’usage différent. Voici la matrice de décision que nous appliquons en production.
| Déclencheur | Quand l’utiliser | Exemple concret |
|---|---|---|
on idle |
Composant utile mais non critique au premier rendu | Widget de chat support en bas de page |
on viewport |
Composant lourd visible plus bas | Carte interactive en pied d’article |
on interaction |
Composant déclenché par un clic ou un focus | Modale d’édition, panneau latéral |
on hover |
Préfetch sur survol, exécution sur clic | Menu d’aperçu, info-bulle riche |
on immediate |
Décharger du bundle sans retarder l’affichage | Footer générique partagé |
on timer(2s) |
Composant promotionnel non bloquant | Bannière d’abonnement newsletter |
Une règle simple : si le composant est visible immédiatement, ne le mettez pas dans un @defer. Le déferrement n’a de sens que pour ce qui peut attendre. Pour une page produit, on peut combiner deux logiques en chaînant les triggers : @defer (on viewport; on idle) charge dès qu’un des deux événements se produit, ce qui couvre à la fois l’utilisateur qui scroll et celui qui reste statique.
Étape 7 — Préfetcher pour masquer la latence
Sur une connexion lente, le téléchargement d’un chunk au moment du clic produit une attente perceptible. La directive prefetch on contourne ce problème en téléchargeant le chunk avant son utilisation, sans pour autant le monter dans le DOM. Le navigateur garde le fichier en cache HTTP et l’exécutera instantanément quand le vrai déclencheur arrivera.
template: `
@defer (on interaction; prefetch on hover) {
<app-modal-edition [item]="selected()" />
} @placeholder {
<button (click)="open()">Modifier</button>
}
`,
Le scénario est éloquent. L’utilisateur survole le bouton, le chunk est téléchargé en arrière-plan. Quelques centaines de millisecondes plus tard, il clique : le module est déjà en cache, la modale apparaît sans le moindre délai. Pour mesurer le gain, ouvrez l’onglet Network du navigateur, simulez une connexion 3G lente, et comparez les temps de chargement avec et sans prefetch on hover. Le différentiel typique est de 300 à 600 ms sur une bibliothèque comme un éditeur de texte riche.
Étape 8 — Combiner @defer avec l’hydratation incrémentale
Quand le rendu côté serveur est actif, @defer dialogue avec l’hydratation incrémentale stabilisée en Angular 21. Au lieu d’envoyer du HTML statique non interactif, le serveur produit le markup et le navigateur le réveille (hydrate) selon les mêmes déclencheurs que @defer. Cette synergie réduit le temps jusqu’à interactivité (TTI) sans sacrifier le SEO.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import {
provideClientHydration,
withIncrementalHydration,
} from '@angular/platform-browser';
import { App } from './app/app';
bootstrapApplication(App, {
providers: [provideClientHydration(withIncrementalHydration())],
});
Une fois withIncrementalHydration() activé, vous pouvez remplacer on viewport par hydrate on viewport dans vos blocs @defer pour profiter du nouveau mécanisme. Le HTML est livré directement par le serveur, l’utilisateur voit la page en quelques millisecondes, et chaque composant devient interactif individuellement quand son trigger se réalise. Pour un site éditorial avec dix composants interactifs disséminés dans l’article, l’économie peut dépasser 40 % sur la métrique Time To Interactive.
Étape 9 — Vérifier le découpage du bundle
Aucune optimisation ne mérite confiance tant qu’elle n’est pas mesurée. Angular CLI produit un rapport de taille à chaque build, mais il faut un peu de méthode pour interpréter les chiffres. La commande suivante génère un rapport JSON exploitable par les outils de visualisation comme source-map-explorer ou webpack-bundle-analyzer.
ng build --configuration production --stats-json
npx source-map-explorer dist/<mon-projet>/browser/main-*.js
L’outil ouvre un treemap dans le navigateur. Cherchez votre composant lourd : s’il apparaît dans main-XXX.js, c’est que le @defer n’a pas pris (souvent à cause d’un import direct dans un autre composant qui le force à rester dans le bundle principal). S’il apparaît dans chunk-YYYY.js, le découpage fonctionne. Le critère de réussite : votre bundle initial ne contient que ce qui s’affiche au-dessus de la ligne de flottaison.
Étape 10 — Tester avec un environnement bandwidth limité
La dernière étape consiste à valider l’expérience sur une connexion réelle. Beaucoup d’audiences utilisent des connexions mobiles plafonnées : la mesure doit refléter cette réalité, pas une fibre symétrique. Les outils DevTools de Chrome et Firefox simulent ces conditions très bien.
# Lighthouse en ligne de commande pour un audit automatisé
npx lighthouse https://mon-site.example \
--preset=desktop \
--throttling.cpuSlowdownMultiplier=4 \
--output=html \
--output-path=./report.html
Le rapport généré liste les Web Vitals (LCP, INP, CLS) avant et après l’optimisation. Avant de mettre @defer en production sur un parcours critique, exécutez le rapport deux fois (avec et sans déferrement) pour quantifier le bénéfice. Sur un dashboard typique avec trois widgets lourds, on observe couramment une amélioration du LCP de 800 ms à 1,2 s sur connexion 3G simulée.
Erreurs fréquentes
| Erreur | Cause | Solution |
|---|---|---|
| Le chunk n’apparaît pas dans le build | Le composant est encore importé directement ailleurs | Supprimer l’import statique, laisser @defer gérer le chargement |
| Flash de spinner sur connexion rapide | Pas de seuil after sur @loading |
Utiliser @loading (after 100ms; minimum 1s) |
| Erreur NG8003 après migration | Référence à une directive sans exportAs ou à un module non importé (par exemple FormsModule oublié) |
Ajouter l’import manquant dans imports du composant standalone, puis redémarrer le serveur de dev |
| Le placeholder reste affiché | Composant déclaré non-standalone | Convertir en standalone: true ou retirer du @defer |
| SSR rend du HTML vide | @defer n’est rendu qu’au client par défaut |
Activer withIncrementalHydration() et utiliser hydrate on |
Adaptation aux connexions limitées
Sur des marchés où la bande passante mobile reste précieuse, @defer n’est pas un confort, c’est une nécessité. Une application Angular non optimisée affiche couramment un bundle initial de 800 Ko à 1,2 Mo, ce qui représente plusieurs secondes de téléchargement en 3G. En extrayant les composants non critiques (carte, statistiques détaillées, modale d’administration), on ramène le bundle initial à 200-300 Ko et on retrouve un LCP sous les 2,5 s, seuil considéré comme bon par Google. La règle pratique : tout composant utilisé par moins de 50 % des sessions est candidat au déferrement.
FAQ
Peut-on combiner les anciennes directives et le nouveau contrôle de flux dans le même template ?
Oui, techniquement, mais c’est déconseillé. Le mélange complique la lecture et certains outils de migration ne peuvent plus se faire automatiquement. Mieux vaut convertir un template entier d’un coup avec ng generate @angular/core:control-flow.
@defer fonctionne-t-il sans SSR ?
Oui, c’est même son cas d’usage principal côté client. SSR et hydratation incrémentale viennent en bonus quand vous voulez un premier rendu HTML statique et un découpage interactif progressif.
Quelle différence entre track et trackBy de l’ancien *ngFor ?
trackBy demandait une fonction nommée déclarée dans le composant ; track accepte une expression inline (typiquement track item.id), ce qui supprime la friction et incite à toujours fournir une clé.
Le chunk d’un @defer est-il partagé entre plusieurs vues ?
Oui, si le même composant est référencé par plusieurs @defer dans l’application, Angular factorise le chunk pour éviter les doublons. Vous voyez ce comportement clairement dans le treemap du bundle analyzer.
Faut-il préfetcher tous les @defer ?
Non. Le préfetch consomme de la bande passante avant utilisation. Réservez-le aux composants probables à court terme (survol, intention d’action) ; pour un composant peu utilisé, laissez @defer charger à la demande.
Pour aller plus loin
- Angular pour entreprise : guide pratique frontend 2026 reprend l’ensemble du panorama et donne la vue d’architecture.
- Angular performance : optimisation pratique 2026 mesure l’impact des stratégies de change detection sur les Web Vitals.
- Angular signals et RxJS en pratique approfondit le modèle réactif que
@deferutilise pour ses triggerswhen.
Références
- Documentation officielle Angular — @defer
- Documentation officielle Angular — Control Flow
- Documentation officielle Angular — Incremental Hydration
- Announcing Angular v20 (blog officiel)