Optimization of event handling in Angular

    Introduction


    Angular provides a convenient declarative way to subscribe to events in a template, using syntax (eventName)="onEventName($event)". Together with the change verification policy, ChangeDetectionStrategy.OnPushthis approach automatically launches the change verification cycle only for the user input that interests us. In other words, if we listen to an (input)event on an <input>item, then the change check will not run if the user simply clicks on the input field. This greatly improves
    performance compared to 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:


    classComponentWithEventHandler{
      // ...
      onEvent(event: Event) {
        if (!this.condition) {
          return;
        }
        // Handling event ...
      }
    }

    Even if the condition has not been fulfilled and no actions have actually taken place, the change check cycle will still be started. In the case of frequent events, such as scrollor mousemove, this may adversely affect the performance of the application.


    In the component UI library I’m working on, a subscription to the mousemoveinside of the drop-down menus caused a recalculation 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 remedy the situation by making a subscription to events bypassing ngZone, for example, using Observable.fromEventand launching a change check with your hands, causing changeDetectorRef.markForCheck(). However, it 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 we are interested in. We can write (keydown.enter)="onEnter($event)"and the handler (and with it the change check loop) will be called only when the key Enteris 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 .preventand .stop, which will cancel the default behavior and stop the ascent of the event automatically.


    EventManagerPlugin



    For event handling Angular uses a class EventManager. It has a set of so-called plug-ins that extend the abstract EventManagerPluginand delegates the processing of an event subscription to the plugin that supports the event (by name). Inside Angular there are several plug-ins, among which is the processing of HammerJS events 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 on the processing of this solution, 3 years have passed, and no progress in this direction has taken place:


    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 plugins 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 EventManagerPlugin, you will notice that we cannot inherit from it, for the most part it is abstract and it is not difficult to implement our 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 (on body, windowand document). We will be interested in modifiers .filter, .preventand .stop. To bind them to our plugin, we implement the required method supports:


    const FILTER = '.filter';
    const PREVENT = '.prevent';
    const STOP = '.stop';
    classFilteredEventPlugin {
      supports(event: string): boolean {
        return (
          event.includes(FILTER) || event.includes(PREVENT) || event.includes(STOP)
        );
      }
    }

    So it EventManagerwill 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 EventManagerto it so that it selects the correct embedded plugin for processing:


    classFilteredEventPlugin {
      supports(event: string): boolean {
        // ...
      }
      addGlobalEventListener(
        element: string,
        eventName: string,
        handler: Function,
      ): Function {
        constevent = eventName
          .replace(FILTER, '')
          .replace(PREVENT, '')
          .replace(STOP, '');
        returnthis.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 EventManager, calling it out ngZoneto avoid starting the change check loop:


    classFilteredEventPlugin {
      supports(event: string): boolean {
        // ...
      }
      addEventListener(
        element: HTMLElement,
        eventName: string,
        handler: Function,
      ): Function {
        constevent = eventName
          .replace(FILTER, '')
          .replace(PREVENT, '')
          .replace(STOP, '');
        // Обёртка над нашим обработчикомconst filtered = (event: Event) => {
          // ...
        };
        const wrapper = () =>
          this.manager.addEventListener(element, event, filtered);
        returnthis.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 original 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. An 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 trueor 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, on one element there can be several handlers for the same event, but, as a rule, these can be simultaneously specified @HostListenerand the handler installed on this component in the template to a higher level. 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:


    exporttypeFilter = (event: Event) => boolean;exporttypeFilters = {[key: string]: Filter};classFilteredEventMainService {
      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 @HostListeneradd another small service that will live with the component and clean the corresponding filters when it is removed:


    export classEventFiltersService{
      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:


    classEventFiltersDirective{
      @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, it is almost always possible to do this 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 a directive:


    classEventFiltersDirective{
      // ...constructor(
        @Optional()@Self()@Inject(FiltersService)private readonly filtersService: FiltersService | null,
      ) {}
      // ...
    }

    If this service is present, we will display a message stating that the directive does not apply to it:


    classEventFiltersDirective{
      @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 the imaginary selectcomponent inside the modal window and the 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 an event mousemovecan trigger dozens of useless change checking cycles. By setting the filter to check the focus targetof the event element as a filter , we can cut off these unnecessary alarms, leaving only those that actually carry the focus.



    Also, in this selectcomponent there is filtering on @HostListenersubscriptions. When you press a key Escinside 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. The selectpress Escis closing vypadashki and return the focus to the field itself, but if it is already closed, it should not prevent emersion events and the subsequent closing of the modal window. Thus, the processing can be described by the decorator:


    @HostListener('keydown.esc.filtered.stop')When the filter: () => this.opened.


    Since it selectis a component with several focused elements, it is possible to track its overall focus through pop-up events focusout. They will occur with all changes in focus, including those that do not leave the component boundaries. This event has a field relatedTargetresponsible for where the focus moves. After analyzing it, we can understand whether to cause an analogue of the event blurfor our component:


    classSelectComponent{
      // ...@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 run 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 the 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 handlers and the launch of verification of changes, we have the opportunity to conveniently apply the typical methods for event processing preventDefaultand stopPropagationright when announcing a subscription. From the groundwork for the future - it would be more convenient to declare filters for @HostListeners right next to them with the help of decorators. In the next article, I plan to talk about a few decorators that we have created at home, and I will try to implement this solution.


    Also popular now: