Всем ангуляроводом привет!

В этой статье разберемся с новинкой из Angular 15 - API композиции директив (Directive composition API).

Прошу прощения за поздний текст, наш корпоративный митап прошел давно, но никак не хватало времени что-то написать…

Итак, пристегните ремни, мы начинаем развивать свою угловую скорость.

Какую проблему решаем?

  • Уменьшение повторяемости кода при создании похожих директив.

  • Но самая главная проблема - расширение функционала директив, которые достаются из библиотек!
    В репозитории angular возник соответствующий proposal, и он стал ооочень популярным.

Немного теории

  • Про паттерн:
    Композиция директив очень похожа на паттерн Компоновщик (Composite).
    Он входит в семейство Структурных паттернов программирования (которые отвечают за создание удобных объектов).
    Компоновщик помогает объединять несколько объектов в один, который может иметь древовидную структуру, но обращаться мы будем только к композиции по определенному контракту корня этого дерева, не задумываясь, как можно работать с каждым листом по отдельности.

  • Про standalone компоненты:
    В Angular v14 у нас появились standalone компоненты. В 14 появились standalone компоненты в preview, а в 15 они получили полноценное место под солнцем (если мы не знаем про это, то мы много упустили и должны побежать читать статьи про standalone components).
    Как мы помним, директивы от компонентов отличаются тем, что у них нет шаблона… а вот свойство standalone также есть. Когда мы начинаем говорить о API композиции директив, то следует запомнить, что директивы должны быть объявлены как standalone.

  • Про хост:
    В нашем случае это компонент/директива, на которую будем навешивать другие директивы.

Переходим к практике

Допустим, у нас есть компонент app-card и мы хотим добавить к его поведенью возможность drag and drop, а также нам нужно, чтобы все наши app-card применяли к себе нашу супер уникальную директиву, которая красит фон блока в красный…

Что мы делали до 15 версии?

Было необходимо на каждый компонент app-card добавить нужные директивы и во всех вхождениях нашей карточки приходилось писать <app-component RedBackgroundDirective CdkDrag>, конечно, мы могли создать дополнительный компонент-обертку, в котором бы располагалась карточка с директивами, но это не очень чистое решение…

Сейчас же мы можем внутри декоратора определить, какие директивы следует применить к компоненту, звучит удобно!

Кстати, давно не трогал чисты js и думал, что там декораторы уже реализованы, но proposal для них на stage 3.

Итак, давайте посмотрим на это

@Directive({
  selector: '[appRedBackground]',
  standalone: true,
  host: { '[style.background]': '"red"' }
})
export class RedBackgroundDirective {}

@Component({
  selector: 'app-card',
  template: '<ng-content/>',
  styles: [':host { display: block}'],
  hostDirectives: [
    RedBackgroundDirective,
    CdkDrag
  ]
})
export class CardComponent {}

Оказалось, что подмешивать/композировать директивы очень просто и не нужны компоненты обертки.

Здорово, но мы также можем объединять директивы внутри другой директивы.

Например, у нас есть директива для подсветки текста appHighlight

@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective implements OnChanges {
  @Input('appHighlight') searchTerm: string | null = '';
  @Input() caseSensitive = false;
  @Input() customClasses = '';
  // some code
}

А также директива для показа тултипа appTooltip

@Directive({
  selector: '[appTooltip]',
  standalone: true
})
export class TooltipDirective {
  @Input('appTooltip') text = '';
  @Output() showTooltip = new EventEmitter<boolean>();
  // some code
}

И, конечно же, мы хотим создать директиву, которая будет выделять текст и показывать тултип)

Все, что нам нужно, это создать директиву appHighlightWithTooltip и немного поработать над ее декоратором.

@Directive({
  selector: '[appHighlightWithTooltip]',
  hostDirectives: [
    { 
      directive: HighlightDirective, 
      inputs: ['customClasses', 'appHighlight: highlight']
    },
    {
      directive: TooltipDirective,
      inputs: ['appTooltip: tooltip'],
	  outputs: ['showTooltip']
    }
  ]
})
export class HighlightWithTooltipDirective {}

Таким образом, мы создали композицию из директив, но кое-что поменяли в контракте нового объекта:
- Не сможем обратиться к HighlightDirective.caseSensitive.

Так как не объявили этот Input в декораторе. (По дефолту все input и output не прокидываются наверх, здесь мы видим концепцию whiteList. Никаких omit, только трушные pick).

- к appHighlight.appHighlight будем обращаться не appHighlight, а highlight

Был задан алиас inputs: ['appHighlight: highlight']

Жизненный цикл

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

Вернемся к первому примеру

