Startseite / Artikel

Code-Splitting von aufwändigen Angular-Diensten nach Bedarf mit injectAsync

Warum eine statisch injizierte Dienstklasse ihre Bibliotheken in den Haupt-Angular-Bundle mitnimmt und wie injectAsync Dienste je nach Benutzerwunsch lazy-lädt, einschließlich bedingter Laden.

Verlag: REACTAPP.TOP6 Min. Lesezeit1484 Wörter

Quellen: Stop Loading Massive Services: Mastering injectAsync in Angular 22

Das verzögerte Laden auf Route-Ebene sowie @defer-Blöcke halten selten genutzte Seiten und Komponenten außerhalb des initialen Bundles einer Angular-Anwendung, doch bei Services ist die Situation anders. Ein einziger Aufruf von inject() für einen ressourcenintensiven Service in einer stets sichtbaren Komponente zieht diesen Service sowie alle von ihm importierten Bibliotheken in den Haupt-Bundle ein. Dieser Artikel erklärt, warum das geschieht, warum ältere Workarounds unpraktisch waren und wie die in Angular 22 eingeführte injectAsync-API es ermöglicht, den Code eines Services nur dann zu laden, wenn der Benutzer ihn tatsächlich benötigt.

Betrachten Sie die unten stehenden API-Details als Aktualisierungsstand: Zum Zeitpunkt der Erstellung ist injectAsync noch neu, daher sollten Sie seine genaue Signatur, Stabilitätsstatus sowie Verwendungsvorschriften in der aktuellen Angular-Dokumentation überprüfen, bevor Sie sie anwenden.

Ein bekanntes Bundle-Problem

Stellen Sie sich ein Unternehmens-Dashboard vor, das eine gute Laufzeitleistung aufweist, aber bei der ersten Ladezeit schwach abschneidet – dabei hat die anfängliche JavaScript-Datei bereits eine Größe von 2,4 MB erreicht. Die Analyse mit source-map-explorer zeigt, dass der größte Übeltäter keineswegs ein UI-Komponent ist. Es handelt sich um einen PdfExportService, der auf umfangreichen Client-Seite-Rendering-Bibliotheken angewiesen ist, um detaillierte, mehrseitige Finanzberichte zu erstellen.

Das Besondere ist, dass nur etwa 5 % der Nutzer jemals auf „Exportieren als PDF“ klicken. Da der Service jedoch in das Header-Komponenten des Dashboards eingebettet ist, laden alle Nutzer bei jedem ersten Besuch etwa 600 KB an Export-Code herunter. Den Preis dafür zahlen die 95 %, die diese Funktion niemals nutzen.

Angular bietet bereits @defer sowie codebasiertes Splitting für Komponenten und Seiten. Was es bis jetzt nicht angeboten hat, ist eine saubere Methode, um einen Service verzögert zu laden, während er im Abhängigkeitsinjektionssystem (DI) bleibt.

Warum ein statischer inject() im Hauptbundle landet

Um zu erkennen, was injectAsync verändert, hilft es, zu wissen, wie Bündler wie esbuild und Vite mit Angular DI interagieren. Betrachten wir eine Header-Komponente, die den Berichtsdienst auf die übliche Weise injiziert:

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();
  }
}

Die oberste import-Anweisung ist entscheidend. Der Bundler analysiert die Imports statisch, erkennt, dass DashboardHeaderComponent von HeavyReportService abhängt, und platziert diesen Service zusammen mit jeder von ihm importierten Drittanbieter-Bibliothek im selben Chunk wie das Komponenten-Code. Wenn die Komponente Teil der Anfangsansicht ist, gilt das auch für die PDF-Bibliothek. Der inject()-Aufruf an sich ist nicht das Problem; das statische Importieren, das dies ermöglicht, schon.

Die ältere Workaround-Lösung und ihre Einschränkungen

Erfahrene Entwickler haben dies seit Langem vermieden, indem sie innerhalb des Klick-Handlers ein dynamisches await import(...) verwendeten, sodass der Bundler den Service als separaten Chunk ausgibt.

Das Problem ist, dass eine dynamisch importierte Klasse einfach nur eine Klasse ist. Wenn man sie selbst erstellt, befindet sie sich außerhalb von Angulars Injector, wodurch Abhängigkeiten wie HttpClient zum Abrufen eines Templates oder ein Authentifizierungs-Dienst, der ein Token hinzufügt, nicht bereitgestellt werden. Um eine ordnungsgemäß vernetzte Instanz zu erhalten, musste man im Voraus einen Injector injizieren und die geladene Klasse darüber auflösen – was funktioniert, aber die DI-Logik im Komponentencode verbreitet und leicht falsch angewendet werden kann. (Für einen Überblick darüber, wie die Auflösung selbst funktioniert, siehe how Angular dependency injection resolves services with inject.)

Was sich durch injectAsync ändert

injectAsync schließt die Lücke zwischen einem dynamischen Import nach ECMAScript und dem Injector-Tree von Angular. Sie beschreiben die Abhängigkeit mit einer Ladefunktion und teilen dem Bundler im Grunde mit: Behalten Sie dies außerhalb des Haupt-Chunks und laden Sie es sowie instanzieren Sie es nur, wenn darum gebeten wird.

Die Abhängigkeitsinjektion muss nicht mehr synchron erfolgen. Das Laden des Codes eines Services kann bis zum Moment warten, in dem der Benutzer eine Aktion ausführt.

Schritt 1: Schreiben Sie den ressourcenintensiven Service wie gewohnt

