Розділення коду важких сервісів Angular за потреби за допомогою injectAsync
Чому статично вставлений сервіс бере свої бібліотеки до основного пакету Angular, та як injectAsync здійснює лейзі-завантаження сервісів за бажанням користувача, включаючи умовне завантаження.
Джерела: Stop Loading Massive Services: Mastering injectAsync in Angular 22
Режим затриманого завантаження на рівні маршруту та блоки @defer дозволяють уникнути включення рідко використовуваних сторінок та компонентів до початкового пакету Angular-додатку, але з сервісами ситуація інша. Одне лише використання inject() для важкого сервісу у компоненті, який завжди видимий, призводить до того, що цей сервіс та всі бібліотеки, які він імпортує, потрапляють до основного частини пакету. У цій статті пояснюється, чому це відбувається, чому старі способи були незручними, та як API injectAsync, введене у Angular 22, дозволяє завантажувати код сервісу лише тоді, коли користувач його дійсно потребує.
Розглядайте наведені нижче деталі API як тимчасову інформацію: на момент написання injectAsync ще є новим, тому перед його використанням переконайтеся у його точній сигнатурі, статусі стабільності та правилах використання у актуальній документації Angular.
Знайома проблема з пакетом
PdfExportService, який використовує важкі бібліотеки для рендерингу на стороні клієнта з метою створення детальних багатосторінкових фінансових звітів.
Angular вже пропонує @defer та розбиття коду за маршрутами для компонентів та сторінок. Однак досі він не мав зручного способу лендингового завантаження сервісу, залишаючи його в межах системи внутрішньої інжекції залежностей (DI).
Чому статичний метод inject() потрапляє до основного бандлу
Щоб зрозуміти, які зміни приносить injectAsync, корисно знати, як інструменти бандлінгу на кшталт esbuild та Vite взаємодіють із системою DI Angular. Розглянемо компонент заголовка, який інжектує сервіс звітів у звичайний спосіб:
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();
}
}
Важливою є оператор import найвищого рівня. Инструмент збирання коду аналізує імпорти статично, бачить, що DashboardHeaderComponent залежить від HeavyReportService, і поміщає цю службу разом із кожною бібліотекою стороннього виробника, яку вона імпортує, у той самий фрагмент, що й компонент. Якщо компонент є частиною початкового відображення, то й бібліотека PDF також. Сам виклик inject() не є проблемою; проблемою є статичний імпорт, який це уможливлює.
Старіший спосіб обходу та його недоліки
Досвідчені розробники вже давно уникають цієї проблеми, використовуючи динамічний await import(...) у обробнику кліків, щоб інструмент збирання коду випускав службу як окремий фрагмент.
Проблема полягає у тому, що динамічно імпортована класифікація — це просто класифікація. Якщо ви створюєте її самостійно, вона знаходиться поза інжектором Angular, тому такі залежності, як HttpClient для отримання шаблону чи сервіс автентифікації для додавання токена, не надаються. Щоб отримати належним чином підключену інстанцію, потрібно заздалегідь ввести Injector та через нього розібрати завантажену класифікацію, що працює, але розповсюджує механізми DI по коду компонентів та легко призводить до помилок. (Щоб ознайомитися з тим, як саме відбувається розбір, дивіться як Angular використовує інжекцію залежностей для розбору сервісів.)
Що змінює injectAsync
injectAsync заповнює прогалину між динамічним імпортом ECMAScript та структурою інжектора в Angular. Ви описуєте залежність за допомогою функції-завантажувача, тим самим повідомляючи інструмент збирання коду: не включайте це до основного блоку, а завантажуйте та інстанціюйте його лише за запитом.
Ін’єкція залежностей більше не мусить бути синхронною. Завантаження коду сервісу може відбутися лише тоді, коли користувач проявить бажання цього.
Крок 1: напишіть важкий сервіс як зазвичай
У самому сервісі нічого змінювати не потрібно. Він все ще може використовувати inject() та будь-які інші компоненти екосистеми Angular. Тут він інжектує HttpClient та імпортує pdfMake — велику залежність, яку потрібно не включати до початкового завантаження.
// 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
}
}
Оскільки в коді, який завантажується енергійно, ніщо не імпортує цей файл статично, бандлер може вивести його разом із pdfMake як окремий фрагмент.
Крок 2: вирішення проблеми з сервісом на межі взаємодії
Замість того, щоб вставляти сервіс як поле класу, компонент запитує його під час натискання кнопки експорту. Сигнал isExporting керує станом та позначкою кнопки під час завантаження фрагмента та виконання роботи.
// 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);
}
}
}
Коли виконується обробник, браузер завантажує частину сервісу, Angular створює його екземпляр та забезпечує необхідними залежностями, такими як HttpClient, а компонент викликає метод generate(). Тут важливі блоки try/catch/finally: збій мережі під час завантаження частини тепер може стати помилкою під час виконання, а блок finally гарантує, що кнопка буде знову увімкнена у будь-якому випадку.
Один момент, який потрібно перевірити за документацією: як і у випадку з inject(), функції DI зазвичай мають викликатися у контексті ін’єкції, наприклад у конструкторі чи функції ініціалізації поля, а не всередині довільного обробника подій. Якщо ваша версія Angular вимагає цього для injectAsync, використовуйте його у функції ініціалізації поля, щоб отримати загрузчик, а потім викликайте цей загрузчик з обробника кліку.
Після створення версії для виробництва результат містить невеликий файл main.js та окремий фрагмент heavy-report.service-[hash].js. Користувачі, які ніколи не клікають на експорт, зовсім не завантажують бібліотеку PDF.
Розділення великої бібліотеки на частини — це очевидне застосування, але той самий механізм дозволяє здійснювати умовне інжектування залежностей. Припустимо, безкоштовні облікові записи використовують StandardDataProcessor, тоді як преміум-корпоративні облікові записи отримують AIProDataProcessor.
Традиційний підхід об’єднує обидві реалізації та використовує фабричного постачальника для вибору однієї під час виконання, тож кожен користувач завантажує обидві. За допомогою injectAsync гілка вирішує, який фрагмент потрібно завантажити:
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);
}
Професійні користувачі оплачують витрати мережі на роботу процесора ШІ, а всі інші отримують легкий варіант. Це є поліморфізмом під час виконання, де саме вибір визначає те, що завантажується, що дозволяє відокремити розмір того, що завантажує користувач, від загального розміру кодової бази. У нинішньому вигляді isProcessing залишається значенням true, якщо відбувається завантаження, або якщо функція execute() кидає помилку; потрібно обгорнути основний код у блоки try/finally, як у прикладі експорту. Надання обом процесорам спільного інтерфейсу також дозволяє зберегти правильний тип змінної processor, замість того щоб вона була імпліцитно типу any.
Коли не варто його використовувати
Ліниве завантаження жертвує початковим розміром пакета заради затримки у момент використання. Для послуг, які більшість користувачів потребують при кожному візиті, їх відкладення лише переносить очікування на гірший момент. Це має сенс для важких, рідко використовуваних або специфічних для певного рівня функцій. Ви також можете зменшити затримку, запускаючи завантаження під час наведення курсору або фокусу на кнопці, ще до натискання.
Ключові висновки
- Спочатку проведіть аудит: використовуйте source-map-explorer або схожий інструмент, щоб знайти важкі послуги, які використовуються лише рідко або умовно.
- Видаліть статичні вставки цих послуг з компонентів, які завантажуються негайно, адже саме статичний імпорт включає їх до основного пакета.
- Завантажуйте їх у момент наміру користувача за допомогою
injectAsyncта динамічногоimport(), зберігаючи повну підтримку інжектування залежностей.