Angular Dependency injection в действии
Рассмотрим особенности, с которыми можно столкнуться при работе с Dependency Injection.
Несколько экземпляров одного сервиса (Sandboxing)
Иногда требуется использовать несколько экземпляров сервиса на одном уровне компонентов. Примером послужит сервис, который хранит состояние для своего компонента. Необходим отдельный экземпляр сервиса для каждого компонента, при этом каждый сервис обладает собственным состоянием, изолированным от состояния других компонентов. Такой подход называется "песочницей
" (sandbox
), поскольку каждый экземпляр сервиса и компонента имеет свою собственную "песочницу". В данном примере HeroBiosComponent
представляет три экземпляра HeroBioComponent
.
// src/app/hero-bios.component.ts @Component({ standalone: true, selector: 'app-hero-bios', template: ` <app-hero-bio [heroId]="1"></app-hero-bio> <app-hero-bio [heroId]="2"></app-hero-bio> <app-hero-bio [heroId]="3"></app-hero-bio>`, providers: [HeroService], imports: [HeroBioComponent] }) export class HeroBiosComponent { }
Каждый HeroBioComponent
может редактировать биографию одного героя. HeroBioComponent
использует HeroCacheService
для загрузки, кэширования и выполнения других операций постоянного хранения данных героя.
// src/app/hero-cache.service.ts @Injectable() export class HeroCacheService { hero!: Hero; constructor(private heroService: HeroService) {} fetchCachedHero(id: number) { if (!this.hero) { this.hero = this.heroService.getHeroById(id); } return this.hero; } }
Три экземпляра компонента HeroBioComponent
не могут использовать один и тот же экземпляр HeroCacheService
, так как они будут конкурировать за кэширование данных героя. Вместо этого каждый HeroBioComponent
получает собственный экземпляр HeroCacheService
, указав HeroCacheService
в массиве провайдеров.
// src/app/hero-bio.component.ts @Component({ standalone: true, selector: 'app-hero-bio', template: ` <h4>{{hero.name}}</h4> <ng-content></ng-content> <textarea cols="25" [(ngModel)]="hero.description"></textarea>`, providers: [HeroCacheService], imports: [FormsModule] }) export class HeroBioComponent implements OnInit { @Input() heroId = 0; constructor(private heroCache: HeroCacheService) { } ngOnInit() { this.heroCache.fetchCachedHero(this.heroId); } get hero() { return this.heroCache.hero; } }
Типы зависимостей
Когда классу требуется зависимость, она добавляется в конструктор. Когда Angular должен создать экземпляр класса, он обращается к DI за зависимостью. По умолчанию DI ищет провайдера в иерархии инжекторов, начиная с локального инжектора компонента и, при необходимости, поднимается по дереву инжекторов до корневого инжектора.
- Первый инжектор, настроенный с провайдером, предоставляет зависимость (экземпляр службы или значение) конструктору.
- Если провайдер не найден в корневом инжекторе, фреймворк DI генерирует ошибку.
Существует ряд опций для изменения стандартного поведения поиска, используя декораторы параметров на параметрах конструктора класса, которые являются службами.
Опциональная зависимость с использованием декоратора @Optional
and limit search with @Host
Зависимости можно зарегистрировать на любом уровне иерархии компонентов. Когда компонент запрашивает зависимость, Angular начинает поиск с инжектора этого компонента и проходит по дереву инжекторов вверх, пока не найдет первый подходящий провайдер. Angular генерирует ошибку, если не может найти зависимость во время этого процесса.
В некоторых случаях вам может потребоваться ограничить поиск или учитывать отсутствие зависимости. Вы можете изменить поведение поиска Angular с помощью декораторов @Host
и @Optional
для инжектируемого сервиса.
- Декоратор
@Optional
указывает Angular вернутьnull
, когда он не может найти зависимость. - Декоратор
@Host
останавливает поиск вверх на уровне хост-компонента. Хост-компонент - компонент который зависимость запросил. Однако, когда этот компонент проецируется в родительский компонент, родительский компонент становится хостом. Следующий пример охватывает этот второй случай.
Эти декораторы могут использоваться как по отдельности, так и вместе, как показано в примере. HeroBiosAndContactsComponent
это версия HeroBiosComponent
, который был приведен ранее.
// src/app/hero-bios.component.ts (HeroBiosAndContactsComponent) @Component({ standalone: true, selector: 'app-hero-bios-and-contacts', template: ` <app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio> <app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio> <app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`, providers: [HeroService], imports: [HeroBioComponent, HeroContactComponent] }) export class HeroBiosAndContactsComponent { constructor(logger: LoggerService) { logger.logInfo('Creating HeroBiosAndContactsComponent'); } }
// src/app/hero-bios.component.ts template: ` <app-hero-bio [heroId]="1"> <app-hero-contact></app-hero-contact> </app-hero-bio> <app-hero-bio [heroId]="2"> <app-hero-contact></app-hero-contact> </app-hero-bio> <app-hero-bio [heroId]="3"> <app-hero-contact></app-hero-contact> </app-hero-bio>`,
Теперь между тегами <hero-bio>
появился новый элемент <hero-contact>
// src/app/hero-bio.component.ts (template) template: ` <h4>{{hero.name}}</h4> <ng-content></ng-content> <textarea cols="25" [(ngModel)]="hero.description"></textarea>`,
Вот HeroContactComponent
, который демонстрирует декораторы.
// src/app/hero-contact.component.ts @Component({ standalone: true, selector: 'app-hero-contact', template: ` <div>Phone #: {{phoneNumber}} <span *ngIf="hasLogger">!!!</span></div>`, imports: [NgIf] }) export class HeroContactComponent { hasLogger = false; constructor( @Host() // limit to the host component's instance of the HeroCacheService private heroCache: HeroCacheService, @Host() // limit search for logger; hides the application-wide logger @Optional() // ok if the logger doesn't exist private loggerService?: LoggerService ) { if (loggerService) { this.hasLogger = true; loggerService.logInfo('HeroContactComponent can log!'); } } get phoneNumber() { return this.heroCache.hero.phone; } }
Декоратор @Host()
для свойства конструктора heroCache
гарантирует получение ссылки на службу кэширования от родительского компонента HeroBioComponent
. Angular выдает ошибку, если у родительского компонента отсутствует эта служба, даже если она присутствует в компоненте, расположенном выше по дереву.
Второй декоратор @Host()
применяется к свойству конструктора loggerService
. Единственный экземпляр LoggerService
в приложении зарегистрирован на уровне AppComponent
. У хоста HeroBioComponent
нет собственного провайдера gLoggerService
.
Angular вернет ошибку, если свойство не было также отмечено декоратором @Optional()
. Когда свойство отмечено как @Optional()
, Angular устанавливает loggerService
в null, и остальная часть компонента адаптируется.
Если закомментировать декоратор @Host()
, Angular просмотрит все дерево инжектора, пока не найдет логгер на уровне AppComponent
. Затем логика логгера запустится с сервисом объявленным на уровне AppComponent.
Если восстановить декоратор @Host()
и закомментировать @Optional
, приложение вернёт исключение, что не может найти требуемый логгер на уровне компонента-хоста.
EXCEPTION: No provider for LoggerService! (HeroContactComponent -> LoggerService)
Объявление своего провайдера с использованием @Inject
Использование cвоего провайдера позволяет предоставить конкретную реализацию для неявных зависимостей, таких как встроенные API браузера. В следующем примере используется InjectionToken
для предоставления API браузера localStorage
в качестве зависимости в BrowserStorageService
.
import { Inject, Injectable, InjectionToken } from '@angular/core'; export const BROWSER_STORAGE = new InjectionToken<Storage>('Browser Storage', { providedIn: 'root', factory: () => localStorage }); @Injectable({ providedIn: 'root' }) export class BrowserStorageService { constructor(@Inject(BROWSER_STORAGE) public storage: Storage) {} get(key: string) { return this.storage.getItem(key); } set(key: string, value: string) { this.storage.setItem(key, value); } remove(key: string) { this.storage.removeItem(key); } clear() { this.storage.clear(); } }
Функция-фабрика возвращает свойство localStorage
, которое использует DOM. Декоратор @Inject
используется как параметр конструктора чтобы указать инжектору на зависимость. Во время тестирования приложения провайдер может быть переопределен при помощи мок-реализации API localStorage вместо взаимодействия с реальными API браузера.
Переопределение провайдера с использованием @Self и @SkipSelf
Провайдеры также могут быть ограничены областью видимости инжектора при помощи декораторов в конструкторе. В следующем примере токен BROWSER_STORAGE
заменяется на sessionStorage
браузера. Та же служба BrowserStorageService
внедряется дважды в конструкторе с декораторами @Self
и @SkipSelf
, чтобы определить, какой инжектор обрабатывает зависимость провайдера.
// src/app/storage.component.ts import { Component, OnInit, Self, SkipSelf } from '@angular/core'; import { BROWSER_STORAGE, BrowserStorageService } from './storage.service'; @Component({ standalone: true, selector: 'app-storage', template: ` Open the inspector to see the local/session storage keys: <h3>Session Storage</h3> <button type="button" (click)="setSession()">Set Session Storage</button> <h3>Local Storage</h3> <button type="button" (click)="setLocal()">Set Local Storage</button> `, providers: [ BrowserStorageService, { provide: BROWSER_STORAGE, useFactory: () => sessionStorage } ] }) export class StorageComponent { constructor( @Self() private sessionStorageService: BrowserStorageService, @SkipSelf() private localStorageService: BrowserStorageService, ) { } setSession() { this.sessionStorageService.set('hero', 'Dr Nice - Session'); } setLocal() { this.localStorageService.set('hero', 'Dr Nice - Local'); } }
Декоратор @Self
указывает инжектору искать провайдеры только в инжекторе компонента. Декоратор @SkipSelf
позволяет пропустить локальный инжектор и искать провайдер в по дереву инжекторов, пока он не будет найден. Экземпляр sessionStorageService
использует SessionStorage API
, а экземпляр localStorageService
обращается к корневому BrowserStorageService
, который работает с LocalStorage API
Подключение DOM-элемента
Даже если разработчики стараются избегать работы с DOM, многие визуальные эффекты и сторонние инструменты, такие как jQuery, требуют доступа к нему. Поэтому может возникнуть необходимость получить доступ к DOM компонента.
Примером может служить директива HighlightDirective
.
// src/app/highlight.directive.ts import { Directive, ElementRef, HostListener, Input } from '@angular/core'; @Directive({ standalone: true, selector: '[appHighlight]' }) export class HighlightDirective { @Input('appHighlight') highlightColor = ''; private el: HTMLElement; constructor(el: ElementRef) { this.el = el.nativeElement; } @HostListener('mouseenter') onMouseEnter() { this.highlight(this.highlightColor || 'cyan'); } @HostListener('mouseleave') onMouseLeave() { this.highlight(''); } private highlight(color: string) { this.el.style.backgroundColor = color; } }
Директива устанавливает фоновый цвет в цвет выделения, когда пользователь наводит курсор мыши на элемент DOM, к которому применяется директива.
Angular устанавливает параметр el
конструктора во внедренный ElementRef
. (ElementRef
- это оболочка вокруг элемента DOM, чей свойство nativeElement
предоставляет доступ к элементу DOM, который директива может изменять.)
В примере кода атрибут директивы appHighlight
применяется к двум тегам <div>, сначала без значения (что приводит к использованию цвета по умолчанию), а затем с присвоенным значением цвета.
<div id="highlight" class="di-component" appHighlight> <h3>Hero Bios and Contacts</h3> <div appHighlight="yellow"> <app-hero-bios-and-contacts></app-hero-bios-and-contacts> </div> </div>
Иногда зависимость нельзя создать стандартным способом через класс. Приведенный ниже пример HeroOfTheMonthComponent
демонстрирует множество альтернатив и объясняет, зачем они нужны. Визуально это просто: несколько свойств и логов, созданных логгером.
Код из примера демонстрирует, как и где фреймворк DI объявляет зависимости. Варианты использования иллюстрируют различные способы использования объекта provide
для ассоциации объекта определения с токеном DI.
// hero-of-the-month.component.ts import { Component, Inject } from '@angular/core'; import { DateLoggerService } from './date-logger.service'; import { Hero } from './hero'; import { HeroService } from './hero.service'; import { LoggerService } from './logger.service'; import { MinimalLogger } from './minimal-logger.service'; import { RUNNERS_UP, runnersUpFactory } from './runners-up'; import { NgFor } from '@angular/common'; @Component({ standalone: true, selector: 'app-hero-of-the-month', templateUrl: './hero-of-the-month.component.html', providers: [ { provide: Hero, useValue: someHero }, { provide: TITLE, useValue: 'Hero of the Month' }, { provide: HeroService, useClass: HeroService }, { provide: LoggerService, useClass: DateLoggerService }, { provide: MinimalLogger, useExisting: LoggerService }, { provide: RUNNERS_UP, useFactory: runnersUpFactory(2), deps: [Hero, HeroService] } ], imports: [NgFor] }) export class HeroOfTheMonthComponent { logs: string[] = []; constructor( logger: MinimalLogger, public heroOfTheMonth: Hero, @Inject(RUNNERS_UP) public runnersUp: string, @Inject(TITLE) public title: string) { this.logs = logger.logs; logger.logInfo('starting up'); } }
Массив providers
показывает, как можно использовать различные определения провайдера: useValue
, useClass
, useExisting
или useFactory
. Теперь о каждом из них поподробнее:
useValue позволяет связать фиксированное значение с токеном DI. Используется для констант, таких как базовые адреса веб-сайтов и флаги функций. Вы также можете использовать провайдер в модульном тесте для передачи макетных данных вместо сервиса с реальными. В примере есть 2 варианта использования:
{ provide: Hero, useValue: someHero }, { provide: TITLE, useValue: 'Hero of the Month' },
Первый объявляет существующий экземпляр класса Hero
для токена Hero
, вместо создания нового экземпляра инжектором. Токен здесь сам класс.
Второй указывает значение строки для токена TITLE
. Этот токен не класс, а специальный ключ поиска, называемый токеном инъекции, представленным InjectionToken
.
- Вы можете использовать токен для любого типа провайдера, это особенно удобно, когда зависимость представляет собой простое значение, например, строку, число или функцию. Значение для провайдера должно быть определено до его использования. Например, строковый литерал заголовка сразу доступен для использования. Вы не можете использовать переменную, значение которой будет определено позже.
const someHero = new Hero(42, 'Magma', 'Had a great month!', '555-555-5555');
Другие типы провайдеров могут создавать свои значения лениво, то есть в момент их необходимости для инъекции.
useClass
позволяет создавать и возвращать новый экземпляр указанного класса.
Можно использовать для указания альтернативной реализации класса. Например, альтернативная реализация может реализовывать другую стратегию, расширять стандартный класс или эмулировать поведение реального класса в тестовом случае. Далее показаны два примера в HeroOfTheMonthComponent
.
// src/app/hero-of-the-month.component.ts { provide: HeroService, useClass: HeroService }, { provide: LoggerService, useClass: DateLoggerService },
Первый провайдер - наиболее распространённый случай, когда класс, который нужно создать (HeroService
), также является DI.
Второй провайдер заменяет DateLoggerService
на LoggerService
. LoggerService
уже зарегистрирован на уровне AppComponent
. Когда дочерний компонент запрашивает LoggerService
, он вместо этого получает экземпляр DateLoggerService
.
Компонент и его потомки получают экземплярDateLoggerService
. Компоненты за пределами этого дерева продолжают получать оригинальный экземплярLoggerService
.
DateLoggerService
наследуется от LoggerService
; он добавляет текущую дату/время к каждому сообщению:
// src/app/date-logger.service.ts @Injectable({ providedIn: 'root' }) export class DateLoggerService extends LoggerService { override logInfo(msg: any) { super.logInfo(stamp(msg)); } override logDebug(msg: any) { super.logInfo(stamp(msg)); } override logError(msg: any) { super.logError(stamp(msg)); } } function stamp(msg: any) { return msg + ' at ' + new Date(); }
useExisting
позволяет заменить один сервис на другой. По сути, первый является только псевдонимом для сервиса, связанного с вторым токеном, создавая два способа доступа к одному и тому же сервису.
// src/app/hero-of-the-month.component.ts { provide: MinimalLogger, useExisting: LoggerService }
Вы можете использовать этот метод, чтобы использовать сокращенное API через альтернативный интерфейс. Вот пример создания альтернативного интерфейса для этой цели.
Предположим, что у LoggerService
есть большое API, намного больше, чем три метода и свойство. Вам может потребоваться сократить этот набор API только до необходимых членов. В данном примере класс-интерфейс MinimalLogger
сокращает API до двух членов:
// src/app/minimal-logger.service.ts // Class used as a "narrowing" interface that exposes a minimal logger // Other members of the actual implementation are invisible export abstract class MinimalLogger { abstract logs: string[]; abstract logInfo: (msg: string) => void; }
В следующем примере приведено использование MinimalLogger
в упрощенной версии HeroOfTheMonthComponent
.
// src/app/hero-of-the-month.component.ts (minimal version) @Component({ standalone: true, selector: 'app-hero-of-the-month', templateUrl: './hero-of-the-month.component.html', // TODO: move this aliasing, `useExisting` provider to the AppModule providers: [{ provide: MinimalLogger, useExisting: LoggerService }], imports: [NgFor] }) export class HeroOfTheMonthComponent { logs: string[] = []; constructor(logger: MinimalLogger) { logger.logInfo('starting up'); } }
В конструкторе HeroOfTheMonthComponent
параметр logger
типизирован как MinimalLogger
, поэтому только методы logs
и logInfo
видны в редакторе TypeScript
.
useFactory
позволяет создать провайдера, вызывая фабричную функцию.
// src/app/hero-of-the-month.component.ts { provide: RUNNERS_UP, useFactory: runnersUpFactory(2), deps: [Hero, HeroService] }
DI объявляется с использованием функции-фабрики. Обратите внимание, что в этом случае необходимо так же указать третий ключ deps
, который указывает зависимости для функции useFactory
.
В результате получается зависимость, которая обычно является экземпляром класса. В нашем случае представляет собой строку с именами финалистов конкурса "Герой месяца". Локальное состояние представляет собой число 2 - количество финалистов, которые должен показать компонент. Это значение передается в аргументах функции runnersUpFactory
(). Эта функция возвращает функцию-фабрику провайдера, которая может использовать как переданное значение состояния, так и внедренные сервисы Hero
и HeroService
.
// runners-up.ts (excerpt) export function runnersUpFactory(take: number) { return (winner: Hero, heroService: HeroService): string => /* ... */ }
Фабричная функция провайдера (возвращаемая функцией runnersUpFactory()
) создает фактический объект зависимости - строку с именами.
Функция принимает на вход победителя Hero
и HeroService
в качестве аргументов. Angular предоставляет эти аргументы из внедренных значений, идентифицированных двумя токенами в массиве deps.
Функция возвращает строку с именами, которую затем Angular внедряет в параметр runnersUp
компонента HeroOfTheMonthComponent
.
Пример для этого материала можно скачать по ссылке