Am Service selbst muss sich nichts ändern. Er kann weiterhin inject() sowie alle anderen Funktionen aus dem Angular-Ekosystem verwenden. Hier injiziert er HttpClient und importiert pdfMake, die große Abhängigkeit, die Sie aus dem initialen Laden ausschließen möchten.

// 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
  }
}

Weil im vorab geladenen Code nichts dieses Dateis statisch importiert, kann der Bundler es zusammen mit pdfMake als eigenständigen Chunk ausgeben.

Schritt 2: Die Dienstinstanz am Interaktionsrand auflösen

Anstatt den Dienst als Klassenfeld einzubinden, beantragt die Komponente ihn beim Klicken auf die Export-Button. Ein Signal namens isExporting steuert den deaktivierten Zustand sowie die Beschriftung des Buttons, solange der Chunk heruntergeladen wird und die Verarbeitung läuft.

// 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);
    }
  }
}

Wenn der Handler ausgeführt wird, lädt der Browser den Chunk des Services herunter, Angular erstellt die Instanz und stellt ihre Abhängigkeiten wie HttpClient bereit, wobei die Komponente anschließend generate() aufruft. Hier sind try/catch/finally wichtig: Ein Netzwerkfehler beim Laden des Chunks kann nun zu einem Laufzeitfehler führen, und der finally-Block sorgt dafür, dass die Schaltfläche in jedem Fall wieder aktiviert wird.

Ein Punkt, den man gegenüber der Dokumentation überprüfen sollte: Genau wie inject() müssen DI-Funktionen in der Regel innerhalb eines Injektionskontexts aufgerufen werden – beispielsweise in einem Konstruktor oder einer Feldinitialisierungsfunktion – und nicht innerhalb eines beliebigen Ereignishandlers. Wenn Ihre Angular-Version dies für injectAsync vorschreibt, rufen Sie diese Funktion in einer Feldinitialisierungsfunktion auf, um einen Loader zu erhalten, und rufen diesen Loader stattdessen vom Klickhandler aus auf.

Nach einem Produktionsbuild enthält die Ausgabe ein kleines main.js sowie einen separaten heavy-report.service-[hash].js-Chunk. Benutzer, die niemals auf „Exportieren“ klicken, laden die PDF-Bibliothek überhaupt nicht herunter.

Konditionelles Laden: Nur den Code liefern, den ein Benutzer ausführen kann

Das Aufteilen einer großen Bibliothek in Teile ist der offensichtliche Anwendungsfall, doch derselbe Mechanismus ermöglicht auch konditionelles Dependency Injection. Angenommen, kostenlose Konten verwenden einen StandardDataProcessor, während Premium-Konten eines Unternehmens einen AIProDataProcessor erhalten.

Der traditionelle Ansatz packt beide Implementierungen zusammen und verwendet einen Factory-Provider, um zur Laufzeit eine auszuwählen – dadurch laden alle Benutzer beides herunter. Mit injectAsync entscheidet der jeweilige Zweig, welcher Chunk heruntergeladen wird:

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);
}

Pro-User zahlen die Netzwerkkosten für den AI-Prozessor, während alle anderen den leichteren erhalten. Dies ist Laufzeit-Polymorphie – die Wahl bestimmt außerdem, was heruntergeladen wird, wodurch die Größe dessen, was ein Benutzer lädt, von der Gesamtgröße des Codebases getrennt wird. Wie geschrieben bleibt isProcessing bei Ladevorgängen oder wenn execute() einen Fehler wirft auf true; umschließen Sie den Codekörper wie im Exportbeispiel in try/finally. Eine gemeinsame Schnittstelle für beide Prozessoren sorgt außerdem dafür, dass processor ordnungsgemäß typisiert bleibt und nicht implizit als any gilt.

Wann man darauf verzichten sollte

Das Verspätete Laden tauscht die anfängliche Größe des Pakets gegen eine Verzögerung zum Zeitpunkt der Nutzung ein. Für eine Funktion, die die meisten Nutzer bei jedem Besuch benötigen, verschiebt das Aufschieben des Ladevorgangs die Wartezeit nur auf einen ungünstigeren Zeitpunkt. Es lohnt sich hingegen für umfangreiche, selten genutzte oder auf bestimmte Ebenen beschränkte Funktionalitäten. Die Verzögerung kann auch verringert werden, indem der Ladevorgang beim Überfahren mit dem Mauszeiger oder beim Fokussieren der Schaltfläche, vor dem Klicken, ausgelöst wird.

Kernpunkte

  • Erst eine Überprüfung durchführen: Verwenden Sie source-map-explorer oder ein ähnliches Tool, um umfangreiche Dienste zu finden, die nur selten oder bedingungsweise genutzt werden.
  • Entfernen Sie statische Einbindungen dieser Dienste aus den sofort geladenen Komponenten, da gerade die statische Importierung dazu führt, dass sie in das Hauptpaket aufgenommen werden.
  • Laden Sie sie zum Zeitpunkt des Nutzerwunsches mithilfe von injectAsync und einem dynamischen import(), wobei die volle DI-Unterstützung beibehalten wird.
  • Zeigen Sie den Ladezustand an und handhaben Sie Fehler, da jeder verzögerte Service nun eine Netzwerkanfrage für seinen Teil beinhaltet.
  • Verwenden Sie dasselbe Muster, um nur die Implementierung herunterzuladen, die einem bestimmten Benutzer zur Ausführung gestattet ist.