Angular Best Practices : performance et change detection

🏷️ Front-end 📅 13/04/2026 15:00:00 👤 Mezgani said
Angular Best Practices Performance Onpush Change Detection
Angular Best Practices : performance et change detection

Améliorez les performances Angular avec OnPush, trackBy, lazy loading, defer et une stratégie de rendu propre orientée production.

Mesurer avant d'optimiser

La première règle de la performance Angular est de ne rien optimiser à l'aveugle. Modifier la stratégie de détection de changement ou découper des composants sans avoir identifié le vrai goulot d'étranglement crée de la complexité sans gain mesurable. Les outils de mesure doivent être le point de départ de toute démarche.

Angular DevTools

L'extension Chrome/Firefox Angular DevTools inclut un profiler de détection de changement. Il liste les composants re-rendus à chaque cycle et affiche le temps passé dans chacun. C'est l'outil le plus direct pour identifier un composant qui se re-rend inutilement.

Dans le panel Profiler, lancez un enregistrement, interagissez avec l'interface, puis stoppez. Les barres colorées indiquent quels composants ont été vérifiés. Un composant en orange ou rouge doit attirer l'attention en priorité.

Lighthouse et Core Web Vitals

Lighthouse (disponible dans DevTools → onglet Lighthouse) génère un rapport complet incluant les métriques Core Web Vitals :

  • LCP (Largest Contentful Paint) : temps d'affichage du contenu principal — cible < 2,5 s
  • CLS (Cumulative Layout Shift) : stabilité visuelle pendant le chargement — cible < 0,1
  • INP (Interaction to Next Paint) : réactivité aux interactions utilisateur — cible < 200 ms

Analyser le bundle avec source-map-explorer

Avant d'optimiser le temps de chargement, identifiez ce qui pèse dans le bundle. Générez les stats JSON lors du build puis visualisez-les :

# 1. Générer les stats JSON lors du build de production
ng build --stats-json

