Rozbijanie kodu w dużych usługach Angular na żądanie za pomocą injectAsync
Dlaczego usługa wstrzykiwana statycznie zabiera ze sobą swoje biblioteki do głównego pliku Angular, oraz jak injectAsync ładuje usługi w trybie opóźnionym w zależności od intencji użytkownika, w tym przy ładowaniu warunkowym.
Źródła: Stop Loading Massive Services: Mastering injectAsync in Angular 22
Opóźnione ładowanie na poziomie trasy oraz bloki @defer zapobiegają włączaniu rzadko używanych stron i komponentów do początkowego pliku aplikacji Angular, ale z usługami jest inaczej. Jedno wywołanie inject() ciężkiej usługi w komponencie zawsze widocznym sprowadza tę usługę oraz wszystkie biblioteki, które importuje, do głównego pliku. Ten artykuł wyjaśnia, dlaczego tak się dzieje, dlaczego starsze rozwiązania były niewygodne oraz jak API injectAsync, wprowadzone w Angular 22, umożliwia ładowanie kodu usługi tylko wtedy, gdy użytkownik faktycznie go potrzebuje.
Traktuj poniższe szczegóły API jako wstępny zarys: w momencie pisania tego tekstu injectAsync jest nowym rozwiązaniem, więc przed jego zastosowaniem sprawdź dokładną specyfikację, status stabilności oraz zasady użycia w aktualnej dokumentacji Angular.
Znany problem z plikiem aplikacji
Załóżmy panel sterowania przedsiębiorstwa o dobrych osiągach podczas działania, ale z powolnym pierwszym ładowaniem, gdzie początkowy plik JavaScript osiągnął rozmiar 2,4 MB. Uruchomienie procesu budowy za pomocą source-map-explorer pokazuje, że największym winowajcą wcale nie jest komponent interfejsu użytkownika. Jest to PdfExportService, który polega na ciężkich bibliotekach renderowania po stronie klienta w celu tworzenia szczegółowych wielostronicowych raportów finansowych.
Ciekawostką jest to, że tylko około 5% użytkowników kiedykolwiek kliknie „Eksportuj do PDF”. Ponieważ usługa ta jest wstrzykiwana do komponentu nagłówka panelu sterowania, każdy użytkownik pobiera mniej więcej 600 KB kodu do eksportu przy każdym pierwszym odwiedzeniu. Koszt ten ponoszą 95% użytkowników, którzy nigdy nie korzystają z tej funkcji.
Angular już oferuje @defer oraz dzielenie kodu na podstawie tras dla komponentów i stron. To, czego do tej pory nie oferował, to prosty sposób na ładowanie usług w trybie opóźnionym przy jednoczesnym zachowaniu ich w systemie iniekcji zależności (DI).
Dlaczego statyczna funkcja inject() trafia do głównego pliku
Aby zrozumieć, jakie zmiany wprowadza injectAsync, przydatne jest poznanie sposobu, w jaki narzędzia takie jak esbuild i Vite współpracują z systemem DI w Angular. Rozważmy komponent nagłówkowy, który w standardowy sposób iniekuje usługę raportowania:
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();
}
}
Najważniejsze jest zdanie import na najwyższym poziomie. Narzędzie do pakowania analizuje importy w sposób statyczny, widzi, że DashboardHeaderComponent zależy od HeavyReportService, i umieszcza tę usługę wraz z każdą biblioteką od third party, którą importuje, w tym samym fragmencie co komponent. Jeśli komponent jest częścią początkowej strony, to biblioteka PDF również. Sam wywołanie inject() nie stanowi problemu; problemem jest statyczny import, który to umożliwia.
Stary sposób obejścia i jego ograniczenia
Doświadczeni programiści od dawna unikają tego problemu, przechodząc na dynamiczne await import(...) wewnątrz obsługi kliknięcia, dzięki czemu narzędzie do pakowania wysyła usługę jako oddzielny fragment.
Problem polega na tym, że klasa importowana dynamicznie jest po prostu klasą. Jeśli sam ją tworzysz, znajduje się poza injektorem Angulara, więc zależności takie jak HttpClient do pobierania szablonu lub usługa autoryzacji dodająca token nie są dostarczane. Aby uzyskać prawidłowo skonfigurowaną instancję, trzeba najpierw zainiektować Injector i za jego pośrednictwem rozwiązać problem z załadowaną klasą – to działa, ale rozprzestrzenia mechanizmy DI w kodzie komponentu i łatwo popełnić błąd. (Aby przypomnieć sobie, jak działa sam proces rozwiązywania, zobacz jak Angular dependency injection rozwiązuje usługi za pomocą inject.)
Co zmienia injectAsync
injectAsync zamienia luki pomiędzy dynamicznym importem w ECMAScript a drzewem injectora w Angularze. Zależność opisujesz za pomocą funkcji ładowania, a w praktyce mówisz do narzędzia pakującego: nie umieszczaj tego w głównym pliku, a pobierz i utwórz instancję dopiero na żądanie.
Iniekcja zależności nie musi już być synchroniczna. Ładowanie kodu usługi może czekać do momentu, gdy użytkownik wykaże chęć jego użycia.
Krok 1: napisz ciężką usługę jak zwykle
Nic w samej usłudze nie musi ulec zmianie. Nadal może korzystać z inject() oraz wszystkich innych elementów ekosystemu Angular. W tym przykładzie injectuje HttpClient i importuje pdfMake – dużą zależność, którą chcesz wykluczyć z początkowego ładowania.
// 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
}
}
Ponieważ nic w kodzie załadowanym wcześniej nie importuje tego pliku w sposób statyczny, narzędzie do pakowania może wygenerować go oraz pdfMake jako oddzielny fragment.
Krok 2: rozwiązanie problemu z usługą na granicy interakcji
Zamiast injectować usługę jako pole klasy, komponent żąda jej po kliknięciu przycisku eksportu. Sygnał isExporting kontroluje stan i etykietę przycisku podczas pobierania fragmentu oraz wykonywania zadań.
// 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);
}
}
}
Gdy uruchamia się obsługa zdarzenia, przeglądarka pobiera fragment usługi, Angular tworzy instancję i dostarcza jej zależności, takie jak HttpClient, a komponent wywołuje metodę generate(). Tutaj istotne są bloki try/catch/finally: awaria sieci podczas ładowania fragmentu może teraz stanowić błąd w czasie wykonywania, a blok finally gwarantuje ponowne włączenie przycisku we wszystkich przypadkach.
Jeden szczegół do sprawdzenia w dokumentacji: podobnie jak w przypadku inject(), funkcje DI zazwyczaj muszą być wywoływane w kontekście iniekcji, np. w konstruktorze lub w inicjalizatorze pola, a nie w dowolnym obsłudzycielu zdarzeń. Jeśli twoja wersja Angular wymaga tego również dla injectAsync, wywołaj ją w inicjalizatorze pola, aby uzyskać loader, a następnie użyj tego loadera w obsłudzycielu kliknięcia.
Po stworzeniu wersji produkcyjnej plik wyjściowy zawiera mały plik main.js oraz oddzielny fragment heavy-report.service-[hash].js. Użytkownicy, którzy nigdy nie klikają przycisku eksportu, w ogóle nie pobierają biblioteki PDF.
Ładowanie warunkowe: wysyłanie tylko kodu, który może być uruchomiony przez użytkownika
Dzielenie dużej biblioteki na fragmenty to oczywiste zastosowanie, ale ten sam mechanizm umożliwia warunkowe dostarczanie zależności. Załóżmy, że konta darmowe używają StandardDataProcessor, podczas gdy konta korporacyjne o wyższej klasie otrzymują AIProDataProcessor.
Podejście tradycyjne łączy obie implementacje i wykorzystuje dostawcę typu fabryka do wyboru jednej w czasie uruchamiania, więc każdy użytkownik pobiera obie. Z użyciem injectAsync gałąź decyduje, który fragment ma zostać pobraany:
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);
}
Klienci premium płacą za koszty sieciowe procesora AI, natomiast wszyscy pozostali korzystają z wersji lżejszej. Jest to polimorfizm w czasie wykonywania, przy którym wybór określa również to, co zostanie pobraane, dzieląc rozmiar danych ładowanych przez użytkownika od całkowitego rozmiaru bazy kodu. W obecnym ujęciu isProcessing pozostaje na wartości true, gdy odbywa się ładowanie lub gdy metoda execute() rzuci błąd; należy otoczyć treść kodu blokami try/finally, jak w przykładzie eksportu. Zapewnienie wspólnej interfejsu dla obu procesorów pozwala również na prawidłowe typowanie zmiennej processor, zamiast jej domyślnego typu any.
Kiedy nie należy go używać
Ładowanie opóźnione polega na poświęceniu początkowej wielkości pliku w zamian za opóźnienie w momencie użycia. W przypadku usług, których większość użytkowników potrzebuje przy każdym wejściu, ich odroczenie tylko przesuwa czas oczekiwania na gorszy moment. Ma sens w przypadku ciężkich, rzadko używanych lub specyficznych dla określonych poziomów funkcjonalności. Można również złagodzić opóźnienie, uruchamiając ładowanie przy przejściu kursorem nad przyciskiem lub po skupieniu na nim, zanim nastąpi kliknięcie.
Główne wnioski
- Najpierw przeprowadź audyt: użyj narzędzia takiego jak source-map-explorer lub podobnego, aby znaleźć ciężkie usługi, które są używane tylko rzadko lub warunkowo.
- Usuń statyczne włączanie tych usług z komponentów ładowanych natychmiastowo, ponieważ to właśnie statyczne importy włączają je do głównego pliku.
- Ładuj je w momencie intencji użytkownika za pomocą
injectAsynci dynamicznegoimport(), zachowując pełne wsparcie dla iniekcji zależności.
Literatura pokrewna
- Jak Angular Dependency Injection rozwiązuje usługi za pomocą inject() — Zrozum, w jaki sposób Angular DI dostarcza usługi za pośrednictwem funkcji inject(), providerów i hierarchicznych injectorów, oraz jak określać zakres instancji i unikać błędów związanych z brakującymi providerami.
- Zamiana flag ładowania w Angularu za pomocą Discriminated AsyncState Union — Modeluj operacje asynchroniczne w Angularu jako jedną zunion typową, wyznaczaj wartości logiczne za pomocą sygnałów obliczanych i steruj każdą zmianą stanu za pomocą pojedynczego operatora RxJS.