Optimisez le chargement avec les deferrable views Angular 17 : lazy load conditionnel avec @defer, @loading, @error et @placeholder pour des apps plus.
Introduction aux deferrable views
Les deferrable views sont la réponse d'Angular 17 à une limite fondamentale du lazy loading de composants : avant Angular 17, différer le chargement d'un composant obligeait à le placer derrière une route (loadComponent) ou à gérer manuellement un import dynamique. Ces deux approches ne fonctionnent pas pour du contenu dans une page (commentaires, sidebar, graphiques) qui n'a pas de route dédiée.
Avec @defer, le lazy loading devient déclaratif, directement dans le template HTML. Angular gère automatiquement le code splitting, le téléchargement du bundle, et le cycle de vie du composant.
@defer est exclu du bundle initial. Son JavaScript n'est ni téléchargé ni parsé au premier chargement de la page. Pour un éditeur riche ou un graphique Chart.js, cela peut représenter 200-500 KB en moins sur le bundle critique.
Comment @defer fonctionne en interne
Quand Angular compile un template avec @defer, il génère deux chunks JavaScript distincts via esbuild : le bundle principal (sans le composant différé) et un chunk lazy qui contient le composant différé et toutes ses dépendances directes.
// Ce que vous écrivez
@defer (on viewport) {
<data-grid [rows]="data" />
}
// Ce qu'Angular génère à la compilation (simplifié) :
// main.js → tout le code sauf DataGridComponent
// chunk-abc123.js → DataGridComponent + ses imports exclusifs
// À l'exécution, Angular :
// 1. Observe l'IntersectionObserver sur le @placeholder
// 2. Quand visible → import('./chunk-abc123.js') [dynamic import natif]
// 3. Compile DataGridComponent dans le contexte Angular
// 4. Remplace le @placeholder par DataGridComponent
Les dépendances partagées (services Angular, RxJS, etc.) restent dans le bundle principal — seul le code exclusif au composant différé est splitté. Si DataGridComponent utilise CommonModule, ce dernier n'est pas dupliqué.
loadComponent() : Les routes lazy-load au niveau de la navigation (changement d'URL). @defer lazy-load au niveau du composant dans une page — les deux sont complémentaires.
Syntaxe complète @defer
La syntaxe minimale sans trigger charge le composant quand le navigateur est inactif (idle par défaut). Avec trigger, les 4 blocs optionnels peuvent accompagner n'importe quel déclencheur.
<!-- Forme la plus simple : charge au idle (comportement par défaut) -->
@defer {
<heavy-chart [data]="chartData" />
}
<!-- Forme complète avec tous les blocs -->
@defer (on viewport) {
<!-- Contenu principal, affiché après chargement -->
<data-table [rows]="rows" />
} @placeholder (minimum 100ms) {
<!-- Affiché AVANT que le trigger se déclenche -->
<div class="skeleton" style="height: 300px"></div>
} @loading (minimum 300ms; after 150ms) {
<!-- Affiché PENDANT le téléchargement du chunk -->
<div class="spinner-border text-primary"></div>
} @error {
<!-- Affiché si le téléchargement ou l'initialisation échoue -->
<div class="alert alert-danger">
Impossible de charger le composant.
<button (click)="retry()">Réessayer</button>
</div>
}
L'option after 150ms sur @loading retarde l'apparition du spinner de 150ms — si le chunk se charge en moins de 150ms (cache chaud), le spinner ne s'affiche jamais, évitant un flash inutile.
Les 6 triggers de déclenchement
| Trigger | Déclenchement | Cas d'usage |
|---|---|---|
on idle | Navigateur inactif (requestIdleCallback) | Analytics, widgets secondaires |
on viewport | Élément visible dans le viewport (IntersectionObserver) | Sections below-the-fold |
on interaction | Premier click ou keydown sur l'élément | Accordéons, onglets |
on hover | mouseenter ou focusin sur l'élément | Tooltips, previews |
on timer(Xs) | Après X secondes | Pop-ups différés, bandeaux |
on immediate | Immédiatement (ne bloque pas le rendu initial) | Composants above-fold non critiques |
<!-- Chaque trigger contrôle exactement quand le JS est téléchargé + exécuté -->
<!-- Commentaires : charge quand l'utilisateur scrolle jusqu'à la section -->
@defer (on viewport) {
<app-comments [postId]="post.id" />
} @placeholder {
<div style="height: 400px" class="bg-light rounded"></div>
}
<!-- Tooltip : charge au hover pour affichage instantané -->
@defer (on hover; prefetch on idle) {
<app-rich-tooltip [content]="tooltipContent" />
} @placeholder {
<span class="tooltip-trigger">ℹ️</span>
}
<!-- Vidéo : prefetch inactif, affichage au clic -->
@defer (on interaction; prefetch on idle) {
<app-video-player [src]="videoUrl" [autoplay]="true" />
} @placeholder {
<div class="video-thumbnail" [style.backgroundImage]="'url(' + thumbnail + ')'">
<button class="play-btn">▶</button>
</div>
}
<!-- Bandeau newsletter : 5s après chargement -->
@defer (on timer(5s)) {
<app-newsletter-modal />
}
Blocs @placeholder, @loading, @error
Les trois blocs d'état ont des rôles distincts dans le cycle de vie d'un @defer.
Ordre chronologique des états
<!-- Timeline d'un @defer (on viewport) : -->
<!-- Étape 1 → L'élément n'est pas encore visible : @placeholder s'affiche -->
<!-- Étape 2 → L'élément entre dans le viewport : trigger déclenché -->
<!-- Étape 3 → Téléchargement du chunk : @loading s'affiche (si > after Xms) -->
<!-- Étape 4 → Chunk chargé, composant initialisé : contenu principal s'affiche -->
<!-- (Erreur) → Si échec du téléchargement ou erreur d'init : @error s'affiche -->
@defer (on viewport) {
<heavy-component />
} @placeholder (minimum 200ms) {
<!-- Le "minimum" force un affichage d'au moins 200ms -->
<!-- Empêche un flash si le composant charge très rapidement -->
<div class="placeholder-wave">
<div class="placeholder col-12" style="height: 50px"></div>
<div class="placeholder col-8 mt-2"></div>
<div class="placeholder col-10 mt-2"></div>
</div>
} @loading (after 100ms; minimum 300ms) {
<!-- "after 100ms" : spinner seulement si le chargement dure > 100ms -->
<!-- "minimum 300ms" : spinner visible au moins 300ms (pas de flash) -->
<div class="d-flex justify-content-center py-4">
<div class="spinner-border text-primary" role="status"></div>
</div>
} @error {
<div class="alert alert-warning d-flex align-items-center gap-2">
⚠️ Ce composant n'a pas pu être chargé.
</div>
}
@placeholder doit avoir une hauteur fixe ou une hauteur proche du contenu final. Si le placeholder passe de 0px à 400px lors du chargement, le layout shift dégrade le CLS (Cumulative Layout Shift) — un Core Web Vital clé.
Prefetch — séparer download et affichage
Le prefetch découple le téléchargement du chunk de son affichage. Le bundle est téléchargé en avance (sans bloquer le rendu) et mis en cache navigateur. Quand le trigger d'affichage se déclenche, le composant s'initialise instantanément.
<!-- Pattern idéal pour les interactions -->
<!-- prefetch on idle = télécharge le bundle pendant les temps creux -->
<!-- on interaction = affiche seulement au clic -->
@defer (on interaction; prefetch on idle) {
<app-rich-text-editor [(content)]="body" />
} @placeholder {
<div class="form-control" style="height: 200px; cursor: pointer"
(click)="activateEditor()">
{{ body || 'Cliquez pour éditer...' }}
</div>
}
<!-- Pattern pour modales -->
<!-- Le bundle de la modal charge dès que le bouton est visible -->
@defer (when isModalOpen(); prefetch on viewport) {
<app-settings-modal (close)="isModalOpen.set(false)" />
}
<!-- Le bouton qui ouvre la modal est dans le viewport -->
<button (click)="isModalOpen.set(true)">Paramètres</button>
Triggers conditionnels avec when
Le trigger when accepte n'importe quelle expression Angular — signal, propriété du composant, observable via async — et déclenche le chargement quand l'expression devient truthy. Contrairement aux triggers on, le trigger when ne se redéclenche pas si l'expression redevient false puis true.
// TypeScript
@Component({...})
export class ProductPage {
isLoggedIn = inject(AuthService).isLoggedIn; // Signal<boolean>
showReviews = signal(false);
tab = signal<'specs' | 'reviews' | 'shipping'>('specs');
}
<!-- Template -->
<!-- Charge le composant avis seulement si l'utilisateur est connecté -->
@defer (when isLoggedIn()) {
<app-user-reviews [productId]="product.id" />
} @placeholder {
<p class="text-muted"><a routerLink="/login">Connectez-vous</a> pour voir les avis</p>
}
<!-- Contenu par onglet : charge chaque onglet seulement si sélectionné -->
<div class="tab-buttons">
<button (click)="tab.set('specs')">Spécifications</button>
<button (click)="tab.set('reviews')">Avis</button>
<button (click)="tab.set('shipping')">Livraison</button>
</div>
@defer (when tab() === 'reviews') {
<app-reviews-tab />
}
@defer (when tab() === 'shipping') {
<app-shipping-info />
}
<!-- Combinaison when + on possible -->
@defer (on viewport; when userPreferences.showAnimations()) {
<app-animated-banner />
}
@defer avec SSR et hydration
En mode SSR (Angular Universal ou rendu serveur), le comportement de @defer est contrôlé par l'attribut hydrate depuis Angular 19. Sans configuration, le contenu différé est inclus dans le HTML serveur mais non hydraté jusqu'au trigger.
// app.config.ts — configuration SSR + hydration incrémentale
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withIncrementalHydration()),
// Autres providers...
]
};
<!-- Template avec hydrate trigger (Angular 19) -->
@defer (hydrate on viewport) {
<!-- Rendu côté serveur (inclus dans HTML initial) -->
<!-- Hydraté (JS attaché) seulement quand visible -->
<app-product-carousel [products]="relatedProducts" />
} @placeholder {
<div class="carousel-skeleton"></div>
}
<!-- Sans withIncrementalHydration : -->
<!-- @defer (on viewport) → composant ABSENT du HTML serveur -->
<!-- → chargé en JS pur côté client quand visible -->
<!-- → risque de CLS si le composant modifie le layout -->
withIncrementalHydration(), un composant dans @defer est omis du rendu serveur. Il apparaîtra côté client uniquement — ce qui peut créer un contenu invisible dans les snapshots Google Search Console. Avec withIncrementalHydration(), le HTML est présent mais le JS attaché en différé.
Impact sur les Core Web Vitals
Les deferrable views agissent directement sur trois Core Web Vitals : LCP, TBT et CLS.
| Métrique | Impact @defer | Comment |
|---|---|---|
| LCP (Largest Contentful Paint) | Améliore | Bundle initial plus petit → parsé plus vite → premier rendu plus rapide |
| TBT (Total Blocking Time) | Améliore fortement | Moins de JS à parser sur le thread principal au chargement |
| CLS (Cumulative Layout Shift) | Risque si mal utilisé | Si le placeholder a une hauteur différente du contenu final → layout shift |
| INP (Interaction to Next Paint) | Améliore | Thread principal moins occupé au chargement → interactions plus réactives |
| FID / TTI | Améliore | Page interactive plus tôt car moins de JS à exécuter initialement |
// Mesurer l'impact avec Lighthouse en mode "Navigation"
// Avant @defer : bundle initial 450KB → TBT 850ms
// Après @defer sur les 3 composants les plus lourds :
// bundle initial 280KB (-38%) → TBT 420ms (-51%)
// Comment mesurer le bundle splitting généré par @defer :
// ng build --stats-json
// npx webpack-bundle-analyzer dist/app/stats.json
// → visualiser les chunks lazy générés
Recettes pratiques par cas d'usage
Voici les patterns les plus courants et optimaux selon le type de composant :
Tableaux et grilles de données
<!-- Grille lourde : prefetch dès que le conteneur est visible, affiche au idle -->
@defer (on idle; prefetch on viewport) {
<ag-grid-angular
[rowData]="rowData()"
[columnDefs]="columnDefs"
class="ag-theme-alpine"
style="height: 600px"
/>
} @placeholder {
<div class="table-skeleton" style="height: 600px">
@for (row of [1,2,3,4,5,6,7,8]; track row) {
<div class="placeholder-glow"><div class="placeholder col-12 py-3 mb-1"></div></div>
}
</div>
}
Éditeurs de texte riche
<!-- Éditeur : prefetch en idle, affiche seulement quand l'utilisateur clique -->
@defer (on interaction; prefetch on idle) {
<quill-editor
[(ngModel)]="content"
[modules]="editorModules"
format="json"
/>
} @placeholder {
<div class="form-control p-3 cursor-pointer"
style="min-height: 200px; border: 2px dashed #dee2e6"
aria-label="Cliquer pour activer l'éditeur">
<span class="text-muted">{{ content ? content : 'Cliquez pour éditer...' }}</span>
</div>
}
Cartes interactives
<!-- Leaflet/Mapbox : charge seulement quand visible (peut peser 200-400KB) -->
@defer (on viewport; prefetch on idle) {
<app-interactive-map
[center]="mapCenter"
[markers]="locations()"
[zoom]="13"
/>
} @loading (after 50ms; minimum 200ms) {
<div class="d-flex align-items-center justify-content-center bg-light rounded"
style="height: 400px">
<div class="spinner-border text-secondary"></div>
</div>
} @placeholder {
<div class="bg-light rounded" style="height: 400px">
<img src="/assets/map-static.webp" alt="Carte" class="w-100 h-100 object-fit-cover rounded">
</div>
}
@placeholder a la même hauteur que le contenu final (CLS = 0) · Utiliser prefetch on idle sur les composants interactifs · Ajouter @error sur les composants critiques · Mesurer le TBT avant/après avec Lighthouse · Utiliser withIncrementalHydration() si SSR activé.