Front-end angularforall.com

- Angular 18 : zoneless change detection

Angular Angular 18 Zoneless Change Detection Zone.js
Angular 18 : zoneless change detection

Abandonnez Zone.js avec Angular 18 : comprenez le mode zoneless, configurez provideExperimentalZonelessChangeDetection et mesurez le gain de performances.

Zone.js : comment ça marche et pourquoi c'est limité

Zone.js fonctionne en monkey-patching les APIs asynchrones natives du navigateur au moment du chargement. Quand Angular démarre, Zone.js remplace setTimeout, Promise.prototype.then, XMLHttpRequest.prototype.send, addEventListener, etc. par des versions wrapées qui notifient Angular après chaque exécution.

// Ce que Zone.js fait en coulisse (simplifié)
const originalSetTimeout = window.setTimeout;

// Zone.js remplace setTimeout par une version patched
window.setTimeout = function(fn, delay) {
    return originalSetTimeout(() => {
        fn();
        // Après chaque callback setTimeout → déclencher la détection de changements Angular
        angularZone.run(() => { /* ngZone.onMicrotaskEmpty.emit() */ });
    }, delay);
};

// Même chose pour : Promise.then, fetch, XHR, queueMicrotask,
//                   MutationObserver, IntersectionObserver, etc.
// → Zone.js patche ~40 APIs différentes

Cette approche a permis à Angular 2 en 2016 d'être "magique" — les templates se mettent à jour automatiquement sans que le développeur ait à appeler quoi que ce soit. Mais elle a des coûts :

ProblèmeImpact
Bundle size~130 KB (gzippé ~33 KB) ajoutés à chaque app Angular
Overhead détectionChaque Promise → check complet de l'arbre de composants
Stack traces polluéesChaque erreur montre 15+ frames Zone.js avant le vrai code
APIs non patchéesWeb Workers, Service Workers, certaines APIs récentes = pas de détection auto
Bibliothèques tiercesCertaines libs désactivent le patching → composants ne se mettent pas à jour
Tests lentsfakeAsync/tick nécessaire, async compliqué à gérer

Principe du mode zoneless

En mode zoneless, Angular ne patch aucune API. La détection de changements est déclenchée uniquement par des événements explicites. Angular utilise son propre scheduler interne (SchedulerLike) basé sur requestAnimationFrame et queueMicrotask pour regrouper les mises à jour.

// Déclencheurs de détection de changements en mode zoneless :

// 1. Signal modifié (.set() ou .update())
const count = signal(0);
count.update(v => v + 1);
// → Angular schedule un rerender des composants qui lisent count()

// 2. Event handler DOM dans un template Angular
// <button (click)="doSomething()">
// → Angular wrapp les event handlers → schedule après chaque event handler

// 3. Async pipe émet une nouvelle valeur
// {{ data$ | async }} → markForCheck() interne quand data$ émet

// 4. Appel manuel de markForCheck() ou detectChanges()
const cdr = inject(ChangeDetectorRef);
cdr.markForCheck();

// 5. output() ou @Output EventEmitter
// → déclenche automatiquement la détection dans le composant parent

// Ce qui NE déclenche PAS la détection en mode zoneless :
// - setTimeout(() => { this.data = 'new value'; }, 1000)  → rien ne se passe !
// - fetch('/api/data').then(d => this.data = d)  → rien ne se passe !
// → Ces patterns nécessitent un signal ou markForCheck() explicite

Configuration dans Angular 18

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';  // Angular 18-19
// En Angular 20 : import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    provideRouter(routes),
    provideHttpClient(),
    // ...
  ]
};
// angular.json — supprimer zone.js du polyfills
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "options": {
            "polyfills": []
            // Retirer "zone.js" de cette liste
          }
        },
        "test": {
          "options": {
            "polyfills": []
            // Important : aussi dans les options de test !
          }
        }
      }
    }
  }
}
Rétrocompatibilité : Si certaines parties de l'app utilisent encore Zone.js (bibliothèques non migrées), tu peux laisser zone.js dans les polyfills ET ajouter provideExperimentalZonelessChangeDetection(). Angular fonctionnera dans un mode hybride — les signaux triggent la détection précise, Zone.js reste en backup pour le code legacy.

Signaux — principal mécanisme de notification

Les signaux sont le premier choix pour la réactivité en mode zoneless. Angular connaît exactement quels composants lisent quels signaux, et ne met à jour que les composants affectés par un changement de signal.

