A while back I wrote an article about EventManager and EventManagerPlugin in Angular. I explained how we skip unnecessary change detection runs for performance sensitive events. That approach also preserved the familiar event subscription syntax. The solution we explored was rather bulky and hard to wrap your head around. It is time to simplify it with decorator usage — an approach I mentioned at the end of the article as a possible improvement.

Recap#

If you missed previous article and would rather not get back to it, here are the prerequisites:

  • Angular allows declarative subscription to events ( (eventName) or @HostListener(‘eventName’))
  • If you use ChangeDetectionStrategy.OnPush (which you should) — Angular runs change detection only on events we subscribed to this way
  • Events such as scroll, mousemove, drag are very frequent. In practice, you often only need to react to them when particular condition is true. Say, user have scrolled container all the way to the bottom and we need to load more items
  • Angular works with events using EventManager and provided EventManagerPlugins
  • We can boost performance by teaching Angular to ignore events we know wouldn’t matter

I’ve given a working method to filter out events. It also allows us to stop event bubbling or cancel default action. In this article we would push it to a finalized solution which you can take and use without extra hassle. Knowledge of previous article code is not required.

Plugins#

To customize event processing we would use modifiers. Angular uses this approach for pseudo events such as keydown.alt.enter.

Let’s refresh our knowledge on EventManager. Upon creation it injects all provided plugins. Angular has some built-in ones like the one for pseudo events mentioned above. You can also supply your own using Dependency Injection via EVENT_MANAGER_PLUGINS multi token. When you subscribe to an event EventManager looks for a plugin that supports it by event name. Then it calls addEventListener method of selected plugin. Plugin passes event name, element that we want to listen an even on and an event handler as arguments. Then it returns back a method to remove subscription.

There is also addGlobalEventListener method but we will skip it for brevity. In our case it would work exactly the same.

Let’s start with preventDefault and stopPropagation. We will create two very similar plugins. They would wrap event handler and strip modifier from event name. Then everything is passed back to EventManager to actually handle:

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

Skipping events is a bit more complex. It consists of 3 parts:

  1. Running the event handler outside Angular so it won’t trigger change detection
  2. Cancel handler execution if given condition is not met
  3. Run handler if given condition is met and trigger change detection

First task is easily handled by a plugin since it has access to NgZone and you can run handler outside of it:

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

To handle last two tasks we will create a decorator filtering method calls.

Decorator#

We will create a factory that takes a predicate as an argument. We will be able to run this predicate in the context of our component/directive instance. That means it would have access to this. However sometimes you need to analyse the event itself to know whether you need to react to it or not. Easiest way for us to get a hold of the event is to call predicate with the same arguments as the decorated method. Then all we would need to do is to pass $event to our handler inside template or in a @HostListener. Here’s the factory code:

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

This way we avoid unnecessary calls. But if predicate green-lit the event and the handler is executed — we need to run change detection. When Angular 10 arrives and Ivy stabilizes we would call markDirty(this). But until that happened we need some way to reach NgZone. Let’s write a temporary hack. As stated before, plugins have access to NgZone. We will create a special plugin that would pass zone to the handler and teach our decorator to intercept it:

@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 () => {};
  }
}

The sole purpose of this plugin is to pass NgZone to the handler as soon as zone stabilizes. We will couple our decorator with @HostListener(‘init.prop’, [‘event’]) and store zone:

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

It’s a hack all right. But we can find solace in the fact that it’s temporary and it actually works. All we need to do now is wait for a brave new Ivy world to come.

Usage#

Demo from the previous article, rewritten to use the new approach is here:

Keep in mind that for AOT all decorators arguments must be exported. Arrow functions will trigger build error. As a simple example let’s make a component that shows a list. It would load new elements when user scrolls it close to bottom. Template will be an async pipe over an Observable of items. Component will have a server request imitation, a subscription and filtering:

export function scrolledToBottom(
   {scrollTop, scrollHeight, clientHeight}: HTMLElement
): boolean {
  return scrollTop >= scrollHeight - clientHeight - 20;
}

@Component({
  selector: 'awesome-component',
  template: `<p *ngFor="let i of service.items$ | async">{{i}}</p>`,
  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();
  }
}

That’s it. You can try it in action here:

Interactive demo

Keep an eye on the console where a message appears on each change detection cycle. All of this code would also work with CustomEvents that you create and dispatch programmatically. Syntax will remain the same.

This solution is released as a tiny (1 KB gzip) open-source library named @tinkoff/ng-event-plugins. You can also find it on npm. When Angular 10 is out we will update code with markDirty(this) as v2.0 while current code works with Angular 4+.

Do you also want to open-source something, but hate the collateral work? Check out this Angular Open-source Library Starter we’ve created for our projects. It got you covered on continuous integration, pre-commit checks, linting, versioning + changelog, code coverage and all that jazz.