Оптимизация обработки событий в Angular +35


Введение


Angular предоставляет удобный декларативный способ подписки на события в шаблоне, с помощью синтаксиса (eventName)="onEventName($event)". Вместе с политикой проверки изменений ChangeDetectionStrategy.OnPush подобный подход автоматически запускает цикл проверки изменений только по интересующему нас пользовательскому вводу. Иными словами, если мы слушаем (input) событие на <input> элементе, то проверка изменений не будет запускаться, если пользователь просто кликает по полю ввода. Это значительно улучшает
производительность, по сравнению с политикой по умолчанию (ChangeDetectionStrategy.Default). В директивах мы также можем подписаться на события на хост-элементе через декоратор @HostListener('eventName').


В моей практике нередко встречаются случаи, когда обработка конкретного события требуется только при выполнении какого-либо условия. т.е. обработчик выглядит примерно так:


class ComponentWithEventHandler {
  // ...

  onEvent(event: Event) {
    if (!this.condition) {
      return;
    }

    // Handling event ...
  }
}

Даже если условие не выполнилось и никакие действия по факту не произошли, цикл проверки изменений всё равно будет запущен. В случае с частыми событиями, вроде scroll или mousemove, это может негативно отразиться на производительности приложения.


В библиотеке UI компонентов, над которой я работаю, подписка на mousemove внутри выпадающих меню вызвала пересчет изменений вверх по всему дереву компонентов на каждое движение мыши. Следить за мышью было необходимо для реализации корректного поведения меню, но это явно стоило оптимизировать. Подробнее об этом ниже.


Подобные моменты особенно важны для универсальных UI элементов. Их на странице может присутствовать множество, а приложения бывают очень сложными и требовательными к производительности.


Исправить ситуацию можно, делая подписку на события в обход ngZone, например, с помощью Observable.fromEvent и запускать проверку изменений руками, вызывая changeDetectorRef.markForCheck(). Однако, это добавляет кучу лишней работы и лишает возможности использовать удобные встроенные инструменты Angular.


Ни для кого не секрет, что Angular позволяет подписываться на так называемые псевдособытия, уточняя какие именно события нас интересуют. Мы можем написать (keydown.enter)="onEnter($event)" и обработчик (а вместе с ним и цикл проверки изменений) вызовется только при нажатии клавиши Enter.Остальные нажатия будут игнорироваться. В этой статье мы разберёмся, как можно воспользоваться тем же подходом, что и Angular, для оптимизации обработки событий. А в качестве бонуса добавим модификаторы .prevent и .stop, которые будут отменять поведение по умолчанию и останавливать всплытие события автоматически.


EventManagerPlugin



Для обработки событий Angular использует класс EventManager. Он имеет набор так называемых плагинов, расширяющих абстрактный EventManagerPlugin и делегирует обработку подписки на событие в тот плагин, который поддерживает данное событие (по имени). Внутри Angular предусмотрены несколько плагинов, среди которых обработка HammerJS событий и плагин, отвечающий за составные события, вроде keydown.enter. Это внутренняя реализация Angular, и данный подход может измениться. Однако, с момента создания issue про переработку этого решения прошло уже 3 года, и никаких подвижек в этом направлении не произошло:


https://github.com/angular/angular/issues/3929


Что интересно в этом для нас? Несмотря на то, что эти классы внутренние и отнаследоваться от них нельзя, токен, отвечающий за внедрение зависимостей для плагинов — публичный. Это значит, мы можем писать свои плагины и расширять ими встроенный механизм обработки событий.


Если посмотреть исходный код EventManagerPlugin, можно заметить, что мы и не сможем от него отнаследоваться, в большинстве своём он абстрактный и реализовать свой класс, отвечающий его требованиям не составляет труда:


https://github.com/angular/angular/blob/master/packages/platform-browser/src/dom/events/event_manager.ts#L92


Грубо говоря, плагин должен уметь определять, работает ли он с данным событием и должен уметь добавлять обработчик события и глобальные обработчики (на body, window и document). Нас будут интересовать модификаторы .filter, .prevent и .stop. Для привязки их к нашему плагину реализуем обязательный метод supports:


const FILTER = '.filter';
const PREVENT = '.prevent';
const STOP = '.stop';

class FilteredEventPlugin {
  supports(event: string): boolean {
    return (
      event.includes(FILTER) || event.includes(PREVENT) || event.includes(STOP)
    );
  }
}

