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

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