@Component({
  selector: 'app-product-detail',
  standalone: true,
  template: `
    @if (product(); as p) {
      <h1>{{ p.name }}</h1>
      <p class="price">{{ p.price | currency:'EUR' }}</p>
      <p class="stock" [class.text-danger]="p.stock === 0">
        Stock : {{ p.stock }}
      </p>
      <p>Total panier : {{ cartTotal() | currency:'EUR' }}</p>
    }
  `
})
export class ProductDetailComponent {
  product = signal<Product | null>(null);
  cartItems = signal<CartItem[]>([]);

  // computed() est aussi un signal → Angular sait qu'il dépend de cartItems()
  cartTotal = computed(() =>
    this.cartItems().reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

  addToCart(product: Product) {
    this.cartItems.update(items => [
      ...items,
      { productId: product.id, price: product.price, quantity: 1 }
    ]);
    // Angular met à jour UNIQUEMENT les bindings qui dépendent de cartItems()
    // et cartTotal() — pas le reste du template
  }
}

RxJS et observables en mode zoneless

Les observables RxJS ne déclenchent pas automatiquement la détection de changements en mode zoneless. Il faut les connecter aux signaux avec toSignal(), ou utiliser le pipe async qui appelle markForCheck() en interne.

import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-user-list',
  standalone: true,
  imports: [AsyncPipe],  // ou utiliser toSignal() et ne pas importer AsyncPipe
  template: `
    <!-- Option 1 : async pipe (marqué deprecated en Angular 19, encore fonctionnel) -->
    @for (user of users$ | async; track user.id) {
      <app-user-card [user]="user" />
    }

    <!-- Option 2 : toSignal() — recommandé en mode zoneless -->
    @for (user of usersSignal(); track user.id) {
      <app-user-card [user]="user" />
    }
  `
})
export class UserListComponent {
  private http = inject(HttpClient);

  // Observable RxJS standard
  users$ = this.http.get<User[]>('/api/users');

  // Converti en signal — toSignal() appelle markForCheck() automatiquement
  // Gère aussi la désinscription automatique au destroy du composant
  usersSignal = toSignal(this.users$, { initialValue: [] });

  // Cas complexe : observable avec transformations
  private searchQuery = signal('');

  // Combiner observable et signal avec toSignal()
  private userService = inject(UserService);
  filteredUsers = toSignal(
    toObservable(this.searchQuery).pipe(
      debounceTime(300),
      switchMap(query => this.userService.search(query))
    ),
    { initialValue: [] }
  );
}
toSignal() vs async pipe : toSignal() est la solution recommandée en mode zoneless. Il convertit l'observable en signal, gère la désinscription automatiquement à la destruction du composant, et s'intègre parfaitement dans le graphe de dépendances des signaux. L'async pipe reste fonctionnel mais n'exploite pas les optimisations du mode zoneless.

markForCheck() pour le code legacy

Pour les parties de code qui ne peuvent pas encore utiliser les signaux (bibliothèques tierces, code legacy), markForCheck() ou detectChanges() restent disponibles et fonctionnent en mode zoneless.

@Component({ selector: 'app-legacy', standalone: true, template: '...' })
export class LegacyComponent implements OnInit, OnDestroy {
  data: Product[] = [];
  private cdr = inject(ChangeDetectorRef);
  private subscription?: Subscription;
  private legacyService = inject(LegacyProductService);

