Angular provides a convenient declarative way to subscribe to events in a template, using the syntax (eventName)="onEventName($event)"
. Together with the change check policy ChangeDetectionStrategy.OnPush
this approach automatically launches the change check cycle only for user input that interests us. In other words, if we listen to (input)
event on a <input>
element, then the change check will not run if the user simply clicks on the input field. It greatly improves
performance, compared with the default policy ( ChangeDetectionStrategy.Default
). In directives, we can also subscribe to events on the host element through the decorator @HostListener('eventName')
.
In my practice, there are often cases when handling a specific event is required only when a condition is met. those. the handler looks like this:
class ComponentWithEventHandler { // ... onEvent(event: Event) { if (!this.condition) { return; } // Handling event ... } }
Even if the condition is not fulfilled and no actions have actually taken place, the change check cycle will still be started. In the case of frequent events like scroll
or mousemove
, this can negatively affect the performance of the application.
In the component UI library I'm working on, a mousemove
subscription within the drop-down menus caused a recount of changes up the entire component tree for each mouse movement. Watching the mouse was necessary to implement the correct menu behavior, but it was clearly worth optimizing. More on this below.
Such moments are especially important for universal UI elements. There may be many of them on the page, and applications can be very complex and demanding of performance.
You can ngZone
situation by subscribing to events bypassing ngZone
, for example, using Observable.fromEvent
and start checking for changes with your hands by calling changeDetectorRef.markForCheck()
. However, this adds a lot of extra work and makes it impossible to use the convenient built-in tools of Angular.
It's no secret that Angular allows you to subscribe to the so-called pseudo-events, specifying which events interest us. We can write (keydown.enter)="onEnter($event)"
and the handler (and the change check loop with it) will be called only when the Enter
key is pressed. Other clicks will be ignored. In this article we will figure out how you can use the same approach as Angular to optimize event handling. And as a bonus, add the modifiers .prevent
and .stop
, which will cancel the default behavior and stop the ascent of the event automatically.
For event handling, Angular uses the EventManager
class. It has a set of so-called plug-ins that extend the abstract EventManagerPlugin
and delegates the processing of an event subscription to the plugin that supports this event (by name). There are several plug-ins inside Angular, including HammerJS event handling and a plugin responsible for composite events, like keydown.enter
. This is an internal implementation of Angular, and this approach may change. However, since the creation of the issue about the processing of this solution, 3 years have passed, and no progress has been made in this direction:
https://github.com/angular/iss/issues/3929
What is interesting in this for us? Despite the fact that these classes are internal and cannot be inherited from them, the token responsible for introducing dependencies for plug-ins is public. This means we can write our own plugins and extend their built-in event handling mechanism.
If you look at the source code of the EventManagerPlugin
, you will notice that we cannot inherit from it, for the most part it is abstract and it’s easy to implement your own class that meets its requirements:
Roughly speaking, a plugin should be able to determine whether it works with this event and should be able to add an event handler and global handlers (to body
, window
and document
). We will be interested in modifiers .filter
, .prevent
and .stop
. To bind them to our plugin, we implement the required supports
method:
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) ); } }
So EventManager
will understand that events in the name of which there are certain modifiers need to be transferred to our plugin for processing. Then we need to implement adding handlers to events. Global handlers are not interested in us, in their case the need for such tools is much less common, and the implementation would be more difficult. Therefore, we simply remove our modifiers from the event name and return it to the EventManager
so that it EventManager
correct embedded plugin for processing:
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); } }
In the case of an event on a regular element, we need to write our logic. To do this, we wrap the handler in the closure and pass the event without our modifiers back to the EventManager
, calling it outside of ngZone
to avoid starting the change check loop:
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 { ... } */ }
At this stage we have: the name of the event, the event itself and the element on which it is being listened. The handler that gets here is not the source handler assigned to this event, but the end of the chain of closures created by Angular for its own purposes.
One solution would be to add an attribute to an element that is responsible for calling the handler or not. Sometimes, to make a decision, it is necessary to analyze the event itself: whether the default action was canceled, which element is the source of the event, etc. The attribute is not enough for this, we need to find a way to set a filter function that receives an event as input and returns true
or false
. Then we could describe our handler as follows:
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)); } };
The solution can be a singleton service that stores the correspondence of elements to the event / filter pairs and auxiliary entities for specifying these correspondences. Of course, there can be several handlers on the same event on the same event, but, as a rule, these can be simultaneously specified @HostListener
and a handler installed on this component in the template one level higher. We will envisage this situation, while others are of little interest to us because of their specificity.
The main service is quite simple and consists of a map and a couple of methods for defining, obtaining and cleaning filters:
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; } }
Thus, we can embed this service in the plugin and receive a filter by passing the element and the name of the event. For use in conjunction with @HostListener
we @HostListener
add another small service that will live with the component and clear the corresponding filters when it is removed:
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); } }
To add filters to elements, you can make a similar directive:
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); } }
If there is a service for filtering events inside a component, we will not allow filters to be hung on it through a directive. In the end, this is almost always possible to do by simply wrapping the component with the element to which our directive will be assigned. To understand that this element already has a service, we will optionally implement it in the directive:
class EventFiltersDirective { // ... constructor( @Optional() @Self() @Inject(FiltersService) private readonly filtersService: FiltersService | null, ) {} // ... }
If this service is present, we will display a message that the directive does not apply to it:
class EventFiltersDirective { @Input() set eventFilters(filters: Filters) { if (this.eventFiltersService === null) { console.warn(ALREADY_APPLIED_MESSAGE); return; } this.mainService.register(this.elementRef.nativeElement, filters); } // ... }
All the code described can be found on Stackblitz:
https://stackblitz.com/edit/angular-event-filter
As examples of use, it shows an imaginary select
- component inside a modal window - and a context menu in the role of its dropouts. In the case of the context menu, if you check any implementation, you will see that the behavior is always the following: when you hover the mouse over the item, it focuses, when you further press the arrows on the keyboard, the focus moves through the items, but if you move the mouse, the focus returns to the element under the mouse pointer. It would seem that this behavior is easy to implement, however, unnecessary reactions to the mousemove
event can trigger dozens of useless change check cycles. By setting the filter to check the focus of the target
element of the event as a filter, we can cut off these unnecessary operations, leaving only those that actually carry the focus.
Also, this select
component has filtering on @HostListener
subscriptions. When you press the Esc
inside the popup, it should close. This should occur only if this click was not necessary in any nested component and was not processed in it. In select
pressing Esc
causes the dropout to close and return the focus to the field itself, but if it is already closed, it should not prevent the event from ascending and then closing the modal window. Thus, the processing can be described by the decorator:
@HostListener('keydown.esc.filtered.stop')
, with a filter: () => this.opened
.
Since select
is a component with several focused elements, it is possible to track its overall focus through focusout
events. They will occur with all changes in focus, including those that do not leave the component boundaries. This event has a relatedTarget
field that is responsible for where the focus moves. After analyzing it, we can understand whether to cause an analogue of the blur
event for our component:
class SelectComponent { // ... @HostListener('focusout.filtered') onBlur() { this.opened = false; } // ... }
The filter, thus, looks like this:
const focusOutFilter = ({relatedTarget}: FocusEvent) => !this.elementRef.nativeElement.contains(relatedTarget);
Unfortunately, the built-in processing of composite keystrokes in Angular will still start in NgZone
, which means it will check for changes. If you wish, we could not resort to the built-in processing, but the performance gain will be small, and recesses in the internal “kitchen” of Angular are fraught with breakdowns when updating. Therefore, we must either abandon the composite event, or use a filter similar to the boundary operator and simply do not call the handler where it is not relevant.
Angular internal event handling is an adventurous undertaking, as the internal implementation may change in the future. This obliges us to keep track of updates, in particular, the task on GitHub given in the second section of the article. But now we can conveniently filter the execution of the handlers and the start of the verification of changes, we have the opportunity to conveniently use the preventDefault
and stopPropagation
methods typical for event processing right at the announcement of the subscription. From the groundwork for the future - it would be more convenient to declare filters for @HostListener
s right next to them with the help of decorators. In the next article, I plan to talk about several decorators that we have created at home, and try to implement this solution.
Source: https://habr.com/ru/post/429692/
All Articles