Division du code des services lourds d’Angular sur demande à l’aide de injectAsync
Pourquoi un service injecté statiquement entraîne ses bibliothèques dans le bundle principal d’Angular, et comment injectAsync charge les services de manière différée en fonction des intentions de l’utilisateur, y compris le chargement conditionnel.
Sources: Stop Loading Massive Services: Mastering injectAsync in Angular 22
Le chargement différé au niveau de la route ainsi que les blocs @defer permettent d’éviter que des pages et des composants peu utilisés ne fassent partie du bundle initial d’une application Angular, mais les services sont une autre histoire. Une seule invocation de inject() pour un service lourd dans un composant toujours visible fait entrer ce service, ainsi que toutes les bibliothèques qu’il importe, dans le chunk principal. Cet article explique pourquoi cela se produit, pourquoi les solutions temporaires précédentes étaient peu pratiques, et comment l’API injectAsync introduite dans Angular 22 vous permet de charger le code d’un service uniquement lorsque l’utilisateur en a réellement besoin.
Considérez les détails de l’API ci-dessous comme une information à jour : au moment de la rédaction, injectAsync est encore nouveau ; vérifiez donc sa signature exacte, son statut de stabilité et ses règles d’utilisation dans la documentation actuelle d’Angular avant de l’intégrer.
Un problème de bundle bien connu
Imaginez un tableau de bord d’entreprise qui affiche des performances en temps réel satisfaisantes mais une lenteur importante lors du premier chargement, le payload JavaScript initial ayant atteint 2,4 MB. En exécutant la compilation à l’aide de source-map-explorer, on constate que le principal responsable n’est pas du tout un composant d’interface utilisateur. Il s’agit plutôt d’un PdfExportService qui fait appel à des bibliothèques de rendu côté client lourdes pour générer des rapports financiers détaillés sur plusieurs pages.
Le problème réside dans le fait que seuls environ 5 % des utilisateurs cliquent jamais sur « Exporter en PDF ». Cependant, comme ce service est intégré au composant de en-tête du tableau de bord, chaque utilisateur télécharge environ 600 KB de code d’exportation à chaque visite initiale. Le prix à payer est supporté par les 95 % d’utilisateurs qui n’utilisent jamais cette fonctionnalité.
Angular propose déjà @defer ainsi que le splitage de code basé sur les routes pour les composants et les pages. Ce qu’il n’offrait pas jusqu’à présent, c’était un moyen simple de charger à la demande un service tout en le gardant au sein du système d’injection de dépendances (DI).
Pourquoi inject() statique se retrouve-t-il dans le bundle principal
Pour comprendre ce que injectAsync change, il est utile de savoir comment des outils de bundling tels que esbuild et Vite interagissent avec l’DI d’Angular. Prenons un composant de en-tête qui injecte le service de rapport de la manière habituelle :
import { Component, inject } from '@angular/core';
import { HeavyReportService } from './heavy-report.service';
@Component({ ... })
export class DashboardHeaderComponent {
// The Static Analysis Trap
private reportService = inject(HeavyReportService);
exportData() {
this.reportService.generate();
}
}
C’est l’instruction import de niveau supérieur qui compte. Le bundler analyse les imports de manière statique, constate que DashboardHeaderComponent dépend de HeavyReportService, et place ce service, ainsi que toutes les bibliothèques tierces qu’il importe, dans le même chunk que le composant. Si le composant fait partie de la vue initiale, la bibliothèque PDF l’est également. Ce n’est pas l’appel inject() en soi qui pose problème ; c’est l’import statique qui le rend possible.
La solution de contournement ancienne et ses limites
Les développeurs expérimentés évitent cela depuis longtemps en utilisant une instruction await import(...) dynamique à l’intérieur du gestionnaire de clic, afin que le bundler émette le service en tant que chunk distinct.
Le problème est qu’une classe importée dynamiquement n’est rien de plus qu’une classe ordinaire. Si vous la créez vous-même, elle se trouve en dehors de l’injecteur d’Angular, ce qui signifie que des dépendances telles que HttpClient pour récupérer un modèle, ou un service d’authentification qui ajoute un jeton, ne sont pas fournies. Pour obtenir une instance correctement configurée, il fallait également injecter un Injector au préalable et résoudre la classe chargée à travers lui, ce qui fonctionne mais répand les mécanismes d’injection de dépendances dans le code des composants et est sujet aux erreurs. (Pour se remémorer le fonctionnement de la résolution, consultez comment Angular résout les services grâce à l’injection de dépendances.)
Qu’est-ce que injectAsync change
injectAsync comble l’écart entre l’import dynamique d’ECMAScript et l’arbre d’injection d’Angular. Vous décrivez la dépendance à l’aide d’une fonction chargeuse, ce qui permet au compilateur de ne pas inclure ce composant dans le chunk principal et de le télécharger ainsi que de l’instancier uniquement sur demande.
L’injection de dépendances n’a plus besoin d’être synchrone. Le chargement du code d’un service peut attendre le moment où l’utilisateur montre une intention d’y accéder.
Étape 1 : écrire le service lourd comme d’habitude
Rien dans le service lui-même n’a besoin de changer. Il peut toujours utiliser inject() ainsi que tout autre élément de l’écosystème Angular. Ici, il injecte HttpClient et importe pdfMake, cette dépendance volumineuse que vous souhaitez exclure du chargement initial.
// heavy-report.service.ts (This will become its own separate JS chunk)
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as pdfMake from 'pdfmake/build/pdfmake'; // Massive dependency
@Injectable({ providedIn: 'root' })
export class HeavyReportService {
private http = inject(HttpClient);
async generate(data: any) {
// Complex, heavy PDF generation logic
}
}
Puisque rien dans le code chargé à l’avance n’importe ce fichier de manière statique, le bundler peut le générer, ainsi que pdfMake, en tant qu’élément distinct.
Étape 2 : résoudre le service à la frontière d’interaction
Au lieu d’injecter le service en tant que champ de classe, le composant le demande lorsqu’on clique sur le bouton d’exportation. Un signal, isExporting, détermine l’état désactivé et le texte affiché par le bouton pendant que le fichier est téléchargé et que le traitement s’exécute.
// The Modern Code-Split Architecture
import { Component, injectAsync, signal } from '@angular/core';
@Component({
selector: 'app-dashboard-header',
template: `
<button (click)="onExport()" [disabled]="isExporting()">
{{ isExporting() ? 'Loading Exporter...' : 'Export PDF' }}
</button>
`
})
export class DashboardHeaderComponent {
isExporting = signal(false);
async onExport() {
this.isExporting.set(true);
try {
// 1. The Magic: The browser downloads the chunk over the network right now.
// Angular instantiates it and wires up all its DI dependencies (like HttpClient).
const reportService = await injectAsync(
() => import('./heavy-report.service').then(m => m.HeavyReportService)
);
// 2. Execute the heavy logic
await reportService.generate({ id: 123 });
} catch (error) {
console.error('Failed to load or execute the report service', error);
} finally {
this.isExporting.set(false);
}
}
}
Lorsque le gestionnaire s’exécute, le navigateur récupère le chunk du service, Angular crée l’instance et fournit ses dépendances telles que HttpClient, puis le composant appelle generate(). Les blocs try/catch/finally sont importants ici : une panne réseau lors du chargement du chunk constitue désormais une erreur possible en temps de exécution, et le bloc finally garantit que le bouton sera réactivé dans tous les cas.
Un détail à vérifier selon la documentation : tout comme inject(), les fonctions de DI doivent généralement être appelées dans un contexte d’injection, comme un constructeur ou un initialiseur de champ, et non à l’intérieur d’un gestionnaire d’événement arbitraire. Si votre version d’Angular impose cette règle pour injectAsync, appelez-la dans un initialiseur de champ afin d’obtenir un chargeur, puis utilisez ce chargeur depuis le gestionnaire de clic.
Après une compilation en version de production, le résultat contient un petit fichier main.js ainsi qu’un chunk séparé heavy-report.service-[hash].js. Les utilisateurs qui n’effectuent jamais d’action d’export ne téléchargent absolument pas la bibliothèque PDF.
La segmentation du code d’une grande bibliothèque est l’usage évident, mais le même mécanisme permet un injecteur de dépendances conditionnel. Supposons que les comptes gratuits utilisent un StandardDataProcessor tandis que les comptes professionnels premium disposent d’un AIProDataProcessor.
L’approche traditionnelle regroupe les deux implémentations et utilise un fournisseur de type usine pour en choisir une à l’exécution, ce qui fait que chaque utilisateur télécharge les deux. Avec injectAsync, c’est la branche qui décide quel chunk doit être chargé :
async processData(payload: any) {
this.isProcessing.set(true);
let processor;
if (this.currentUser.tier === 'PRO') {
// Only Pro users pay the network cost for the AI chunk
processor = await injectAsync(
() => import('./ai-pro-processor.service').then(m => m.AiProProcessorService)
);
} else {
// Free users download the lightweight chunk
processor = await injectAsync(
() => import('./standard-processor.service').then(m => m.StandardProcessorService)
);
}
await processor.execute(payload);
this.isProcessing.set(false);
}
Les utilisateurs professionnels paient les coûts de réseau liés au processeur d’IA, tandis que tous les autres disposent du modèle léger. Il s’agit de polymorphisme à l’exécution, où le choix détermine également ce qui est téléchargé, ce qui sépare la taille des données chargées par l’utilisateur de la taille totale du codebase. Tel quel, isProcessing reste à la valeur true tant que le chargement se poursuit ou que execute() génère une exception ; il convient d’encadrer le corps du code dans des blocs try/finally, comme dans l’exemple d’exportation. Fournir une interface commune aux deux processeurs permet également à processor d’avoir un type bien défini, plutôt que d’être implicitement de type any.
Quand ne pas l’utiliser
Le chargement différé permet de sacrifier la taille initiale du bundle en échange d’un délai au moment de l’utilisation. Pour un service dont la plupart des utilisateurs ont besoin à chaque visite, le différer ne fait que reporter l’attente à un moment encore pire. Cela s’avère utile pour des fonctionnalités lourdes, peu utilisées ou spécifiques à certaines catégories d’utilisateurs. Vous pouvez également atténuer ce délai en déclenchant le chargement au survol ou au focus du bouton, avant la pression sur ce dernier.
Points clés
- Faites d’abord un audit : utilisez source-map-explorer ou un outil similaire pour identifier les services lourds qui ne sont utilisés que rarement ou de manière conditionnelle.
- Supprimez les injections statiques de ces services des composants chargés en priorité, car c’est l’importation statique qui les inclut dans le bundle principal.
- Chargez-les au moment où l’utilisateur a l’intention de les utiliser, en utilisant
injectAsyncet unimport()dynamique, tout en conservant un support complet pour l’injection de dépendances.