📜 ⬆️ ⬇️

Optimization of event handling in Angular

Introduction


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.


EventManagerPlugin



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:


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


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)); } }; 

Decision


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); } // ... } 


Practical application


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); 

Conclusion


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