angular
March 11

Применение HostAttributeToken в Angular 17.3

Angular имеет декоратор @Attribute, облегчающий внедрение атрибутов из узла-хоста. Этот подход особенно удобен, когда нет необходимости устанавливать привязку.

Однако для функции inject был предложен не полный аналог. В Angular v17.3.0 появился новый класс HostAttributeToken, позволяющий выполнять внедрение атрибутов аналогично @Attribute. Вот как это можно сделать:

import { Component, HostAttributeToken } from '@angular/core';

@Component({
  selector: 'app-foo',
  standalone: true
})
export class FooComponent {
  variation = inject(new HostAttributeToken('variation'));
}


Этот новый интерфейс работает аналогично @Attribute, с одним важным отличием: в случае отсутствия атрибута он генерирует ошибку DI, вместо того чтобы возвращать null, как делает @Attribute. Это изменение приближает его поведение к другим инъекционным токенам.

<app-foo variation="primary" />

Разработчику следует отметить, что значения атрибутов должны быть статическими; динамические привязки или интерполяции здесь работать не будут.

Для необязательных атрибутов со значением по умолчанию мы можем передать опцию optional:

import { Component, HostAttributeToken } from '@angular/core';

@Component({
  selector: 'app-foo',
  standalone: true
})
export class FooComponent {
  variation: Variation = inject(
    new HostAttributeToken('variation'), { optional: true }
  ) || 'primary';
}

Этот шаблон обеспечивает гибкость, обеспечивая резервные значения в случае отсутствия атрибутов.

Для дальнейшего упрощения обработки атрибутов рассмотрите использование следующего API:

import {
  assertInInjectionContext,
  inject,
  HostAttributeToken,
} from '@angular/core';

export function hostAttr<R>(key: string, defaultValue: R): R {
  assertInInjectionContext(hostAttr);

  return (
    (inject(new HostAttributeToken(key), { optional: true }) as R) ??
    defaultValue
  );
}

hostAttr.required = function <R>(key: string): R {
  assertInInjectionContext(hostAttr);
  return inject(new HostAttributeToken(key)) as R;
};


Теперь использование атрибутов в ваших компонентах становится проще и соответствует функциям ввода и модели:

@Component({
  selector: 'app-foo',
  standalone: true
})
export class FooComponent {
  variation = hostAttr<Variation>('variation', 'primary');
  variation = hostAttr.required<Variation>('variation');
}

Мы также можем пойти дальше и добавить опцию передачи парсера:

@Component({
  selector: 'app-foo',
  standalone: true
})
export class FooComponent {
  page = hostAttr.required('page', toNumber);
}


Используя HostAttributeToken, вы делаете свой код совместимым с элементами, такими как ng-container и ng-template, обеспечивая более безопасное использование в сценариях серверного рендеринга (SSR).

Использование класса вместе с InjectionToken

В Angular DI проще всего, когда провайдер является классом, который также является сервисом. Однако токен не обязательно должен быть классом, и даже когда он является классом, он не обязан быть того же типа, что и возвращаемый объект.

// src/app/hero-of-the-month.component.ts
{ provide: MinimalLogger, useExisting: LoggerService },

MinimalLogger - абстрактный класс

// 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;
}

Абстрактный класс часто служит базой для расширения, но в данном случае он используется исключительно как DI токен, без наследников в приложении. Такой подход, называемый "интерфейсом класса", позволяет использовать абстрактный класс для строгой типизации и в то же время как токен для DI, несмотря на то что TypeScript интерфейсы недоступны во время выполнения. Это способствует снижению связности между классом и его потребителями, ограничивая интерфейс только необходимыми для вызова членами.

Использование класса в качестве интерфейса позволяет получить особенности интерфейса в реальном объекте JavaScript. Однако, чтобы минимизировать затраты памяти, у класса не должно быть реализации. Так, класс MinimalLogger преобразуется в JavaScript для функции конструктора в неоптимизированном, предварительно не минифицированном виде.
var MinimalLogger = (function () {
  function MinimalLogger() {}
  return MinimalLogger;
}());
exports("MinimalLogger", MinimalLogger);
Класс не содержит никаких членов и не увеличивается в размерах, независимо от количества добавленных членов, при условии, что эти члены объявлены, но не имеют реализации.

InjectionToken

Объекты InjectionToken предназначены для объявления зависимостей, которые могут быть простыми значениями (такими как даты, числа, строки) или неструктурированными объектами (например, массивами или функциями). Эти объекты не имеют интерфейсов и поэтому плохо описываются классами. Вместо этого они лучше представлены токеном, который одновременно уникален и символичен — объектом JavaScript с понятным названием, который не будет конфликтовать с другим токеном, имеющим то же имя.

{ provide: TITLE,         useValue:   'Hero of the Month' },
{ provide: RUNNERS_UP,    useFactory:  runnersUpFactory(2), deps: [Hero, HeroService] }

Этот подход использовался в примере "Герой месяца", где InjectionToken применялся как для провайдера TITLE, так и для провайдера-фабрики RUNNERS-UP. Создание токена выглядит следующим образом:

import { InjectionToken } from '@angular/core';

export const TITLE = new InjectionToken<string>('title');

Необязательный параметр типа передает тип зависимости разработчику и IDE.

Подключение в наследуемый класс

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

В этом выдуманном примере SortedHeroesComponent наследует от HeroesBaseComponent для отображения отсортированного списка героев.

Компонент HeroesBaseComponent может работать автономно (standalone). Он требует собственный экземпляр HeroService для получения списка героев и отображает их в порядке, в котором они поступают из базы данных.

@Component({
  standalone: true,
  selector: 'app-unsorted-heroes',
  template: '<div *ngFor="let hero of heroes">{{hero.name}}</div>',
  providers: [HeroService],
  imports: [NgFor]
})
export class HeroesBaseComponent implements OnInit {
  constructor(private heroService: HeroService) { }

  heroes: Hero[] = [];

  ngOnInit() {
    this.heroes = this.heroService.getAllHeroes();
    this.afterGetHeroes();
  }

  // Post-process heroes in derived class override.
  protected afterGetHeroes() {}

}

Конструкторы должны быть проще


Конструкторы должны выполнять лишь инициализацию переменных. Это правило обеспечивает безопасное создание компонента при тестировании без опасений, что он совершит что-то неожиданное, например, отправит запрос на сервер. Поэтому вызов HeroService происходит в методе ngOnInit, а не в конструкторе.

Пользователи хотят видеть список в алфавитном порядке. Вместо изменения исходного компонента, создайте подкласс и создайте компонент SortedHeroesComponent, который сортирует героев перед их выводом. SortedHeroesComponent позволяет базовому классу получать героев.

К сожалению, Angular не может инжектировать HeroService непосредственно в базовый класс. Вам нужно объявитьHeroService снова, а затем передать его базовому классу внутри конструктора.

@Component({
  standalone: true,
  selector: 'app-sorted-heroes',
  template: '<div *ngFor="let hero of heroes">{{hero.name}}</div>',
  providers: [HeroService],
  imports: [NgFor]
})
export class SortedHeroesComponent extends HeroesBaseComponent {
  constructor(heroService: HeroService) {
    super(heroService);
  }

  protected override afterGetHeroes() {
    this.heroes = this.heroes.sort((h1, h2) => h1.name < h2.name ? -1 :
            (h1.name > h2.name ? 1 : 0));
  }
}

Обратите внимание на метод afterGetHeroes(). Возможно, первой мыслью для вас было бы создать метод ngOnInit в SortedHeroesComponent и выполнять сортировку там. Но Angular вызывает ngOnInit производного класса перед вызовом ngOnInit базового класса, поэтому вы бы сортировали массив героев до их получения. Это приведет к ошибке.

Переопределение метода afterGetHeroes() базового класса решает проблему.

Подобные сложности говорят о необходимости избегать наследования классов компонентов.

Устранение цикличной зависимости при помощи forwardRef()

Порядок объявления классов имеет значение в TypeScript. Вы не можете обращаться непосредственно к классу, пока он не будет объявлен.

Это обычно не проблема, особенно если придерживаться рекомендуемого правила о том, чтобы каждый класс находился в отдельном файле. Однако иногда круговые ссылки неизбежны. Например, когда класс 'A' ссылается на класс 'B', а 'B' ссылается на 'A'. Один из них должен быть определен первым.

Функция forwardRef() в Angular создает непрямую ссылку, которую Angular может разрешить позже.

К примеру: Parent Finder полон круговых ссылок классов, которые невозможно разорвать. Вы сталкиваетесь с этой дилеммой, когда класс делает ссылку на самого себя, как, например, AlexComponent в своем массиве провайдеров. Массив провайдеров является свойством функции декоратора @Component(), которая должна находиться выше определения класса. forwardRef поможет в разрешении этой проблемы.

providers: [{ provide: Parent, useExisting: forwardRef(() => AlexComponent) }],

Это вольный перевод материала