# 2. Visualiser le bundle avec source-map-explorer
npx source-map-explorer dist/mon-app/browser/*.js

# Alternative : webpack-bundle-analyzer (interface plus interactive)
npx webpack-bundle-analyzer dist/mon-app/browser/stats.json

Le résultat est une carte interactive montrant quelles dépendances occupent le plus d'espace. Un module inattendu — lodash entier, moment.js, une bibliothèque UI complète — est souvent la cause principale d'un bundle gonflé.

À retenir : mesurez d'abord avec Angular DevTools, Lighthouse et source-map-explorer. Optimisez ensuite uniquement ce qui est réellement lent ou lourd. Une optimisation sans mesure préalable crée souvent plus de complexité qu'elle n'apporte de gain.

ChangeDetectionStrategy.OnPush

Par défaut, Angular utilise la stratégie Default : à chaque événement (clic, timer, requête HTTP), il parcourt l'intégralité de l'arbre de composants pour détecter les changements. Avec OnPush, un composant n'est vérifié que si l'une des conditions suivantes est remplie :

  • Une référence d'@Input() a changé (nouvel objet ou nouvelle valeur primitive)
  • Un événement DOM est déclenché depuis ce composant ou l'un de ses enfants
  • Un Observable ou Signal lié au template émet une nouvelle valeur
  • markForCheck() est appelé manuellement depuis le composant

Comparaison Default vs OnPush

Stratégie Déclencheur de vérification Avantage Risque principal
Default Tout événement global (zone.js) Simple, aucune discipline requise Nombreux re-rendus inutiles
OnPush Changement de référence, signal ou événement local Rendu minimal, architecture disciplinée Mutations en place non détectées

Exemple complet : @Input() immuable et Signal local

import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  Input,
  inject,
  signal
} from '@angular/core';
import { CurrencyPipe } from '@angular/common';

@Component({
  selector: 'app-product-card',
  standalone: true,
  imports: [CurrencyPipe],
  // OnPush : ce composant est vérifié uniquement sur changement de référence d'input
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="card">
      <h3>{{ product.name }}</h3>
      <p>{{ product.price | currency:'EUR' }}</p>
      <!-- Le signal local déclenche la mise à jour sans markForCheck() -->
      <button (click)="toggleFavorite()">
        {{ isFavorite() ? '★ Retirer des favoris' : '☆ Ajouter aux favoris' }}
      </button>
    </div>
  `
})
export class ProductCardComponent {
  // @Input() immuable : Angular détecte le changement de référence
  @Input({ required: true }) product!: { name: string; price: number };

  // Signal local : mise à jour granulaire sans cycle de détection global
  isFavorite = signal(false);

  toggleFavorite(): void {
    // update() reçoit la valeur courante et retourne la nouvelle
    this.isFavorite.update(current => !current);
  }
}

Quand utiliser markForCheck()

Si une donnée externe (WebSocket, push notification, Observable non lié au template) modifie l'état du composant depuis l'extérieur de la zone Angular, le cycle de détection ne le saura pas. Injectez ChangeDetectorRef et appelez markForCheck() pour signaler manuellement que ce composant doit être re-vérifié au prochain cycle.

import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  OnInit,
  inject
} from '@angular/core';
import { WebSocketService } from './websocket.service';

@Component({
  selector: 'app-live-counter',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>Connexions actives : {{ count }}</span>`
})
export class LiveCounterComponent implements OnInit {
  count = 0;

  private cdr = inject(ChangeDetectorRef);
  private ws  = inject(WebSocketService);

  ngOnInit(): void {
    // Le WebSocket émet en dehors de la zone Angular
    this.ws.onConnectionCount$.subscribe(n => {
      this.count = n;
      // Sans markForCheck(), le template ne se mettrait jamais à jour
      this.cdr.markForCheck();
    });
  }
}
Bonne pratique : activez OnPush dès la création de chaque nouveau composant. C'est beaucoup plus difficile à introduire après coup sur une base de code avec des mutations d'objets en place.

Signals et computed() pour l'état local

Les Signals (introduits en Angular 16, stables depuis Angular 17) remplacent avantageusement BehaviorSubject pour gérer l'état local d'un composant. Ils sont plus lisibles, plus simples à déboguer et s'intègrent nativement avec OnPush sans aucune configuration supplémentaire.

signal() vs BehaviorSubject

Critère BehaviorSubject Signal
Lecture de la valeur subject.getValue() mySignal()
Mise à jour subject.next(val) mySignal.set(val)
Utilisation dans le template Pipe async obligatoire Appel direct mySignal()
Intégration OnPush Besoin de markForCheck() Automatique, natif
Cleanup Unsubscribe obligatoire Aucun cleanup nécessaire

Pourquoi éviter les méthodes dans les templates

Appeler une méthode directement dans un template Angular ({{ getTotal() }}) force Angular à l'exécuter à chaque cycle de détection de changement, même si les données n'ont pas changé. Sur une liste de 100 éléments avec un calcul coûteux, cela représente 100 appels inutiles par cycle. Utilisez computed() pour mettre le résultat en cache.

Exemple : compteur de panier avec signal() et computed()

import { Component, computed, signal } from '@angular/core';
import { CurrencyPipe } from '@angular/common';

interface CartItem {
  name: string;
  price: number;
  qty:   number;
}

@Component({
  selector: 'app-cart',
  standalone: true,
  imports: [CurrencyPipe],
  template: `
    <h2>Panier ({{ itemCount() }} article{{ itemCount() > 1 ? 's' : '' }})</h2>
    <ul>
      @for (item of items(); track item.name) {
        <li>{{ item.name }} × {{ item.qty }} — {{ item.price * item.qty | currency:'EUR' }}</li>
      }
    </ul>
    <!-- computed() : recalculé uniquement quand items() change -->
    <p class="total">Total : <strong>{{ totalPrice() | currency:'EUR' }}</strong></p>
    <button (click)="addItem()">Ajouter un article test</button>
  `
})
export class CartComponent {
  // Signal mutable : source de vérité pour la liste du panier
  items = signal<CartItem[]>([
    { name: 'Clavier mécanique', price: 79,  qty: 1 },
    { name: 'Souris ergonomique', price: 35,  qty: 2 },
  ]);

  // computed() : dérivé de items(), mis en cache, recalcul automatique si items() change
  itemCount  = computed(() => this.items().reduce((acc, i) => acc + i.qty, 0));
  totalPrice = computed(() => this.items().reduce((acc, i) => acc + i.price * i.qty, 0));

  addItem(): void {
    // update() reçoit la valeur actuelle et retourne la nouvelle valeur immutablement
    this.items.update(list => [...list, { name: 'Câble USB-C', price: 12, qty: 1 }]);
  }
}
Règle d'or : n'appelez jamais une méthode coûteuse directement dans un template ({{ getTotal() }}). Déclarez une propriété computed() dans le composant et utilisez-la dans le template.

Listes performantes : @for, trackBy et Virtual Scrolling

Les listes sont souvent responsables des problèmes de rendu les plus visibles. Sans identifiant unique, Angular détruit et recrée tous les éléments DOM à chaque mise à jour — même si un seul élément a changé. Avec un identifiant, seuls les nœuds réellement modifiés sont recréés.

@for avec track (Angular 17+)

La nouvelle syntaxe de bloc @for rend le suivi obligatoire. C'est intentionnel : le compilateur impose que chaque item soit identifiable, ce qui élimine la classe entière de bugs liée à l'absence de trackBy.

<!-- @for avec track : Angular identifie chaque item par son id -->
@for (user of users(); track user.id) {
  <app-user-card [user]="user" />
} @empty {
  <p class="text-muted">Aucun utilisateur trouvé.</p>
}

<!-- Si les items n'ont pas d'id unique, utiliser $index en dernier recours -->
@for (item of staticList; track $index) {
  <li>{{ item }}</li>
}

*ngFor avec trackBy (syntaxe classique)

Sur les projets antérieurs à Angular 17 ou utilisant encore la syntaxe template classique, trackBy remplit le même rôle. La fonction de suivi doit retourner une valeur unique et stable pour chaque item.

// Dans le composant TypeScript : déclarer la fonction de suivi
trackByUserId(index: number, user: { id: number }): number {
  // Retourner l'identifiant unique de l'item
  // Angular compare cette valeur pour décider si un nœud doit être recréé
  return user.id;
}
<!-- Dans le template : passer la référence à trackBy -->
<app-user-card
  *ngFor="let user of users; trackBy: trackByUserId"
  [user]="user"
/>

Virtual Scrolling avec Angular CDK

Pour les listes de plusieurs centaines ou milliers d'items, le rendu DOM complet est prohibitif : 1 000 items génèrent des milliers de nœuds DOM actifs. Le Virtual Scrolling ne rend que les éléments visibles dans la fenêtre, remplaçant les autres par de l'espace vide.

# Installer Angular CDK si ce n'est pas déjà fait
ng add @angular/cdk
// Dans le composant standalone : importer ScrollingModule
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [ScrollingModule, UserCardComponent],
  template: `
    <!-- itemSize : hauteur fixe en px de chaque item (obligatoire pour FixedSizeVirtualScrollViewport) -->
    <cdk-virtual-scroll-viewport itemSize="72" class="user-list-viewport">
      <!-- *cdkVirtualFor remplace *ngFor pour la virtualisation -->
      <app-user-card
        *cdkVirtualFor="let user of users; trackBy: trackByUserId"
        [user]="user"
      />
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`
    /* La hauteur du viewport doit être explicite */
    .user-list-viewport {
      height: 400px;
      overflow-y: auto;
      /* contain: strict améliore les performances de layout */
      contain: strict;
    }
  `]
})
export class UserListComponent {
  users: { id: number; name: string }[] = Array.from({ length: 5000 }, (_, i) => ({
    id:   i + 1,
    name: `Utilisateur ${i + 1}`
  }));

  trackByUserId(_: number, user: { id: number }): number {
    return user.id;
  }
}
Quand activer Virtual Scrolling ? Au-delà de 100 items visibles simultanément, les gains sont significatifs. En dessous, track ou trackBy suffit largement.

Lazy loading des routes et @defer

Le bundle initial est le principal facteur du temps de chargement perçu. Toute fonctionnalité non nécessaire au premier écran doit être chargée à la demande. Angular propose deux mécanismes complémentaires : le lazy loading des routes pour les modules entiers et les blocs @defer pour les composants individuels dans un template.

Lazy loading des routes

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    // Chargé immédiatement : fait partie du bundle initial
    loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'dashboard',
    // Chargé uniquement quand l'utilisateur navigue vers /dashboard
    loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
  {
    path: 'admin',
    // Feature complète (plusieurs routes) chargée en un seul chunk à la demande
    loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
  },
];

@defer : chargement différé de composants dans le template

@defer (Angular 17+) permet de retarder le chargement d'un bloc de composants sans modifier la configuration des routes. Le code JavaScript du bloc différé est exclu du bundle initial et chargé selon les triggers définis.

<!-- Trigger 1 : on viewport — chargé quand le placeholder entre dans le viewport -->
@defer (on viewport) {
  <app-recommendations />
} @placeholder {
  <div class="placeholder-box" style="height: 200px;">Chargement des recommandations...</div>
}

<!-- Trigger 2 : on interaction — chargé au premier clic ou focus -->
@defer (on interaction) {
  <app-rich-text-editor />
} @placeholder {
  <button class="btn btn-secondary">Ouvrir l'éditeur</button>
}

<!-- Trigger 3 : on idle — chargé quand le navigateur est inactif -->
@defer (on idle) {
  <app-analytics-widget />
}

<!-- Trigger 4 : when — condition booléenne explicite -->
@defer (when isUserLoggedIn()) {
  <app-personalized-feed />
} @loading (after 100ms; minimum 500ms) {
  <app-spinner />
} @error {
  <p class="text-danger">Impossible de charger le fil personnalisé.</p>
}

Exemple pratique : dashboard avec widgets lourds

<!-- Contenu critique : chargé immédiatement, fait partie du bundle initial -->
<app-kpi-summary    [data]="kpis()"         />
<app-recent-orders  [orders]="recentOrders()" />

<!-- Graphiques lourds : chargés seulement quand visibles dans le viewport -->
<!-- prefetch on idle : télécharge le code pendant que l'utilisateur lit les KPIs -->
@defer (on viewport; prefetch on idle) {
  <app-revenue-chart    [period]="selectedPeriod()" />
  <app-user-funnel-chart />
} @placeholder (minimum 300ms) {
  <!-- Skeleton à dimensions fixes pour éviter le layout shift (CLS) -->
  <div class="chart-skeleton" style="height: 320px; background: var(--skeleton-bg);"></div>
} @loading (after 100ms; minimum 500ms) {
  <app-spinner message="Chargement des graphiques..." />
}
Combinaison gagnante : lazy loading des routes pour les modules entiers (loadChildren) + @defer (on viewport) pour les composants lourds en bas de page — graphiques, recommandations, widgets sociaux, éditeurs.

RxJS : éviter les fuites mémoire

Une fuite mémoire RxJS survient quand un composant s'abonne à un Observable et ne résilie pas cet abonnement lors de sa destruction. Le callback continue de s'exécuter sur un composant mort, ce qui provoque des erreurs silencieuses, des comportements imprévisibles et une dégradation progressive des performances.

Anti-pattern : subscribe() manuel sans cleanup

// ❌ À éviter absolument : fuite mémoire garantie si le composant est détruit
@Component({ /* ... */ })
export class BadComponent implements OnInit {
  data: string[] = [];

  private dataService = inject(DataService);

  ngOnInit(): void {
    // Ce subscribe() ne sera jamais résilié : le callback vivra indéfiniment
    this.dataService.getData().subscribe(result => {
      this.data = result;
      // Si le composant est détruit, cette ligne s'exécute encore sur un composant mort
    });
  }
}

Solution 1 : takeUntilDestroyed() (Angular 16+)

takeUntilDestroyed() est l'approche recommandée depuis Angular 16. L'opérateur RxJS résilie automatiquement l'abonnement quand le contexte d'injection du composant est détruit. Plus de ngOnDestroy à implémenter manuellement.

import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DataService } from './data.service';

@Component({ /* ... */ })
export class GoodComponent implements OnInit {
  data: string[] = [];

  private dataService = inject(DataService);
  // DestroyRef permet d'utiliser takeUntilDestroyed() dans ngOnInit (hors constructeur)
  private destroyRef  = inject(DestroyRef);

  ngOnInit(): void {
    this.dataService.getData()
      // takeUntilDestroyed() résilie l'abonnement quand le composant est détruit
      .pipe(takeUntilDestroyed(this.destroyRef))
      .subscribe(result => {
        this.data = result;
      });
  }
}

Solution 2 : async pipe dans le template

Le pipe async souscrit à l'Observable et le résilie automatiquement quand le composant est détruit. C'est la solution la plus concise quand la donnée est uniquement affichée dans le template.

// Composant : exposer l'Observable directement, sans subscribe()
@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    <ul>
      <!-- async pipe : souscrit et résilie automatiquement -->
      @for (user of users$ | async; track user.id) {
        <li>{{ user.name }}</li>
      }
    </ul>
  `
})
export class UserListComponent {
  // L'Observable est exposé tel quel au template
  users$ = inject(UserService).getUsers();
}

Solution 3 : toSignal() — Observable converti en Signal

toSignal() convertit un Observable en Signal. L'abonnement est résilié automatiquement, le pipe async n'est plus nécessaire et le Signal s'intègre nativement avec OnPush.

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { UserService } from './user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    <ul>
      @for (user of users(); track user.id) {
        <li>{{ user.name }}</li>
      }
    </ul>
  `
})
export class UserListComponent {
  private userService = inject(UserService);

  // toSignal() : abonnement géré automatiquement, valeur lisible comme un Signal
  users = toSignal(this.userService.getUsers(), { initialValue: [] });
}

Bonnes pratiques RxJS supplémentaires

  • Utilisez switchMap pour annuler les requêtes HTTP obsolètes (auto-complétion, recherche live)
  • Debouncez les événements clavier avec debounceTime(300) avant d'envoyer une requête
  • Partagez les flux coûteux avec shareReplay(1) si plusieurs abonnés consomment la même source
  • Utilisez combineLatest plutôt que des abonnements imbriqués pour combiner plusieurs streams
import { toSignal }            from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap, of } from 'rxjs';
import { takeUntilDestroyed }  from '@angular/core/rxjs-interop';

// Exemple : champ de recherche avec debounce + switchMap + toSignal
searchResults = toSignal(
  this.searchControl.valueChanges.pipe(
    // Attendre 300 ms de silence avant d'envoyer la requête
    debounceTime(300),
    // Ne pas déclencher si la valeur n'a pas changé
    distinctUntilChanged(),
    // Annuler la requête précédente si une nouvelle valeur arrive (switchMap)
    switchMap(query => query
      ? this.searchService.search(query)
      : of([])
    ),
    // Résilier automatiquement quand le composant est détruit
    takeUntilDestroyed(),
  ),
  { initialValue: [] }
);

Optimiser les assets et le bundle

La taille du bundle et le poids des assets impactent directement le temps de chargement initial et les Core Web Vitals. Ces optimisations sont souvent simples à mettre en place et donnent des gains mesurables sans modifier l'architecture des composants.

Images : WebP, dimensions explicites et lazy loading

Les images sont le facteur dominant du LCP et du CLS pour la majorité des applications Angular. Trois attributs font l'essentiel du travail :

<!-- ✅ Image optimisée : WebP, dimensions explicites, lazy loading -->
<img
  src="assets/images/hero.webp"
  alt="Tableau de bord de l'application"
  width="1200"
  height="630"
  loading="lazy"
  decoding="async"
>

<!-- ✅ Image critique (LCP) : chargement prioritaire, pas de lazy -->
<img
  src="assets/images/hero-above-fold.webp"
  alt="Bannière principale"
  width="1200"
  height="630"
  loading="eager"
  fetchpriority="high"
>

<!-- ❌ À éviter : absence de dimensions → layout shift → CLS élevé -->
<img src="assets/images/hero.jpg" alt="...">

ng-container : zéro overhead DOM

ng-container est un conteneur logique invisible : il ne génère aucun élément dans le DOM réel. Utilisez-le pour grouper des directives structurelles ou des blocs conditionnels sans polluer le DOM avec des <div> superflus qui alourdissent le layout et les sélecteurs CSS.

<!-- ❌ Div inutile qui s'ajoute au DOM et peut casser le CSS -->
<div *ngIf="isLoggedIn">
  <app-user-menu />
  <app-notifications />
</div>

<!-- ✅ ng-container : aucun élément DOM généré, zéro impact sur le layout -->
<ng-container *ngIf="isLoggedIn">
  <app-user-menu />
  <app-notifications />
</ng-container>

<!-- ✅ Avec la nouvelle syntaxe @if (Angular 17+) : ng-container implicite -->
@if (isLoggedIn) {
  <app-user-menu />
  <app-notifications />
}

Tree-shaking : imports précis

Le tree-shaking d'Angular supprime le code inutilisé lors du build de production. Mais des imports trop larges — en particulier import * — empêchent cet élaguage et font gonfler le bundle inutilement.

// ❌ Import de toute la bibliothèque lodash → tree-shaking impossible → +70 Ko
import * as _ from 'lodash';
const grouped = _.groupBy(items, 'category');

// ✅ Import de la seule fonction nécessaire → seule cette fonction dans le bundle
import groupBy from 'lodash/groupBy';
const grouped = groupBy(items, 'category');

// ✅ Encore mieux : équivalent natif ES2024 (zéro dépendance)
const grouped = Object.groupBy(items, item => item.category);

Services tree-shakables avec providedIn: 'root'

Les services déclarés avec providedIn: 'root' sont automatiquement tree-shakables : Angular les exclut du bundle si aucun composant ne les injecte. À l'inverse, un service ajouté dans providers[] d'un module est toujours inclus, qu'il soit utilisé ou non.

// ✅ Tree-shakable : inclus dans le bundle seulement si effectivement injecté
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
  track(event: string, data?: Record<string, unknown>): void {
    // Logique d'envoi des événements analytics
  }
}

// ❌ Déclaré dans providers[] d'un module → toujours inclus dans le bundle
@NgModule({
  providers: [AnalyticsService] // Présent même si jamais injecté nulle part
})
export class AppModule {}

Checklist performance Angular

Voici un récapitulatif des pratiques couvertes dans cet article, utilisable comme checklist lors d'un audit de performance ou d'une code review.

Mesure et diagnostic

  • Angular DevTools installé et profiler utilisé avant toute optimisation
  • Lighthouse run effectué, métriques LCP / CLS / INP documentées
  • Bundle analysé avec ng build --stats-json + source-map-explorer
  • Dépendances lourdes et inattendues identifiées et traitées

Rendu et état

  • ChangeDetectionStrategy.OnPush activé sur tous les composants de présentation
  • Signals utilisés pour l'état local (préféré à BehaviorSubject)
  • computed() utilisé pour tout calcul dérivé affiché dans le template
  • Aucune méthode coûteuse appelée directement dans le template
  • markForCheck() utilisé uniquement si une mise à jour externe est nécessaire

Listes

  • track défini sur tous les blocs @for
  • trackBy défini sur tous les *ngFor
  • Virtual Scrolling (cdk-virtual-scroll-viewport) activé pour les listes de plus de 100 items

Chargement différé

  • Toutes les routes non critiques configurées en lazy loading (loadComponent / loadChildren)
  • @defer (on viewport) utilisé pour les composants lourds en bas de page
  • Placeholders et skeletons à dimensions fixes pour éviter le layout shift

RxJS

  • Aucun subscribe() manuel sans cleanup explicite
  • takeUntilDestroyed() ou pipe async utilisés systématiquement
  • toSignal() utilisé pour convertir les Observables affichés dans les templates
  • debounceTime + switchMap sur toutes les recherches utilisateur

Assets et bundle

  • Images converties en WebP, attributs width et height toujours présents
  • loading="lazy" sur toutes les images hors viewport initial
  • ng-container ou @if utilisés à la place de <div> pour les conditions
  • Imports précis sur toutes les bibliothèques tierces (pas d'import *)
  • Services déclarés avec providedIn: 'root' pour être tree-shakables

Impact estimé par pratique

Pratique Avant Après Difficulté
OnPush sur les composants Tout l'arbre vérifié à chaque événement Seuls les composants impactés vérifiés Faible
Lazy loading des routes Bundle initial souvent > 500 Ko Bundle initial réduit à < 150 Ko Faible
Virtual Scrolling 5 000+ nœuds DOM pour 1 000 items ~50 nœuds DOM actifs en permanence Moyenne
Images WebP + lazy LCP > 4 s, CLS > 0,1 LCP < 2 s, CLS proche de 0 Faible
@defer (on viewport) Tous les composants dans le bundle initial Composants secondaires chargés à la demande Faible
computed() vs méthodes template Recalcul à chaque cycle de détection Recalcul uniquement si les dépendances changent Très faible

La performance Angular n'est pas un hack ponctuel à appliquer une seule fois. C'est une discipline de rendu, d'architecture et de chargement progressif à maintenir tout au long de la vie du projet. Ces pratiques, appliquées dès le départ, ont un coût d'adoption faible et un impact significatif sur l'expérience utilisateur et les métriques Core Web Vitals.