Так EventManager поймёт, что события, в имени которых присутствуют определённые модификаторы, нужно передать на обработку в наш плагин. Затем нам нужно реализовать добавление обработчиков на события. Глобальные обработчики нас не интересуют, в их случае необходимость в подобных инструментах встречается существенно реже, а реализация была бы сложнее. Поэтому мы просто уберём из имени события наши модификаторы и вернём его в EventManager, чтобы он подобрал правильный встроенный плагин для обработки:


class FilteredEventPlugin {
  supports(event: string): boolean {
    // ...
  }

  addGlobalEventListener(
    element: string,
    eventName: string,
    handler: Function,
  ): Function {
    const event = eventName
      .replace(FILTER, '')
      .replace(PREVENT, '')
      .replace(STOP, '');

    return this.manager.addGlobalEventListener(element, event, handler);
  }
}

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


class FilteredEventPlugin {
  supports(event: string): boolean {
    // ...
  }

  addEventListener(
    element: HTMLElement,
    eventName: string,
    handler: Function,
  ): Function {
    const event = eventName
      .replace(FILTER, '')
      .replace(PREVENT, '')
      .replace(STOP, '');

    // Обёртка над нашим обработчиком
    const filtered = (event: Event) => {
      // ...
    };

    const wrapper = () =>
      this.manager.addEventListener(element, event, filtered);

    return this.manager.getZone().runOutsideAngular(wrapper);
  }

  /*
  addGlobalEventListener(...): Function {
    ...
  }
  */
}

На данном этапе у нас есть: имя события, само событие и элемент, на котором оно слушается. Обработчик, который попадает сюда — не исходный обработчик, назначенный на это событие, а конец цепочки замыканий, созданных Angular для своих целей.


Одним решением могло бы стать добавление атрибута на элемент, отвечающего за то, вызывать обработчик или нет. Иногда для принятия решения необходимо проанализировать само событие: было ли отменено действие по умолчанию, какой элемент является источником события и т.д. Для этого недостаточно атрибута, нам нужно найти способ задать функцию-фильтр, получающую на вход событие и возвращающую true или false. Тогда мы могли бы описать наш обработчик следующим образом:


const filtered = (event: Event) => {
  const filter = getOurHandler(some_arguments);

  if (
    !eventName.includes(FILTER) ||
    !filter ||
    filter(event)
  ) {
    if (eventName.includes(PREVENT)) {
      event.preventDefault();
    }

    if (eventName.includes(STOP)) {
      event.stopPropagation();
    }

    this.manager.getZone().run(() => handler(event));
  }
};

Решение


Решением может стать синглтон сервис, хранящий соответствия элементов парам событие/фильтр и вспомогательные сущности для задания этих соответствий. Разумеется, на одном элементе может быть несколько обработчиков на одно и то же событие, но, как правило, это могут быть одновременно заданные @HostListener и обработчик, установленный на этот компонент в шаблоне на уровень выше. Эту ситуацию мы предусмотрим, другие же случаи нас мало интересуют ввиду своей специфичности.


Основной сервис довольно простой и состоит из мапы и пары методов для задания, получения и очистки фильтров:


export type Filter = (event: Event) => boolean;
export type Filters = {[key: string]: Filter};

class FilteredEventMainService {
  private elements: Map<Element, Filters> = new Map();

  register(element: Element, filters: Filters) {
    this.elements.set(element, filters);
  }

  unregister(element: Element) {
    this.elements.delete(element);
  }

  getFilter(element: Element, event: string): Filter | null {
    const map = this.elements.get(element);

    return map ? map[event] || null : null;
  }
}

Таким образом, мы можем внедрить этот сервис в плагин и получать фильтр, передавая элемент и имя события. Для использования в связке с @HostListener добавим ещё один небольшой сервис, который будет жить вместе с компонентом и очищать соответствующие фильтры при его удалении:


export class EventFiltersService {
  constructor(
    @Inject(ElementRef) private readonly elementRef: ElementRef,
    @Inject(FilteredEventMainService)
    private readonly mainService: FilteredEventMainService,
  ) {}

  ngOnDestroy() {
    this.mainService.unregister(this.elementRef.nativeElement);
  }

  register(filters: Filters) {
    this.mainService.register(this.elementRef.nativeElement, filters);
  }
}

Для добавления фильтров на элементы можно сделать аналогичную директиву:


