Introduction
In an earlier piece on EventManager and EventManagerPlugin in Angular, I demonstrated how to cut down on unnecessary change detection cycles for high-frequency events. The technique kept the familiar event subscription syntax intact, but the implementation was somewhat heavy and difficult to follow. Now it's time to streamline that approach with a decorator—something I hinted at as a future improvement in that earlier text.
Quick Review
If you skipped the previous article and prefer not to dig through it, here's what you need to know:
- Angular lets you subscribe to events declaratively using
(eventName)or@HostListener(‘eventName’) - With
ChangeDetectionStrategy.OnPush(which is recommended), change detection only runs for events subscribed to in this manner - Events like
scroll,mousemove, anddragfire extremely often. Practically speaking, you frequently only want to respond when a certain condition holds—for example, when the user has scrolled to the bottom of a container and we need to fetch additional items - Behind the scenes, Angular processes events through
EventManagerand its registeredEventManagerPlugins - Performance can be improved by teaching Angular to disregard events that don't matter in a given situation
The earlier article presented a working method for filtering events, which also allowed stopping propagation or preventing default behavior. Now we'll turn that into a polished, drop-in solution. You don't need to review the previous code samples.
Plugins
To customize how events are processed, we'll leverage modifiers—the same mechanism Angular uses for pseudo-events like keydown.alt.enter.
Let's recall how [EventManager](https://angular.io/api/platform-browser/EventManager) operates. When instantiated, it receives all provided plugins via dependency injection. A few are built-in, such as the one that handles pseudo-events. You can also register your own through the EVENT_MANAGER_PLUGINS multi-provider token. When a subscription is created, EventManager checks each plugin to see if it supports the event name. The first match gets its addEventListener method invoked, receiving the event name, the target element, and the handler. It returns a cleanup function that removes the listener.
There's also an
addGlobalEventListenermethod, but we'll leave that out for simplicity. In our scenario, it behaves identically.
Let's begin with preventDefault and stopPropagation. These two plugins will look nearly identical—each wraps the handler, removes the modifier from the event name, and then hands everything back to EventManager for normal processing:
@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,
);
}
}
Ignoring certain events demands a bit more work. It breaks down into three steps:
- Running the handler outside Angular's zone so change detection remains untouched
- Preventing the handler from executing when the condition doesn't pass
- Executing the handler when the condition passes, and then triggering change detection
The first step is simple for a plugin because it has access to NgZone—we can call the 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,
),
);
}
}
The remaining two steps will be handled by a decorator that gates method invocations.
Decorator
We'll create a factory that accepts a predicate function. That predicate runs in the context of the component or directive instance, so it has access to the component's this. But sometimes you need to inspect the event itself before deciding whether to respond. The easiest approach is to call the predicate with the same arguments the decorated method would receive. You just pass $event to the handler in the template or via @HostListener. Here's the factory:
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 prevents needless invocations. However, if the predicate returns true and the handler runs, change detection must be triggered. In the future, with Angular 10 and stable Ivy, we'll call markDirty(this). Until then, we need another route to reach NgZone. Let's create a temporary workaround. Plugins have access to NgZone, so we'll build a plugin that passes the zone to the handler and have our decorator 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 deliver NgZone to the handler as soon as the zone is stable. We'll connect our decorator with @HostListener(‘init.prop’, [‘event’]) and stash the zone reference:
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);
});
}
},
});
};
};
}
Admittedly, this is a bit of a hack. The good news is it's temporary and functional. We just need to wait for full Ivy adoption.
Using It
Here's the demo from the previous article, converted to this new approach:
Be aware that with AOT compilation, all decorator arguments must be exported. Arrow functions inside decorators will cause build errors. As a concrete example, imagine a component that renders a list and fetches more items once the user scrolls near the bottom. The template uses an async pipe over an Observable of items. The component simulates server requests, holds a subscription, and applies the 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 all there is to it. You can test it live here:
Interactive demo
Watch the browser console—a message logs on every change detection cycle. This code also works with CustomEvents that you create and dispatch programmatically, using the same syntax.
This implementation is packaged as a compact (~1 KB gzipped) open-source library called @tinkoff/ng-event-plugins. It's also on npm. Once Angular 10 ships, we'll release v2.0 with markDirty(this), while the current version supports Angular 4 and newer.
Thinking about open-sourcing your own project but dreading the setup chores? Take a look at this Angular Open-source Library Starter we put together. It handles continuous integration, pre-commit checks, linting, versioning, changelog generation, code coverage, and more.
