Давным-давно я написал статью о работе с EventManager в Angular. В ней я рассказал, как можно сохранить привычный нам синтаксис подписок на события, при этом избежав лишних запусков проверки изменений на частых и чувствительных событиях.


Однако описанный мною метод громоздкий и сложный для восприятия. Пришло время переписать фильтрацию на декораторы.



Краткий повтор


Для тех, кто не читал и не хочет читать прошлую статью, краткое изложение проблемы:


  1. Angular позволяет декларативно подписываться на события в шаблоне ((eventName)) и через декораторы (@HostListener(‘eventName’)).
  2. При стратегии проверки изменений OnPush Angular запустит проверку, если произошло событие, на которое мы таким образом подписались.
  3. События вроде scroll, mousemove, drag срабатывают очень часто. На практике реагировать нужно только на некоторые из них (например, когда пользователь прокрутил контейнер до конца — загружаем новые элементы).
  4. Обработкой событий в Angular занимается EventManager с помощью предоставленных ему EventManagerPluginов.
  5. Если мы научим Angular игнорировать ненужные нам события, то избежим лишних проверок изменений.

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


Плагины


Для настройки обработки будем использовать модификаторы имени события аналогично встроенным в Angular псевдособытиям нажатия клавиш (keydown.ctrl.enter и тому подобные).


Вспомним, как работает EventManager. В момент создания он собирает имеющиеся плагины. В Angular заложено несколько стандартных плагинов, а добавить свои можно благодаря внедрению зависимостей через EVENT_MANAGER_PLUGINS мультитокен. При подписке на событие он находит подходящий плагин, спрашивая все по очереди, поддерживают ли они событие с таким именем. Затем он вызывает метод addEventListener подходящего плагина, передавая в него имя события, элемент, на котором мы его слушаем, и обработчик, который нужно вызвать. Назад плагин возвращает метод для удаления подписки.


Начнем с preventDefault и stopPropagation. Создадим пару плагинов, которые, получив на вход имя события и обработчик, выполнят свою задачу и передадут обработку дальше:


@Injectable()
export class StopEventPlugin {
  supports(event: string): boolean {
    return event.split('.').includes('stop');
  }

  addEventListener(
    element: HTMLElement, 
    event: string, 
    handler: Function
  ): Function {
    const wrapped = (event: Event) => {
      event.stopPropagation();
      handler(event);
    };

    return this.manager.addEventListener(
      element,
      event
        .split('.')
        .filter(v => v !== 'stop')
        .join('.'),
      wrapped,
    );
  }
}

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


  1. Вывод обработчика из зоны видимости Angular, чтобы проверка не запускалась.
  2. Отмена вызова обработчика при невыполнении условия.
  3. Вызов обработчика и запуск проверки изменений при выполнении условия.

С первым пунктом отлично справится плагин, так как у него есть доступ к NgZone и запустить обработчик вне зоны очень просто:


@Injectable()
export class SilentEventPlugin {
  supports(event: string): boolean {
    return event.split('.').includes('silent');
  }

  addEventListener(
    element: HTMLElement,
    event: string,
    handler: Function
  ): Function {
    return this.manager.getZone().runOutsideAngular(() =>
      this.manager.addEventListener(
        element,
        event
          .split('.')
          .filter(v => v !== 'silent')
          .join('.'),
        handler,
      ),
    );
  }
}

Для второго и третьего пунктов используем декоратор, фильтрующий вызов метода.


Декоратор


Создадим фабрику, которая будет получать на вход функцию-фильтр. Мы сможем выполнять ее в контексте инстанса нашего компонента/директивы, так что у нас будет доступ к this.


Однако часто нужно изучить само событие, чтобы понять, нужно ли на него реагировать. Самый простой способ получить доступ к нему для нас — вызывать функцию-фильтр с теми же аргументами, что и метод, на который мы вешаем декоратор. Тогда останется только передавать $event в обработчик события в шаблоне или @HostListener. Код декоратора с использованием фильтра будет выглядеть следующим образом:


export function shouldCall<T>(
  predicate: Predicate<T>
): MethodDecorator {
  return (_target, _key, desc: PropertyDescriptor) => {
    const {value} = desc;

    desc.value = function(this: T, ...args: any[]) {
      if (predicate.apply(this, args)) {
        value.apply(this, args);
      }
    };
  };
}

Так мы избежим лишних вызовов. Но если фильтр даст зеленый свет и обработчик выполнится — нужно как-то сообщить Angular, что необходимо запустить проверку изменений.


Когда выйдет Angular 10 и Ivy стабилизируется и станет доступным для библиотек, будет достаточно вызывать markDirty(this). Но пока этого не случилось, нам нужно как-то добраться до NgZone. Для этого запилим временный хак. Как мы помним, доступ к зоне есть у плагинов. Напишем специальный плагин, который пришлет NgZone к нам, а декоратор ее перехватит:


@Injectable()
export class ZoneEventPlugin {
  supports(event: string): boolean {
    return event.split('.').includes('init');
  }

  addEventListener(
    _element: HTMLElement,
    _event: string, 
    handler: Function
  ): Function {
    const zone = this.manager.getZone();
    const subscription = zone.onStable.subscribe(() => {
      subscription.unsubscribe();
      handler(zone);
    });

    return () => {};
  }
}

Единственная задача этого плагина — слушать подписку на событие с модификатором .init и передавать в обработчик зону, как только она стабилизируется (иными словами, когда компонент соберется). Наш декоратор будет использоваться вместе с @HostListener(‘prop.init’, [‘$event’]) и будет ловить зону:


export function shouldCall<T>(
  predicate: Predicate<T>
): MethodDecorator {
  return (_, key, desc: PropertyDescriptor) => {
    const {value} = desc;

    desc.value = function() {
      const zone = arguments[0] as NgZone;

      Object.defineProperty(this, key, {
        value(this: T, ...args: any[]) {
          if (predicate.apply(this, args)) {
            zone.run(() => {
              value.apply(this, args);
            });
          }
        },
      });
    };
  };
}

Конечно, это хак. Но можно утешиться тем, что это временное решение и оно работает. Остается дождаться дивного нового мира Ivy.



Использование


Демо из прошлой статьи, переработанное на новый подход, можно изучить тут:
https://stackblitz.com/edit/angular-event-filter-decorator


Помните, что для АОТ компиляции функции, которые передаются в фабрики декораторов, выносятся в отдельные экспортируемые сущности. В качестве простейшего примера сделаем компонент, который показывает список и подгружает новые элементы, когда он полностью прокручен вниз. В шаблоне будет async пайп на Observable из элементов:


<p *ngFor="let item of service.items$ | async">{{item}}</p>

В коде компонента добавим сервис, имитирующий запросы на сервер за новыми элементами и подписку на событие скролла с фильтрацией:


export function scrolledToBottom(
   {scrollTop, scrollHeight, clientHeight}: HTMLElement
): boolean {
  return scrollTop >= scrollHeight - clientHeight - 20;
}

@Component({
  selector: 'awesome-component',
  templateUrl: './awesome-component.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AwesomeComponent {
  constructor(@Inject(Service) readonly service: Service) {}

  @HostListener('scroll.silent', ['$event.currentTarget'])
  @HostListener('init.onScroll', ['$event'])
  @shouldCall(scrolledToBottom)
  onScroll() {
    this.service.loadMore();
  }
}

Вот и все. Посмотреть работу в действии можно тут:
https://stackblitz.com/edit/angular-event-filters-scroll


Обратите внимание на консоль, в которой выводится сообщение на каждый цикл проверки изменений. Весь этот код будет работать и с произвольными CustomEventами, которые создаются и диспатчатся руками. Синтаксис при этом никак не изменится.


Описанное решение вынесено в крошечную (1 КБ gzip) open-source-библиотеку под названием @tinkoff/ng-event-filters. К релизу Angular 10 выпустим версию 2.0.0, в которой перейдем на markDirty(this), а текущий код работает даже с Angular 4.


Исходный код


npm-пакет


У вас тоже есть что-то, что вы мечтали выложить в open source, но вас отпугивают сопутствующие хлопоты? Попробуйте Angular Open-source Library Starter, который мы сделали для своих проектов. В нем уже настроен CI, проверки при коммитах, линтеры, генерация CHANGELOG, покрытие тестами и все в таком духе.