angular
March 10

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. Теперь о каждом из них поподробнее:

1) useValue

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');

Другие типы провайдеров могут создавать свои значения лениво, то есть в момент их необходимости для инъекции.

2) useClass

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(); }

3) useExisting

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.

4) useFactory

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.

Пример для этого материала можно скачать по ссылке