La plupart des articles sur la performance Angular parlent de OnPush change detection et trackBy. Ce sont des sujets importants, mais ce n'est pas par là que je commence. Chaque application Angular que j'ai profilée avait déjà OnPush sur certains composants et trackBy dans certaines boucles, et restait lente. Les problèmes étaient ailleurs : un bundle initial surdimensionné, des fuites de souscription gardant des composants détruits en vie, et de longues listes rendant des centaines de noeuds DOM en une fois. OnPush était hors sujet tant que ces problèmes n'étaient pas réglés. Les exemples de code ci-dessous utilisent un domaine de demandes de prêt purement à titre d'illustration ; aucun ne provient du codebase d'un employeur.
Commencez par le bundle
Lancez source-map-explorer sur votre chunk principal avant de faire quoi que ce soit d'autre. Le résultat vous dit, en octets, ce qui dévore votre chargement initial. Dans chaque audit que j'ai mené, les plus gros coupables sont les bibliothèques tierces importées sans réfléchir et le code de fonctionnalités qui s'est retrouvé dans le bundle principal parce que quelqu'un a importé un composant directement au lieu d'utiliser loadComponent.
- npx source-map-explorer dist/your-app/browser/main-*.js donne une décomposition visuelle du contenu du bundle (compilez d'abord avec --source-map, car les builds de production omettent les source maps par défaut)
- Toute bibliothèque de plus de 50 Ko dans le bundle principal qui n'est pas nécessaire sur le premier écran est candidate au déplacement
- Les bibliothèques de dates, de graphiques et de renderers PDF sont les coupables les plus courants
- Chaque route de fonctionnalité qui utilise loadComponent ou loadChildren au lieu d'un import direct est une frontière de chunk
Convertir en lazy loading des routes de fonctionnalités importées de manière eager est, de façon fiable, la plus grosse réduction du bundle initial, et le score mobile suit. Tout le reste de cette liste est incrémental en comparaison.
OnPush est un minimum, pas une solution miracle
ChangeDetectionStrategy.OnPush dit à Angular de sauter un composant lors du change detection sauf si l'un de ses inputs change par référence, un événement provient de celui-ci, ou un async pipe ou signal le marque comme dirty. Dans un arbre de composants avec des centaines de noeuds, cela réduit drastiquement le travail gaspillé par cycle d'événement.
import { CurrencyPipe } from '@angular/common';
@Component({
// Attribute selector, not an element selector. <app-application-row> inside a <tbody> is
// invalid HTML, and the browser hoists it out of the table before Angular gets a say.
selector: 'tr[appApplicationRow]',
standalone: true,
imports: [CurrencyPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<td>{{ application.applicantName }}</td>
<td>{{ application.requestedAmount | currency:'CAD' }}</td>
<td [class]="'status-' + application.status">{{ application.status }}</td>
`
})
export class ApplicationRowComponent {
@Input({ required: true }) application!: LoanApplication;
}Le piège : OnPush nécessite des patterns d'input immuables. Si vous mutez un tableau et passez la même référence, OnPush ne re-rendra pas. Cela piège les développeurs qui font array.push() et s'attendent à ce que la vue se mette à jour. Les signals ne suppriment pas cette règle. Un signal ne notifie que lorsqu'il reçoit une nouvelle valeur, comparée avec Object.is par défaut, donc update(list => [...list, item]) déclenche un rendu alors qu'un push() sur le même tableau ne le fait pas.
Les fuites de souscription tuent la performance silencieusement
Les souscriptions non gérées sont le problème de performance qui n'apparaît jamais dans Lighthouse. Un composant souscrit à un intervalle ou un WebSocket et ne se désouscrit jamais. Le composant est détruit par la navigation. La souscription continue de tourner. Son callback continue de travailler, elle garde le composant détruit en mémoire, et dans une application Zone.js chaque émission déclenche encore une passe de change detection sur toute l'application. Multipliez cela par un utilisateur qui navigue dans l'application pendant trente minutes et vous avez des dizaines de souscriptions actives sur des composants détruits.
Sur tout codebase en Angular 16 ou plus récent, takeUntilDestroyed va dans chaque fonctionnalité que je livre. Après avoir traqué des fuites de ce genre dans plus d'un codebase, c'est la première chose que je regarde quand je relis une souscription.
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';
@Component({ standalone: true })
export class ApplicationStatusComponent implements OnInit {
private statusService = inject(ApplicationStatusService);
private destroyRef = inject(DestroyRef);
@Input({ required: true }) applicationId!: string;
status = signal<ApplicationStatus | null>(null);
ngOnInit(): void {
// Polling every 10s for status updates
this.statusService.pollStatus(this.applicationId).pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(s => this.status.set(s));
}
}Virtual scrolling pour les grandes listes
Un tableau de 500 lignes, ce n'est pas 500 noeuds DOM. C'est 500 lignes plus chaque cellule qu'elles contiennent, donc trois colonnes vous mènent déjà au-delà de 2000 éléments. Chacun porte des styles, des écouteurs d'événements et un coût de change detection. Le virtual scrolling CDK ne rend que ce qui tient dans le viewport, ce qui est la seule approche sensée pour toute liste qui peut dépasser 100 éléments.
import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
standalone: true,
imports: [ScrollingModule, ApplicationRowComponent],
// The viewport has to be the scrolling container, so the table lives inside it. Set
// table-layout: fixed and a row height that matches itemSize, or the recycling maths drifts.
template: `
<cdk-virtual-scroll-viewport itemSize="64" style="height: 70vh">
<table class="application-table">
<tbody>
<tr appApplicationRow
*cdkVirtualFor="let app of applications(); trackBy: trackById"
[application]="app"
></tr>
</tbody>
</table>
</cdk-virtual-scroll-viewport>
`
})
export class ApplicationListComponent {
applications = input.required<LoanApplication[]>();
trackById = (_: number, app: LoanApplication) => app.id;
}Les pipes purs plutôt que les méthodes dans le template
Un appel de méthode dans un template s'exécute à chaque cycle de change detection. Un pipe pur ne s'exécute que quand ses entrées changent. C'est un signalement de revue de code que je lève à chaque fois que je le vois.
// In the template. Bad: runs on every CD cycle
// <td>{{ formatAmount(application.requestedAmount) }}</td>
// Good: runs only when requestedAmount changes
// <td>{{ application.requestedAmount | loanAmount }}</td>
@Pipe({ name: 'loanAmount', standalone: true, pure: true })
export class LoanAmountPipe implements PipeTransform {
transform(value: number): string {
return new Intl.NumberFormat('en-CA', {
style: 'currency',
currency: 'CAD',
maximumFractionDigits: 0
}).format(value);
}
}L'ordre qui fonctionne réellement
- 1. Auditez le bundle. Retirez tout ce qui est volumineux et non nécessaire au premier rendu.
- 2. Appliquez le lazy loading à toutes les routes de fonctionnalités. C'est le changement à plus fort impact par heure de travail.
- 3. Trouvez les fuites de souscription. Ajoutez takeUntilDestroyed dans tout le codebase comme standard.
- 4. Ajoutez OnPush à tous les composants. Corrigez les violations d'immuabilité des inputs au fur et à mesure.
- 5. Ajoutez le virtual scrolling à toute liste qui peut dépasser 100 éléments.
- 6. Remplacez les appels de méthodes dans le template par des pipes purs.
Le code le plus rapide est celui qui ne s'exécute pas. Chaque optimisation de cette liste fonctionne en différant ou en éliminant du travail qu'Angular faisait inutilement.
Rien de tout cela ne nécessite une refonte architecturale. Tout peut être appliqué de manière incrémentale, fonctionnalité par fonctionnalité, sprint par sprint. L'analyse du bundle seule vous dit par où commencer. Après cela, c'est juste de l'exécution.
Références
- Angular change detection with OnPush. https://angular.dev/best-practices/skipping-subtrees
- Angular CDK virtual scrolling. https://material.angular.io/cdk/scrolling/overview
- takeUntilDestroyed. https://angular.dev/api/core/rxjs-interop/takeUntilDestroyed
- Core Web Vitals. https://web.dev/articles/vitals