class EventFiltersDirective {
  @Input()
  set eventFilters(filters: Filters) {
    this.mainService.register(this.elementRef.nativeElement, filters);
  }

  constructor(
    @Inject(ElementRef) private readonly elementRef: ElementRef,
    @Inject(FilteredEventMainService)
    private readonly mainService: FilteredEventMainService,
  ) {}

  ngOnDestroy() {
    this.mainService.unregister(this.elementRef.nativeElement);
  }
}

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


class EventFiltersDirective {
  // ...

  constructor(
    @Optional()
    @Self()
    @Inject(FiltersService)
    private readonly filtersService: FiltersService | null,
  ) {}

  // ...
}

Если этот сервис присутствует, будем выводить сообщение о том, что директива к нему не применима:


class EventFiltersDirective {
  @Input()
  set eventFilters(filters: Filters) {
    if (this.eventFiltersService === null) {
      console.warn(ALREADY_APPLIED_MESSAGE);

      return;
    }

    this.mainService.register(this.elementRef.nativeElement, filters);
  }

  // ...
}


Применение на практике


Весь описанный код можно найти на Stackblitz:


https://stackblitz.com/edit/angular-event-filter


В качестве примеров использования там показаны мнимый select — компонент внутри модального окна — и контекстное меню в роли его выпадашки. В случае с контекстным меню, если вы проверите любую реализацию, то увидите, что поведение всегда следующее: при наведении мыши на пункт, он фокусируется, при дальнейшем нажатии стрелочек на клавиатуре, фокус перемещается по пунктам, но если подвигать мышью, фокус возвращается в элемент, находящийся под указателем мыши. Казалось бы, это поведение несложно реализовать, однако, лишние реакции на событие mousemove могут запускать десятки бесполезных циклов проверки изменений. Установив в качестве фильтра проверку на сфокусированность target элемента события, мы можем отсечь эти лишние срабатывания, оставив только те, что реально переносят фокус.



Также, в этом select-компоненте есть фильтрация на @HostListener подписках. При нажатии клавиши Esc внутри попапа, он должен закрываться. Это должно происходить, только если это нажатие не было необходимо в каком-то вложенном компоненте и не было обработано в нём. В select нажатие Esc вызывает закрытие выпадашки и возврат фокуса в само поле, но если он уже закрыт, он не должен препятствовать всплытию события и последующему закрытию модального окна. Таким образом, обработку можно описать декоратором:


@HostListener('keydown.esc.filtered.stop'), при фильтре: () => this.opened.


Поскольку select является компонентом с несколькими фокусируемыми элементами, отслеживание его общей сфокусированности возможно через всплывающие события focusout. Они будут происходить при всех изменениях фокуса, в том числе, не покидающих границы компонента. У этого события есть поле relatedTarget, отвечающее за то, куда перемещается фокус. Проанализировав его, мы можем понять, вызывать ли нам аналог события blur для нашего компонента:


class SelectComponent {
  // ...

  @HostListener('focusout.filtered')
  onBlur() {
    this.opened = false;
  }

  // ...
}

Фильтр, при этом, выглядит так:


const focusOutFilter = ({relatedTarget}: FocusEvent) =>
  !this.elementRef.nativeElement.contains(relatedTarget);

Вывод


К сожалению, встроенная обработка составных нажатий клавиш в Angular всё равно запустится в NgZone, а значит вызовет проверку изменений. При желании мы могли бы не прибегать к встроенной обработке, но выигрыш в производительности будет невелик, а углубления во внутреннюю “кухню” Angular чреваты поломками при обновлении. Поэтому мы либо должны отказаться от составного события, либо использовать фильтр аналогично граничному оператору и просто не вызывать обработчик там, где он не актуален.


Вклинивание во внутреннюю обработку событий Angular — затея авантюрная, так как внутренняя реализация может измениться в будущем. Это обязывает нас следить за обновлениями, в частности, за задачей на GitHub, приведённой во второй секции статьи. Зато теперь мы можем удобно фильтровать выполнение обработчиков и запуск проверки изменений, у нас появилась возможность удобно применять типичные для обработки событий методы preventDefault и stopPropagation прямо при объявлении подписки. Из заделов на будущее — было бы удобнее объявлять фильтры для @HostListener-ов прямо рядом с ними с помощью декораторов. В следующей статье я планирую рассказать о нескольких декораторах, которые мы у себя создали, и попробую реализовать это решение.




К сожалению, не доступен сервер mySQL