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ème | Impact |
|---|---|
| Bundle size | ~130 KB (gzippé ~33 KB) ajoutés à chaque app Angular |
| Overhead détection | Chaque Promise → check complet de l'arbre de composants |
| Stack traces polluées | Chaque erreur montre 15+ frames Zone.js avant le vrai code |
| APIs non patchées | Web Workers, Service Workers, certaines APIs récentes = pas de détection auto |
| Bibliothèques tierces | Certaines libs désactivent le patching → composants ne se mettent pas à jour |
| Tests lents | fakeAsync/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 !
}
}
}
}
}
}
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() 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ère | Default (Zone.js) | OnPush (Zone.js) | Zoneless + Signals |
|---|---|---|---|
| Déclenchement détection | Tout event async | @Input change, markForCheck() | Signal modifié, event DOM, markForCheck() |
| Granularité | Arbre complet | Sous-arbre du composant | Composants exacts qui lisent le signal modifié |
| Zone.js requis | Oui | Oui (+ OnPush) | Non |
| Bundle size | +130 KB Zone.js | +130 KB Zone.js | 0 KB (zone.js supprimé) |
| Débogage | Stack traces polluées | Stack traces polluées | Stack traces propres |
| Code nécessaire | Aucun (auto) | OnPush + markForCheck() si besoin | Signals ou markForCheck() |
| Courbe apprentissage | Faible | Moyenne | Moyenne (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
provideExperimentalZonelessChangeDetection(). Vérifie que tout fonctionne. Puis retire zone.js du polyfills une fois que tous les composants utilisent des signaux ou markForCheck().