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

Краткий повтор
Для тех, кто не читал и не хочет читать прошлую статью, краткое изложение проблемы:
- Angular позволяет декларативно подписываться на события в шаблоне (
(eventName)) и через декораторы (@HostListener(‘eventName’)). - При стратегии проверки изменений
OnPushAngular запустит проверку, если произошло событие, на которое мы таким образом подписались. - События вроде
scroll,mousemove,dragсрабатывают очень часто. На практике реагировать нужно только на некоторые из них (например, когда пользователь прокрутил контейнер до конца — загружаем новые элементы). - Обработкой событий в Angular занимается
EventManagerс помощью предоставленных емуEventManagerPluginов. - Если мы научим 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, ); } }
Задачу пропуска событий решить несколько сложнее. По сути, она состоит из трех составляющих:
- Вывод обработчика из зоны видимости Angular, чтобы проверка не запускалась.
- Отмена вызова обработчика при невыполнении условия.
- Вызов обработчика и запуск проверки изменений при выполнении условия.
С первым пунктом отлично справится плагин, так как у него есть доступ к 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.
У вас тоже есть что-то, что вы мечтали выложить в open source, но вас отпугивают сопутствующие хлопоты? Попробуйте Angular Open-source Library Starter, который мы сделали для своих проектов. В нем уже настроен CI, проверки при коммитах, линтеры, генерация CHANGELOG, покрытие тестами и все в таком духе.
ссылка на оригинал статьи https://habr.com/ru/company/tinkoff/blog/492766/
Добавить комментарий