  ngOnInit() {
    // Observable non converti en signal
    this.subscription = this.legacyService.products$.subscribe(products => {
      this.data = products;
      // Obligatoire en mode zoneless pour les assignations de propriétés classiques
      this.cdr.markForCheck();
      // markForCheck() → schedule un check de ce composant et ses ancêtres
    });

    // Avec setTimeout : sans markForCheck, le template ne se met pas à jour
    setTimeout(() => {
      this.data = this.legacyService.getSnapshot();
      this.cdr.markForCheck();  // obligatoire !
    }, 1000);
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

Comparaison OnPush vs Zoneless

CritèreDefault (Zone.js)OnPush (Zone.js)Zoneless + Signals
Déclenchement détectionTout event async@Input change, markForCheck()Signal modifié, event DOM, markForCheck()
GranularitéArbre completSous-arbre du composantComposants exacts qui lisent le signal modifié
Zone.js requisOuiOui (+ OnPush)Non
Bundle size+130 KB Zone.js+130 KB Zone.js0 KB (zone.js supprimé)
DébogageStack traces polluéesStack traces polluéesStack traces propres
Code nécessaireAucun (auto)OnPush + markForCheck() si besoinSignals ou markForCheck()
Courbe apprentissageFaibleMoyenneMoyenne (mais plus logique)

Compatibilité avec les bibliothèques tierces

Certaines bibliothèques utilisent Zone.js en coulisse ou supposent que la détection de changements est automatique. En mode zoneless, elles peuvent nécessiter des adaptations.

// Bibliothèques compatibles avec zoneless (liste indicative 2025) :
// ✅ NgRx Store, Effects, ComponentStore, Signals Store — compatible
// ✅ Angular Material — compatible depuis v18
// ✅ RxJS + async pipe — compatible (markForCheck() interne)
// ✅ Angular Router — compatible
// ✅ PrimeNG v18+ — compatible

// Bibliothèques nécessitant une attention :
// ⚠️ Bibliothèques qui modifient des propriétés non-signal et supposent un re-render auto
// ⚠️ Bibliothèques utilisant NgZone.run() en interne (elles fonctionnent en mode hybride)

// Solution pour bibliothèque tierce qui n'est pas zoneless-compatible :
// Mode hybride : garder zone.js ET provideExperimentalZonelessChangeDetection()
// Les signaux utilisent le scheduler zoneless, le reste utilise Zone.js en fallback

// Vérifier si une lib déclenche la détection :
import { NgZone } from '@angular/core';

const ngZone = inject(NgZone);
ngZone.run(() => {
    // Code forcément exécuté dans la zone Angular
    // → même en mode zoneless, ngZone.run() déclenche la détection
    this.legacyLibrary.doSomething();
    this.data = this.legacyLibrary.getData();
    // Puis markForCheck() si nécessaire
});

Tests unitaires en mode zoneless

Les tests Angular en mode zoneless sont plus simples car fakeAsync, tick() et flushMicrotasks() ne sont plus nécessaires pour la plupart des scénarios. Angular fournit provideExperimentalZonelessChangeDetection() pour les tests.

import { TestBed, fakeAsync } from '@angular/core/testing';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';

describe('CounterComponent', () => {
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [CounterComponent],
      providers: [
        provideExperimentalZonelessChangeDetection()  // activer le mode zoneless dans les tests
      ]
    }).compileComponents();
  });

  it('should update display when signal changes', async () => {
    const fixture = TestBed.createComponent(CounterComponent);
    fixture.detectChanges();  // premier render

    const component = fixture.componentInstance;
    const compiled = fixture.nativeElement;

    expect(compiled.querySelector('p').textContent).toContain('0');

    // Modifier le signal
    component.count.set(5);

    // En mode zoneless, detectChanges() déclenche un cycle de détection synchrone
    fixture.detectChanges();

    expect(compiled.querySelector('p').textContent).toContain('5');
    // Plus besoin de fakeAsync / tick() pour ce scénario !
  });
});

Mesurer les gains de performances

// Mesurer avec Angular DevTools (onglet Profiler)
// En mode Zone.js par défaut : chaque setTimeout, XHR, Promise déclenche
//   un cycle de détection complet → "ChangeDetectionCycle" visible dans le profiler

// En mode zoneless + signaux : cycles de détection uniquement quand un signal change
// → le profiler montre beaucoup moins de cycles, et chaque cycle touche moins de composants

// Gains typiques mesurés (varie selon l'app) :
// Bundle : -130 KB (zone.js supprimé) → -33 KB gzippé
// Bootstrap : -15 à -30% (pas de patching de ~40 APIs au démarrage)
// CPU repos : -40 à -70% (plus de check sur chaque Promise/setTimeout)
// Mémoire : -5 à -15% (moins d'allocations de closures par Zone.js)
// INP (Interaction to Next Paint) : amélioration sur les apps à forte interactivité

// Comment mesurer avant/après migration :
// 1. Lighthouse Performance → noter TBT, TTI, INP
// 2. Chrome DevTools → Performance tab → enregistrer 5s d'interaction
//    → noter "Scripting" time (doit baisser en zoneless)
// 3. Angular DevTools → Profiler → compter les cycles de détection par seconde
Recommandation migration : Commence par activer le mode zoneless en gardant zone.js (mode hybride) et ajoute provideExperimentalZonelessChangeDetection(). Vérifie que tout fonctionne. Puis retire zone.js du polyfills une fois que tous les composants utilisent des signaux ou markForCheck().

Partager