Розширені патерни впровадження залежностей в Angular
Розширені патерни dependency injection в Angular використовують кастомні токени, фабричні функції, multi-providers та декоратори ієрархії ін'єкторів для побудови архітектури сервісів, яка виходить далеко за межі реєстрації одного класу.
Теорія
TL;DR
- Базовий DI: один токен, один екземпляр. Розширений DI: масиви, фабрики, дерево ін'єкторів.
multi: trueзбирає всі providers для токена в масив. Без нього кожен наступний provider мовчки перезаписує попередній.useFactoryвиконується в момент резолюції, тому всередині можна викликатиinject()для динамічних залежностей.@Self,@SkipSelf,@Optionalвизначають, в якому ін'єкторі шукати, а не що шукати.- Providers на рівні компонента створюють новий екземпляр для кожного компонента, а не спільний синглтон.
Швидкий приклад
Multi-provider для HTTP interceptors, найпоширеніший розширений патерн DI:
// app.config.ts
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
]
// При впровадженні отримуємо масив, не один екземпляр
constructor(@Inject(HTTP_INTERCEPTORS) private interceptors: HttpInterceptor[]) {
// interceptors = [екземпляр AuthInterceptor, екземпляр LoggingInterceptor]
}
// Ланцюжок запиту: Auth -> Logging -> HttpClientБез multi: true другий provider перезаписує перший. Angular не попередить про це. Interceptor просто зникне.
Ключова різниця
Базовий DI резолвить один клас або значення для токена. Розширені патерни змінюють саме значення поняття «один». Multi-providers збирають всі відповідні записи в масив під час обходу дерева ін'єкторів. Factory providers виконують функцію в момент резолюції замість виклику new. Абстрактні класи як токени дозволяють міняти реалізацію без зміни коду споживача. Всі три патерни працюють всередині одного ієрархічного дерева ін'єкторів, яке Angular будує при старті.
Коли використовувати
- Кілька обробників для однієї задачі (interceptors, валідатори, плагіни) →
multi: true - Логіка вибору реалізації в runtime (браузер vs SSR, dev vs prod) →
useFactory - Сторонні об'єкти або легасі-код без
@Injectable→useValueабоuseExisting - Фіча-модуль потребує власного екземпляра, а не root-синглтона → providers на рівні компонента або роуту
- Заміна реалізації за стабільним контрактом → абстрактний клас як токен
Як Angular резолвить залежності
Angular будує дерево екземплярів Injector при старті. Корінь дерева - це platform injector, нижче знаходиться root app injector, потім модульні ін'єктори, потім компонентні. Коли ти викликаєш inject(Token) або оголошуєш параметр конструктора, Angular іде вгору по дереву з поточного вузла, поки не знайде відповідний provide-ключ.
Для multi-providers Angular збирає кожен відповідний запис з усього обходу і повертає їх як масив у порядку реєстрації. Для звичайних providers повертає першу знайдену відповідність на шляху вгору. Саме тому provider на рівні компонента перекриває root-провайдер для піддерева цього компонента, але сусідні компоненти продовжують бачити root-екземпляр.
Декоратори ін'єкції: @Self, @SkipSelf, @Optional
Ці три декоратори визначають, де шукати.
@Self() обмежує пошук поточним ін'єктором. Якщо токен там відсутній, Angular одразу кидає помилку. Використовуй, коли сервіс має сенс тільки на рівні компонента, наприклад контролер форми, який обов'язково має бути оголошений на тому ж елементі.
@SkipSelf() навпаки - пропускає поточний ін'єктор і починає пошук з батьківського. Так дочірні компоненти можуть отримати доступ до сервісу батька, надаючи власну реалізацію своїм дочірнім елементам.
@Optional() повертає null замість викидання помилки, якщо нічого не знайдено. Зручно для сервісів аналітики або feature-флагів, які реєструються не завжди.
@Component({ providers: [FormService] })
export class FormComponent {
// Кидає помилку, якщо FormService відсутній в ін'єкторі ЦЬОГО компонента
constructor(@Self() private form: FormService) {}
}
@Component({})
export class ChildComponent {
// Використовує LoggerService батька, ігнорує будь-який локальний override
constructor(@SkipSelf() private logger: LoggerService) {}
}Абстрактний клас як токен
TypeScript-інтерфейси зникають після компіляції, тому їх не можна використовувати як DI-токени. Абстрактні класи залишаються після компіляції і генерують реальні JavaScript-функції конструктора, які Angular може використати як ключ у своїй provider map.
abstract class StorageService {
abstract get(key: string): string | null;
abstract set(key: string, value: string): void;
}
@Injectable()
class LocalStorageService extends StorageService {
get(key: string) { return localStorage.getItem(key); }
set(key: string, value: string) { localStorage.setItem(key, value); }
}
// У providers:
{ provide: StorageService, useClass: LocalStorageService }
// У споживача:
constructor(private storage: StorageService) {
this.storage.get('token'); // фактична реалізація: LocalStorageService
}Заміни LocalStorageService на SessionStorageService в одному місці. Більше нічого міняти не потрібно.
Типові помилки
Забутий multi: true для HTTP interceptors
Це найчастіша DI-помилка в Angular-проектах. Працює нормально з одним interceptor, ламається мовчки, коли другий розробник додає ще один.
// Помилка: другий provider перезаписує перший
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor }, // перезаписує Auth
]
// Правильно
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: LoggingInterceptor, multi: true },
]Виклик inject() поза injection context
@Injectable()
class BadService {
private dep = inject(OtherService); // OK - ініціалізатор поля виконується під час створення
someMethod() {
const dep = inject(OtherService); // Кидає помилку: inject() працює тільки під час construction
}
}inject() працює в ініціалізаторах полів, конструкторах та функціях useFactory. В будь-якому іншому місці - runtime-помилка.
Кругові залежності у фабриках
{ provide: A, useFactory: () => new A(inject(B)) }
{ provide: B, useFactory: () => new B(inject(A)) } // A потребує B, B потребує AУ dev-режимі Angular детектує цикли та кидає помилку. У деяких prod-збірках краш відбувається без зрозумілого повідомлення. Розривай цикл через Injector з ледачою резолюцією або переструктуруй граф залежностей.
Очікування, що провайдери lazy-модуля будуть видимі у сусідніх роутах
Lazy-завантажений модуль створює власний дочірній ін'єктор. Провайдери, зареєстровані там, недоступні іншим роутам. Якщо потрібен спільний сервіс для кількох lazy-роутів, реєструй його в root-ін'єкторі через providedIn: 'root'.
Де зустрічається в реальних проектах
- Angular HttpClient використовує
HTTP_INTERCEPTORSяк multi-provider токен за замовчуванням - NGRX і NGXS реєструють плагіни стора через кастомні multi-provider токени
- Nx-монорепозиторії використовують
useFactoryзPLATFORM_IDдля env-специфічного конфігу в SSR-додатках APP_CONFIGinjection tokens - стандарт у standalone Angular-додатках для feature-флагів та API URL
Follow-up питання
Q: Яка різниця між useClass і useFactory?
A: useClass викликає new для класу, Angular сам обробляє ін'єкцію конструктора. useFactory запускає звичайну функцію в момент резолюції - всередині можна писати умовну логіку і викликати inject() для додаткових залежностей.
Q: Як multi: true взаємодіє з ієрархією ін'єкторів?
A: Кожен ін'єктор у дереві збирає власні multi-provider записи. Дочірні ін'єктори додають свої записи першими, потім батьківські. Порядок у фінальному масиві відповідає порядку реєстрації на кожному рівні.
Q: Коли Angular створює новий екземпляр сервісу, а коли перевикористовує існуючий?
A: Angular створює один екземпляр на ін'єктор для кожного токена. providedIn: 'root' - один екземпляр у root-ін'єкторі, спільний для всього. Provider на рівні компонента - новий екземпляр для кожного екземпляра компонента, який знищується разом з ним.
Q: Чому TypeScript-інтерфейс не можна використовувати як DI-токен?
A: Інтерфейси стираються після компіляції. В runtime немає об'єкта, який Angular міг би використати як ключ у своїй provider map. Абстрактні класи залишаються, бо генерують реальну JavaScript-функцію конструктора.
Q (senior): У lazy-завантаженому роуті є providedIn: 'root' сервіс, перевизначений через route-level providers. Що отримає сусідній роут при ін'єкції цього сервісу?
A: Root-екземпляр. Перевизначення діє тільки в межах піддерева ін'єкторів lazy-роуту. Сусідні роути поділяють батьківський ін'єктор і не бачать override.
Приклади
Базовий: env-залежний логер через useFactory
import { InjectionToken, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
export const LOGGER = new InjectionToken<Logger>('LOGGER');
// У providers:
{
provide: LOGGER,
useFactory: () => {
const platformId = inject(PLATFORM_ID);
return isPlatformBrowser(platformId)
? new ConsoleLogger() // Браузер: логуємо в консоль
: new NoopLogger(); // SSR: нічого не логуємо, уникаємо hydration-проблем
}
}
// Використання
@Injectable()
export class ApiService {
private logger = inject(LOGGER);
fetchData() {
this.logger.log('Завантажую дані'); // В браузері - лог, в SSR - тиша
}
}Фабрика виконується один раз на ін'єктор при створенні. Вона сама обирає потрібну реалізацію залежно від середовища, без жодного if у коді споживача.
Середній: APP_CONFIG токен для feature-флагів
export interface AppConfig {
apiUrl: string;
debug: boolean;
}
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config');
// main.ts
bootstrapApplication(AppComponent, {
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: environment.apiUrl,
debug: !environment.production,
}
}
]
});
// У будь-якому сервісі або компоненті
@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(APP_CONFIG);
getUrl(path: string) {
if (this.config.debug) console.log(`Запит: ${this.config.apiUrl}/${path}`);
return `${this.config.apiUrl}/${path}`;
}
}InjectionToken з useValue - стандартний спосіб передати статичну конфігурацію через DI без створення окремого класу.
Складний: Ієрархічне перевизначення у lazy-завантаженій фічі
// Root надає глобальний ApiService
@Injectable({ providedIn: 'root' })
export class ApiService { baseUrl = '/api'; }
// Feature-сервіс розширює поведінку root
@Injectable()
export class FeatureApiService extends ApiService { baseUrl = '/api/feature'; }
// Lazy-роут перевизначає ApiService тільки для свого піддерева
const routes: Routes = [
{
path: 'feature',
loadComponent: () => import('./feature.component').then(m => m.FeatureComponent),
providers: [{ provide: ApiService, useClass: FeatureApiService }]
}
];
// FeatureComponent та його нащадки отримають FeatureApiService
// Всі інші роути продовжують отримувати root ApiServiceЦе патерн для multi-tenant дашбордів або white-label застосунків, де різні секції потребують іншої поведінки API, але використовують той самий код споживача.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.