Ієрархія інжекторів в Angular
Ієрархія інжекторів в Angular - це дерево екземплярів Injector, що відображає дерево компонентів. Коли Angular резолвить залежність, він починає з найближчого інжектора і рухається вгору, поки не знайде провайдер або не кине помилку.
Теорія
Коротко
- Як корпоративна ієрархія: спочатку питаємо свого менеджера (локальний інжектор), потім керівника відділу (модуль), потім CEO (root)
- Дочірній інжектор наслідує від батьківського, але перекриває його, якщо сам декларує провайдер для того ж токена
providedIn: 'root'створює один синглтон для всього застосунку;Component.providersстворює новий екземпляр для кожного компонента- Ліниво завантажені модулі мають окремий інжектор, тому сервіс у
NgModule.providersлінивого модуля НЕ є спільним з root - Правило вибору: спільний стан по всьому застосунку = root, ізоляція фічі = модуль, дані для конкретного екземпляра = компонент
Швидкий приклад
// Root інжектор має UserService (providedIn: 'root')
@Injectable({ providedIn: 'root' })
export class UserService { name = 'real user'; }
// ChildComponent декларує власний провайдер - перекриває root
@Component({
selector: 'app-child',
template: '...',
providers: [{ provide: UserService, useClass: MockUserService }]
})
export class ChildComponent {
constructor(private user: UserService) {
console.log(user instanceof MockUserService); // true - локальний інжектор перемагає
}
}Дочірній компонент отримає MockUserService, навіть якщо в root є UserService. Сусідні компоненти ChildComponent і далі отримуватимуть root-екземпляр.
Як Angular резолвить залежності
Коли компонент або сервіс запитує токен, Angular рухається вгору по дереву інжекторів:
- Перевіряє власний інжектор компонента (оголошений у
providers[]) - Якщо не знайдено - перевіряє інжектор батьківського компонента
- Продовжує вгору через всіх предків
- Досягає інжектора модуля
- Досягає root (application) інжектора
- Якщо нічого не знайдено - кидає
NullInjectorError
Кожен екземпляр Injector зберігає резолвнуті сервіси в records map. Тому один і той самий токен не інстанціюється двічі в межах одного інжектора. Це і є гарантія синглтона на кожному рівні.
Чотири рівні інжекторів
Root інжектор. @Injectable({ providedIn: 'root' }) реєструє сервіс на рівні застосунку. Один екземпляр на весь застосунок, створюється лениво при першому запиті. Підтримує tree-shaking: якщо сервіс ніхто не інжектує, він не потрапляє в бандл.
Інжектор модуля. Якщо сервіс вказаний у @NgModule.providers, він живе в інжекторі цього модуля. Для eagerly loaded модулів це зливається з root. Для lazy-loaded модулів створюється дочірній інжектор, тобто сервіс відокремлений від root.
Інжектор компонента. Додаючи сервіс у @Component.providers, отримуємо новий екземпляр для кожного екземпляра компонента. Цей екземпляр знищується разом із компонентом. Дочірні компоненти наслідують його, якщо не перевизначають.
Інжектор елемента (директиви). @Directive.providers працює так само, як провайдери компонента, але прив'язаний до елемента, де застосовується директива.
Коли що використовувати
- Спільний стан по всьому застосунку (авторизація, логування, feature flags):
providedIn: 'root' - Ізоляція фічі в lazy-loaded роуті:
NgModule.providersвсередині лінивого модуля - Дані для конкретного екземпляра компонента (локальний стан форми, кеш картки):
Component.providers - Мокування сервісу в піддереві для тестів або e2e: провайдер на рівні компонента або модуля з
useClass - Бібліотечні сервіси, що не повинні забруднювати глобальний простір:
providedIn: 'platform'або рівень модуля
Як Angular будує це під капотом
При завантаженні Angular рекурсивно створює екземпляри Injector для кожного @Component з масивом providers[] і пов'язує кожен із батьківським через властивість parent. Під час резолвінгу викликається getAt(), що рухається вгору від місця запиту. Результати кешуються в records map кожного інжектора, тому повторний запит того ж токена повертає закешований екземпляр без повторної інстанціації.
В Angular 14+ providedIn: 'root' аналізується статично, що уможливлює tree-shaking. Сервіси в масивах providers[] не підлягають tree-shaking, бо вони явно вказані в метаданих модуля або компонента.
Standalone-компоненти (Angular 14+) частково спрощують цю картину: вони використовують EnvironmentInjector замість NgModule-інжекторів. Але провайдери на рівні компонента і далі створюють елементні інжектори так само.
Типові помилки
Помилка 1: очікування що провайдер лінивого модуля використовує root-екземпляр
// Неправильно: це створює ОКРЕМИЙ екземпляр, а не root-синглтон
@NgModule({
providers: [DataService] // Ліниво завантажений модуль має власний інжектор!
})
export class FeatureModule {}Якщо DataService також providedIn: 'root', провайдер лінивого модуля перекриє його для всього, що всередині. Компоненти поза модулем і далі отримують root-екземпляр. Щоб використовувати root: видали з NgModule.providers і покладайся лише на providedIn: 'root'.
Помилка 2: оголошення сервісу і в модулі, і в компоненті
@NgModule({ providers: [CartService] })
// ...і також:
@Component({ providers: [CartService] })
export class CartComponent {}Інжектор компонента перекриє інжектор модуля. Ти отримаєш два окремі екземпляри, що витрачають зайву пам'ять. Angular DevTools покаже це: відкрий дерево компонентів і перевір джерела інжектора для кожного компонента.
Помилка 3: забуваєш що дочірні компоненти наслідують провайдери батьківського компонента
@Component({ providers: [SharedService] })
export class ParentComponent {}
// ChildComponent отримає екземпляр SharedService від ParentComponent
// НЕ root-екземпляр, НЕ новий
@Component({ selector: 'app-child', template: '' })
export class ChildComponent {
constructor(private s: SharedService) {}
}Я бачив як команди додають провайдер до батьківського компонента "тільки для цього батька", а потім дивуються чому дочірній компонент отримує несподіваний стан. Дочірній наслідує автоматично.
Помилка 4: providedIn: 'root' у бібліотеці, що публікується
Це реєструє сервіс у root-інжекторі застосунку під час збірки. Для звичайних застосунків - нормально. Але для бібліотек це може конфліктувати зі споживачами. Використовуй providedIn: 'platform' для сервісів, що мають бути спільними між мікрофронтендами, або залиш вибір застосунку-споживачу.
Помилка 5: плутанина між viewProviders і providers
viewProviders обмежує сервіс лише view компонента (шаблоном), виключаючи контент, проєктований через <ng-content>. providers ділиться і з view, і з content-дочірніми. Неправильний вибір призводить до NullInjectorError у проєктованих компонентах, що намагаються інжектувати цей сервіс.
Де зустрічається в реальних проєктах
- Angular Material:
OverlayRefі сервіси діалогів використовуютьprovidedIn: 'root'для синглтона overlay-контейнера - NgRx:
StoreModule.forRoot()реєструє стан на рівні root;StoreModule.forFeature()додає стан фічі в дочірній інжектор модуля - Nx workspaces: ліниві feature-бібліотеки використовують провайдери на рівні модуля для ізоляції сервісів від shell-застосунку
- Angular Universal (SSR): провайдери на рівні платформи замінюють браузерні API на Node-сумісні без будь-яких змін у root
Питання на співбесіді
Q: Що відбувається коли lazy-loaded модуль надає сервіс, що також є providedIn: 'root'?
A: Інжектор лінивого модуля перекриє root для будь-якого компонента всередині цього модуля. Компоненти поза лінивим модулем і далі отримують root-синглтон. Два окремі екземпляри існують одночасно в одному застосунку.
Q: Як @SkipSelf() змінює резолвінг?
A: Він пропускає інжектор поточного компонента і починає пошук з батьківського. Корисно коли сервіс потребує доступу до своєї батьківської версії, наприклад вузол дерева, якому потрібен TreeService батька, а не свій власний.
Q: Що робить @Self()?
A: Обмежує пошук лише інжектором поточного компонента. Якщо токен там не знайдено - Angular одразу кидає помилку без підйому по дереву. Використовується щоб гарантувати що залежність обов'язково надається на тому ж рівні, що й споживач.
Q: Як standalone-компоненти змінюють ієрархію інжекторів?
A: Standalone-компоненти (Angular 14+) використовують EnvironmentInjector замість NgModule-інжекторів. Функція bootstrapApplication() створює root EnvironmentInjector. Провайдери на рівні компонента і далі створюють елементні інжектори. Функція inject() в реактивних контекстах, зокрема в сигналах, використовує те саме дерево.
Q (senior): Директива застосована до компонента і директива інжектує сервіс. Де Angular шукає цей сервіс?
A: Пошук починається в інжекторі елемента, на якому стоїть директива. Він включає провайдери і компонента, і директиви одночасно. Після цього Angular рухається вгору по дереву компонентів звичайним чином. Директива не має окремої гілки інжектора; вона поділяє контекст інжектора елемента з хост-компонентом.
Приклади
Базовий: ізольований екземпляр на компонент
// counter.service.ts
@Injectable()
export class CounterService {
count = 0;
increment() { this.count++; }
}
// card.component.ts
@Component({
selector: 'app-card',
template: `
<button (click)="counter.increment()">+</button>
<span>{{ counter.count }}</span>
`,
providers: [CounterService] // Кожна картка отримує свій власний лічильник
})
export class CardComponent {
constructor(public counter: CounterService) {}
}Два елементи <app-card> на одній сторінці показують незалежні лічильники. Якби CounterService був providedIn: 'root', вони б ділили стан і обидві кнопки збільшували б одне число.
Середній: мокування сервісу в lazy-loaded фічі
// auth.service.ts
@Injectable({ providedIn: 'root' })
export class AuthService {
isLoggedIn = false;
login() { this.isLoggedIn = true; }
}
// mock-auth.service.ts
@Injectable()
export class MockAuthService extends AuthService {
isLoggedIn = true; // Завжди залогований для e2e-тест модуля
}
// auth-test.module.ts (lazy loaded)
@NgModule({
providers: [{ provide: AuthService, useClass: MockAuthService }]
})
export class AuthTestModule {}
// dashboard.component.ts (всередині AuthTestModule)
@Component({ template: `<span *ngIf="auth.isLoggedIn">Вітаємо</span>` })
export class DashboardComponent {
constructor(public auth: AuthService) {}
// Отримує MockAuthService - завжди показує "Вітаємо"
}Root-екземпляр AuthService залишається незміненим. Лише компоненти всередині AuthTestModule бачать мок. Все поза лінивою межею і далі використовує реальний синглтон.
Просунутий: @SkipSelf() для самореференційних сервісів
// logger.service.ts
@Injectable()
export class LoggerService {
constructor(
@Optional() @SkipSelf() private parent: LoggerService
) {}
log(msg: string) {
const prefix = this.parent ? '[child]' : '[root]';
console.log(`${prefix} ${msg}`);
}
}
// parent.component.ts
@Component({
selector: 'app-parent',
template: '<app-child></app-child>',
providers: [LoggerService] // Root логер
})
export class ParentComponent {
constructor(private logger: LoggerService) {
this.logger.log('parent init'); // "[root] parent init"
}
}
// child.component.ts
@Component({
selector: 'app-child',
template: '',
providers: [LoggerService] // Дочірній логер, знає про батька
})
export class ChildComponent {
constructor(private logger: LoggerService) {
this.logger.log('child init'); // "[child] child init"
}
}@SkipSelf() каже Angular шукати LoggerService починаючи з батьківського компонента, а не з поточного. У поєднанні з @Optional() це уникає помилки кругової залежності на рівні root, де батьківського логера просто немає.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.