Skip to main content

Ієрархія інжекторів в Angular

Ієрархія інжекторів в Angular - це дерево екземплярів Injector, що відображає дерево компонентів. Коли Angular резолвить залежність, він починає з найближчого інжектора і рухається вгору, поки не знайде провайдер або не кине помилку.

Теорія

Коротко

  • Як корпоративна ієрархія: спочатку питаємо свого менеджера (локальний інжектор), потім керівника відділу (модуль), потім CEO (root)
  • Дочірній інжектор наслідує від батьківського, але перекриває його, якщо сам декларує провайдер для того ж токена
  • providedIn: 'root' створює один синглтон для всього застосунку; Component.providers створює новий екземпляр для кожного компонента
  • Ліниво завантажені модулі мають окремий інжектор, тому сервіс у NgModule.providers лінивого модуля НЕ є спільним з root
  • Правило вибору: спільний стан по всьому застосунку = root, ізоляція фічі = модуль, дані для конкретного екземпляра = компонент

Швидкий приклад

ts
// 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 рухається вгору по дереву інжекторів:

  1. Перевіряє власний інжектор компонента (оголошений у providers[])
  2. Якщо не знайдено - перевіряє інжектор батьківського компонента
  3. Продовжує вгору через всіх предків
  4. Досягає інжектора модуля
  5. Досягає root (application) інжектора
  6. Якщо нічого не знайдено - кидає 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-екземпляр

ts
// Неправильно: це створює ОКРЕМИЙ екземпляр, а не root-синглтон @NgModule({ providers: [DataService] // Ліниво завантажений модуль має власний інжектор! }) export class FeatureModule {}

Якщо DataService також providedIn: 'root', провайдер лінивого модуля перекриє його для всього, що всередині. Компоненти поза модулем і далі отримують root-екземпляр. Щоб використовувати root: видали з NgModule.providers і покладайся лише на providedIn: 'root'.

Помилка 2: оголошення сервісу і в модулі, і в компоненті

ts
@NgModule({ providers: [CartService] }) // ...і також: @Component({ providers: [CartService] }) export class CartComponent {}

Інжектор компонента перекриє інжектор модуля. Ти отримаєш два окремі екземпляри, що витрачають зайву пам'ять. Angular DevTools покаже це: відкрий дерево компонентів і перевір джерела інжектора для кожного компонента.

Помилка 3: забуваєш що дочірні компоненти наслідують провайдери батьківського компонента

ts
@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 рухається вгору по дереву компонентів звичайним чином. Директива не має окремої гілки інжектора; вона поділяє контекст інжектора елемента з хост-компонентом.

Приклади

Базовий: ізольований екземпляр на компонент

ts
// 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 фічі

ts
// 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() для самореференційних сервісів

ts
// 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, де батьківського логера просто немає.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?