@Component({
  selector: 'app-card',
  template: '<ng-content/>',
  hostDirectives: [
    RedBackgroundDirective,
    CdkDrag
  ]
})
export class CardComponent {}

Если в директивы и компонент добавить ngOnInit и ngAfterViewInit для примера, то порядок будет следующий:

1 RedBackgroundDirective - constructor
2 CdkDrag - constructor
3 CardComponent  - constructor
4 RedBackgroundDirective - ngOnInit
5 CdkDrag - ngOnInit
6 CardComponent  - ngOnInit
7 RedBackgroundDirective - ngAfterViewInit
8 CdkDrag - ngAfterViewInit
9 CardComponent  - ngAfterViewInit

Доступ к свойствам директив

Может возникнуть вопрос, как из хоста обратиться к свойствам директивы из декоратора, например, чтобы переопределить значение по умолчанию для Input?

@Directive({
  selector: '[appHighlightWithTooltip]',
  hostDirectives: [
    { 
      directive: HighlightDirective, 
      inputs: ['appHighlight: highlight']
    }
  ]
})
export class HighlightWithTooltipDirective {
  constructor() {
    inject(HighlightDirective).caseSensitive = false;
  }
}

Хочу отметить, что видны все public свойства, неважно, были они определены в декораторе как видимые или нет.

Отписка через директиву

Так, кажется, вышла новая версия angular, но мы не занялись любимым занятием любого ангулярщика - поиском новых способов отписки…

@Directive({
  selector: '[appDestroy]',
  standalone: true
})
export class DestroyDirective {
  private _destroy$ = new Subject<boolean>();
  get destroy$(): Observable<boolean> {
    return this._destroy$.asObservable();
  }
  ngOnDestroy(): void {
    this._destroy$.next(true);
    this._destroy$.complete();
  }
}

@Component({
  selector: 'app-card',
  template: '<ng-content/>',
  hostDirectives: [DestroyDirective]
})
export class CardComponent {
  destroy$ = inject(DestroyDirective).destroy$; // takeUntil(this.destroy$) - и полетели
}

Но обязательно стоит сказать о главном минусе такого подхода - destroy$ сработает в момент вызова ngOnDestroy внутри директивы, а это произойдет раньше, чем вызов ngOnDestroy для компоненты (см. выше про жизненный цикл).

Производительность

Цепочка из директив может быть достаточно большая, также на один хост можно добавить большое количество директив.

@Directive({
  selector: '[c]',
  hostDirectives: [ADirective, BDirective]
})
export class CDirective {}

@Directive({
  selector: '[e]',
  hostDirectives: [CDirective, DDirective ]
})
export class EDirective {}

Что может привести к проблемам с производительностью, так как для EDirective ngcc создаст для каждой директивы из композиции отдельный объект.

А если EDirective будет применена к компонентам из ngFor с 100 элементов, то будет создано 500 дополнительных объектов, что повлечет съедание памяти.

На самом деле, на среднем ПК это, скорее всего, не скажется, но при больших объемах данных следует не забывать про профилировщик =)

Заключение

В v15 появился отличный способ расширять функциональность библиотечных директив, а также писать мелкие директивы, которые можно будет объединять в различные комбинации, что повысит dry и s из solid.

- Ссылка на примеры кода
- Документация angular.io

Комментарии (3)


  1. dcooder
    00.00.0000 00:00

    Делать стилизацию, добавлять dragNdrop, и прочие подобные вещи наверное все-таки лучше не через композицию, а добавлением директивы к компоненту уже в месте использования. Ибо требования бизнеса непредсказуемы, сегодня бизнес хочет только красные карточки такого дизайна и с draggable, а завтра захотел такие же, но зеленые, и без draggable, но с каким-нибудь тултипом при наведении. И делать ради этого еще один такой же компонент с композицией других директив - мягко говоря решение не очень. Либо придется выпиливать композицию из компонента, и добавлять директиву во всех местах в коде, где используется данных компонент.

    А вот пример с отпиской прям огонь. Тут композиция по мне гораздо лучше всех известных на данный момент способов отписки, ну разве что кроме DestroyService от Тинькофф. Недостаток с более ранним выполнением ngOnDestroy думаю вообще не критичный, очень маловероятно, что в OnDestroy компонента понадобиться "живой" observable.


    1. yuriy-bezrukov Автор
      00.00.0000 00:00

      Иногда пример, это только пример...

      Пока не встретился с применением hostDirectives на компоненте в своем проекте, но когда-нибудь настанет этот день)


  1. vsezol
    00.00.0000 00:00

    Еще бы DestroyDirective возвести в абсолют наследовать от Observable) Но это все шутки уже