Недавно Бхарат Рави опубликовал статью о директиве самосохраняющегося select-элемента на InDepth. Это интересная концепция изолирования логики в директиве, что в целом идея хорошая.

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

Директивы работают со своим элементом

Когда вы задумываетесь о создании директивы, обратите внимание на работу с шаблоном. Если для функционирования нужно несколько элементов, то для этой цели лучше подойдет компонент.

«Ваши директивы не вольны модифицировать DOM за рамками своего элемента»

Вот фрагмент кода из оригинальной статьи:

handleErrorCase(element) {
  this.removeBackground(element);
  const child = this.document.createElement('img');
  child.src = ERROR_ICON;
  const parent = this.renderer.parentNode(this.elRef.nativeElement);
  this.renderer.appendChild(parent, child);
  setTimeout(() => {
    this.renderer.removeChild(parent, child);
  }, 1000);
}

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

Мы также не знаем о стилях родителя, возможно, еще один ребенок сломает верстку. И, наконец, мы даже не знаем, существует ли родитель. Если ваша директива лежит в контенте другого компонента, есть шанс, что она отрендерится до прикрепления к DOM. Поэтому золотое правило гласит: в директивах оставайтесь в рамках их элемента.

Избегайте ручной работы с DOM

Мне понятно желание минимизировать вложенность. Вместо прикрепления иконок как картинок мы могли бы менять фон элемента через CSS и помещать иконки в нужное место. Это полезно для UX, так как картинки не блокируют клики. Но ручная работа с DOM — плохая затея по ряду причин:

  1. Это угроза кросс-платформенности.

  2. Это потенциально небезопасно, так как мы минуем санитайзер.

  3. Оптимизация становится нашей ответственностью.

  4. Это не по-Ангуляровски.

Мы могли бы применить @HostBinding для задания стилей. Но их непросто использовать с Observable (см. мою статью по теме) и OnPush-проверкой изменений. Кроме того, все будет гораздо прозрачнее, если у нас будет шаблон со всеми нужными элементами. Так что вместо запихивания всего в один элемент давайте сделаем небольшую обертку:

<autosave-select>
  <select>...</select>
</autosave-select>

Пишите асинхронные действия декларативно

Обычно мы прибегаем к RxJS для асинхронных операций. Директива из статьи тоже начинает с RxJS, но быстро переходит к императивным манипуляциям и setTimeout. Все это можно решить, не покидая реактивный мир. Я очень рекомендую всем вкладываться в изучение RxJS. Это один из самых мощных инструментов в экосистеме Angular.

Подборка задачек для тренировки RxJS от нас с Ромой

В данном случае мы можем сделать нехитрую цепочку операторов, которая обработает все за нас. Еще мы избавимся от необходимости ручной подписки. С async-пайпом в конце мы сможем подключить OnPush и забыть про отписки.

Передача методов через инпут — сомнительная затея

Я не против чистых функций в инпутах. Мы часто используем их в Taiga UI, например методы преобразования <T> в строку или проверку на состояние disabled. Но в этом случае она выглядит странно.

Обычно инпуты используют для переменных значений. Для статичных данных я предпочитаю Dependency Injection. Директива задумана самодостаточной, но работа с сервером все равно скинута на родительский компонент, когда мы передаем метод через инпут. С помощью DI можно добавить уровень абстракции:

export abstract class SaveService<T> {
  abstract save(value: T): Observable<unknown>;
}

Теперь реально передавать реализации через сервисы или директивы на случай нескольких таких компонентов на странице.

Рефактор

Теперь давайте сделаем компонент с той же задачей. Для начала мы запустим поток при выборе. Это может быть fromEvent или Subject + @HostListener:

export class AutosaveSelectComponent<T> {
  private readonly change$ = new Subject<T>();

  @HostListener('change', ['$event.target.value'])
  onChange(value: T) {
    this.change$.next(value);
  }
}

Затем создадим Observable состояния, отвечающий за индикацию:

readonly state$ = this.change$.pipe(
  switchMap(value =>
    this.service.save(value).pipe(
      switchMapTo(
        timer(3000).pipe(
          mapTo(null),
          startWith(State.Success)
        )
      ),
      startWith(State.Loading),
      catchError(() => of(State.Error))
    )
  ),
);

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

Осталось слегка приправить CSS’ом:

<ng-content></ng-content>
<ng-container [ngSwitch]="state$ | async">
  <img
    *ngSwitchCase="state.Loading"
    class="icon icon_loading"
    alt="" 
    src="loading.png"
  />
  <img
    *ngSwitchCase="state.Success"
    class="icon"
    alt="" 
    src="success.png"
  />
  <img
    *ngSwitchCase="state.Error"
    class="icon"
    alt="" 
    src="error.png"
  />
</ng-container>

Рабочий пример с использованием нескольких сервисов смотрите на StackBlitz.

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


  1. essome
    11.10.2021 16:30

    А в чем смысл такого компонента? Не могу придумать где бы я это использовал. Но в любом случае завязывать компонент на автосохранение я бы не стал.

    Имею ввиду, что, индикаторами автосохранения должна заниматься отдельная директива, а селект не должен знать кто, когда и как его сохраняет


    1. Waterplea Автор
      11.10.2021 17:09

      Паттерн настроек без кнопки "Сохранить" встречается довольно часто. А написанный тут подход вполне можно переложить на показ нотификашек (тогда не понадобится компонент-обёртка, будет просто директива) и произвольный контрол, к примеру, чекбокс.

      Ну и в статье всё именно так, как ты написал во втором абзаце :)


      1. EuRusik
        11.10.2021 18:46

        Как я понял из кода на StackBlitz, директивы подменяют детали реализации абстрации переопределяя токен сервиса уровнем выше


        1. Waterplea Автор
          11.10.2021 18:47

          Директива - один из способов подложить что-то в DI дерево. Да, на примере StackBlitz показано, как можно раскидать в разные места разную реализацию с помощью директив.