Прокачиваем работу с событиями в Angular
Давным-давно я написал статью о работе с EventManager в Angular. В ней я рассказал, как можно сохранить привычный нам синтаксис подписок на события, при этом избежав лишних запусков проверки изменений на частых и чувствительных событиях.
Однако описанный мною метод громоздкий и сложный для восприятия. Пришло время переписать фильтрацию на декораторы.
Краткий повтор
Для тех, кто не читал и не хочет читать прошлую статью, краткое изложение проблемы:
- Angular позволяет декларативно подписываться на события в шаблоне (
(eventName)
) и через декораторы (@HostListener(‘eventName’)
). - При стратегии проверки изменений
OnPush
Angular запустит проверку, если произошло событие, на которое мы таким образом подписались. - События вроде
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(
predicate: Predicate
): 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(
predicate: Predicate
): 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
из элементов:
{{item}}
В коде компонента добавим сервис, имитирующий запросы на сервер за новыми элементами и подписку на событие скролла с фильтрацией:
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, покрытие тестами и все в таком духе.