Хабрахабр

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

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

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

class ComponentWithEventHandler // Handling event ... }
}

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

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

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

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

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

Он имеет набор так называемых плагинов, расширяющих абстрактный EventManagerPlugin и делегирует обработку подписки на событие в тот плагин, который поддерживает данное событие (по имени). Для обработки событий Angular использует класс EventManager. Это внутренняя реализация Angular, и данный подход может измениться. Внутри Angular предусмотрены несколько плагинов, среди которых обработка HammerJS событий и плагин, отвечающий за составные события, вроде keydown.enter. Однако, с момента создания 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

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

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

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

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

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть