Skip to main content
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Mike Collins and Elishia Dvorak
Ben Nadel at CFUNITED 2010 (Landsdown, VA) with: Mike Collins ( @collinsmikej ) Elishia Dvorak ( @elishtweet )

Creating A DOM Events Plug-In That Configures Host Bindings Outside Of The Angular Zone In Angular 7.1.4

By on

So, this morning, I created an Angular Directive that synthesized a "hesitate" event based on a collection of native DOM (Document Object Model) events. And, since I didn't need to trigger a change-detection digest for the underlying DOM event-handlers, I bound them outside of the core Angular Zone (NgZone). This worked; but, it required a lot more boilerplate code when compared with the template-based event-binding that Angular provides out of the box. As such, I wondered if I could get the best of both worlds by creating a DOM Events Plug-in that configured the host bindings outside of the core NgZone in Angular 7.1.4.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

As a quick recap, Angular templates provide a wonderful syntax for managing event-bindings that automatically integrates with the Rendering engine's change-detection system. So, for example, if you have a template click-binding like this:

<a (click)="handleClick()">Perform Action</a>

... Angular will implicitly bind the event-handler when the component is mounted, unbind the event-handler when the component is destroyed, and trigger a local change-detection digest when the user clicks the target. It's super convenient and makes it painless to bind a template to a Directive.

I always want that pain-free experience. And, in 99% of cases, I want that integrated change-detection check. However, in rare cases, such as in my aforementioned blog post, I want to have more explicit control over when and if the the change-detection digest is triggered.

Luckily, Angular manages all DOM-events through a plug-in system. This means, we can create a custom event syntax that will be consumable by an event plug-in that binds event-handlers outside of the core NgZone.

To differentiate my events from the already-supported events, I am going to suffix the event-names with ".noChangeDetection". So, for example, my custom events plug-in will "support" events with the following names:

  • (click.noChangeDetection)
  • (mouseenter.noChangeDetection)
  • (mouseleave.noChangeDetection)
  • (mousedown.noChangeDetection)
  • (focus.noChangeDetection)
  • (blur.noChangeDetection)

As you can see, each of these events is such a standard DOM event-type with the suffix ".noChangeDetection" tacked onto the end.

Now, to get this to work, I have to register my custom DOM-events plug-in during the application bootstrapping. In my App Module, you can see that I am providing the class, DomEventsNoChangeDetectionPlugin, as part of the multi-collection, EVENT_MANAGER_PLUGINS:

// Import the core angular services.
import { BrowserModule } from "@angular/platform-browser";
import { EVENT_MANAGER_PLUGINS } from "@angular/platform-browser";
import { NgModule } from "@angular/core";

// Import the application components and services.
import { AppComponent } from "./app.component";
import { DomEventsNoChangeDetectionPlugin } from "./dom-no-change-detection.plugin";
import { HesitateDirective } from "./hesitate.directive";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@NgModule({
	imports: [
		BrowserModule
	],
	declarations: [
		AppComponent,
		HesitateDirective
	],
	providers: [
		{
			provide: EVENT_MANAGER_PLUGINS,
			useClass: DomEventsNoChangeDetectionPlugin,
			multi: true
		}
	],
	bootstrap: [
		AppComponent
	]
})
export class AppModule {
	// ...
}

When Angular comes across a template-based event-binding, it will iterate over this collection backwards in order to find a plug-in that supports the given event-name. This "backwards" iteration is important because it gives our plug-in a higher precedence than the plug-ins provided by Angular itself.

Each DOM-events plug-in provides three methods:

  • addEventListener( ... )
  • addGlobalEventListener( ... )
  • supports( eventName )

This .supports() method is the hook we can use in order to intercept the ".noChangeDetection" event-bindings before Angular checks its own core plug-ins. And, when we do intercept the binding, we just have to make sure that we configure the event-handler outside of the Angular Zone.

Here's my attempt at this DOM-events plug-in. Both the addEventListener() and
addGlobalEventListener() methods turn around and call a private method, setupEventBinding(). It's this method that manages the Zone:

// Import the core angular services.
import { Component } from "@angular/core";
import { EventManager } from "@angular/platform-browser";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

type EventTarget = Window | Document | HTMLElement;

// I provide support for basic DOM event bindings that are triggered outside of the
// Angular Zone. This is to facilitate workflows (like event synthesis) that would
// benefit from Angular's template-based event binding but don't need to trigger
// unnecessary change-detection digests.
export class DomEventsNoChangeDetectionPlugin {

	// The manager will get injected by the EventPluginManager at runtime.
	// --
	// NOTE: Using Definite Assignment Assertion to get around initialization.
	public manager!: EventManager;

	// ---
	// PUBLIC METHODS.
	// ---

	// I bind the given event handler to the given element. Returns a function that
	// tears-down the event binding.
	public addEventListener(
		element: HTMLElement,
		higherOrderEventName: string,
		handler: Function
		) : Function {

		var eventName = this.parseHigherOrderEventName( higherOrderEventName );

		return( this.setupEventBinding( element, eventName, handler ) );

	}


	// I bind the given event handler to the given global element selector. Returns a
	// function that tears-down the event binding.
	public addGlobalEventListener(
		higherOrderElement: string,
		higherOrderEventName: string,
		handler: Function
		) : Function {

		var target = this.parseHigherOrderElement( higherOrderElement );
		var eventName = this.parseHigherOrderEventName( higherOrderEventName );

		return( this.setupEventBinding( target, eventName, handler ) );

	}


	// I determine if the given event name is supported by this plug-in. For each event
	// binding, the plug-ins are tested in the reverse order of the EVENT_MANAGER_PLUGINS
	// multi-collection. Angular will use the first plug-in that supports the event.
	public supports( eventName: string ) : boolean {

		return( eventName.endsWith( ".noChangeDetection" ) );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I parse the "higher order" element selector into an actual browser DOM reference.
	private parseHigherOrderElement( selector: string ) : EventTarget {

		switch( selector ) {
			case "window":
				return( window );
			break;
			case "document":
				return( document );
			break;
			case "body":
				return( document.body );
			break;
			default:
				throw( new Error( `Element selector [${ selector }] not supported.` ) );
			break;
		}

	}


	// I parse the "higher order" event name into the event name that is recognizable
	// by the browser. For example, parses "click.noChangeDetection" into "click".
	private parseHigherOrderEventName( eventName: string ) : string {

		return( eventName.split( "." ).shift() || "" );

	}


	// I bind the given event handler to the given event target. I can be used for both
	// local and global targets. Returns a function that tears-down the event binding.
	private setupEventBinding(
		target: EventTarget,
		eventName: string,
		handler: Function
		) : Function {

		// In order to bypass the change-detection system, we're going to bind the DOM
		// event handler outside of the Angular Zone. The calling context can always
		// choose to re-enter the Angular zone if it needs to (such as when synthesizing
		// an event).
		this.manager.getZone().runOutsideAngular( addProxyFunction );

		return( removeProxyFunction );

		// -- Hoisted Functions -- //

		function addProxyFunction() {

			target.addEventListener( eventName, proxyFunction, false );

		}

		function removeProxyFunction() {

			target.removeEventListener( eventName, proxyFunction, false );

		}

		function proxyFunction( event: Event ) {

			handler( event );

		}

	}

}

As you can see, the setupEventBinding() calls .addEventListener() from within the context of the .runOutsideAngular() Zone method. The ensures that the underlying DOM-event bindings don't trigger change-detection.

Now, in our Angular Components and Directives, we can consume these events quite easily. In my App Component, I have two buttons that bind to the (click) event - one that uses change-detection and one that doesn't. I then implement an ngDoChanges() life-cycle event-handler on the App Component so we can see when a change-detection digest is triggered:

// Import the core angular services.
import { Component } from "@angular/core";
import { DoCheck } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Component({
	selector: "my-app",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<div class="buttons">

			<button (click)="logClick( $event )">
				Click
			</button>

			<button (click.noChangeDetection)="logClick( $event )">
				Click (no Change-Detection)
			</button>

		</div>

		<p (hesitate)="logHesitation()">
			Do you want to click me?
		</p>
	`
})
export class AppComponent implements DoCheck {

	// I log the DOM-click event.
	public logClick( event: MouseEvent ) : void {

		console.log( "Button was clicked", event );

	}


	// I log the synthesized hesitation event.
	public logHesitation() : void {

		console.log( "User hesitated to act!" );

	}


	// I get called whenever a change-detection digest has been triggered.
	public ngDoCheck() : void {

		console.log( "ngDoCheck() - Change-detection triggered." );

	}

}

If we load this Angular application in the browser and click each button a few times, we get the following output:

Creating a custom dom-events plug-in that bings events without trigger change-detection in Angular 7.1.4.

As you can see, the event-binding created with the "(click)" syntax triggered a change-detection digest after each click. And, the event-binding created with the "(click.noChangeDetection)" syntax triggered no change-detection.

For most developers, this approach may never be of interest. However, as you recall, I was creating an Angular Directive that synthesized a custom event based on a series of native DOM-events. And, I wanted to do so in a way that I didn't trigger a bunch of intermediary change-detection digests along the way. As such, let's revisit the HesitateDirective from my earlier post, this time, rewritten to use the ".noChangeDetection" event suffix - notice the host-bindings in the Directive meta-data:

// Import the core angular services.
import { Directive } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { NgZone } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

@Directive({
	selector: "[hesitate]",
	inputs: [ "duration" ],
	outputs: [ "hesitateEvents: hesitate" ],
	// NOTE: We are going to synthesize a "hesitate" event by building on top of core
	// DOM events. However, since we know that no external state will change based on
	// these DOM events, we can bind them outside of the Angular Zone.
	host: {
		"(mouseenter.noChangeDetection)": "handleMouseenter()",
		"(mousedown.noChangeDetection)": "handleMousedown()",
		"(mouseleave.noChangeDetection)": "handleMouseleave()"
	},
	// Let's export this Directive instance so that the calling context can explicitly
	// cancel a pending hesitation during a more intricate set of user interactions.
	exportAs: "hesitation"
})
export class HesitateDirective implements OnDestroy {

	public duration: number;
	public hesitateEvents: EventEmitter<void>;

	private timer: any; // TypeScript gets confused if we try to type this.
	private zone: NgZone;

	// I initialize the hesitate directive.
	constructor( zone: NgZone ) {

		this.zone = zone;

		this.duration = 2000;
		this.hesitateEvents = new EventEmitter();
		this.timer = 0;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I cancel any pending hesitation timer.
	// --
	// NOTE: This is method is PUBLIC so that it may be consumed as part of the EXPORTED
	// API in the View of the calling context.
	public cancel() : void {

		clearTimeout( this.timer );

	}


	// I get called once when the host element is being unmounted.
	public ngOnDestroy() : void {

		this.cancel();

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I handle the mousedown event inside the host element.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleMousedown() : void {

		// If the user shows any mouse-activity (other than enter/leave) inside the host
		// element, we want to cancel the hesitation timer. Such mouse activity indicates
		// non-hesitation intent on behalf of the user.
		this.cancel();

	};


	// I handle the mouseevent event inside the host element.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleMouseenter() : void {

		// When the user enters the host, start the hesitation timer. This timer will be
		// fulfilled if the user remains inside of the host without performing any other
		// meaningful actions.
		this.timer = setTimeout( this.handleTimerThreshold, this.duration );

	};


	// I handle the mouseleave event inside the host element.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleMouseleave() : void {

		this.cancel();

	};


	// I handle the timer threshold event.
	// --
	// CAUTION: Currently OUTSIDE the core NgZone.
	private handleTimerThreshold = () : void => {

		// Once the hesitation timer threshold has been surpassed, we want to trigger an
		// output event. However, since all the underlying events were bound with the
		// .noChangeDetection modifier, we are currently executing outside of the Angular
		// Zone. As such, we have to step up into the Angular Zone before we call emit().
		this.zone.run(
			() => {

				this.hesitateEvents.emit();

			}
		);

	}

}

As you can see, I am binding the "mouseenter", "mouseleave", and "click" events on the Host element using the ".noChangeDetection" suffix. This will cause my event-handlers to be bound outside of the core Angular Zone. Then, when I want to emit a "hesitate" event - which will need change detection - I step up into the Angular Zone before I call .emit() on my public EventHandler.

To test that this works, we can mouse over the (hesitate) binding in my App Component:

Using the custom dom-events plug-in to refactor the HesitateDirective in Angular 7.1.4.

As you can see, even though my HesitateDirective uses the ".noChangeDetection" custom DOM-event bindings under the hood, it is still able to step back into the Angular Zone when it needs to ensure a change-detection digest is triggered after it emits the "hesitate" event.

One of the great joys of Angular is the power and simplicity of its template syntax. This joy is magnified when you see that Angular was architected in such a way that you can actually hook into that template parsing and provide custom behavior. In this case, I am providing a custom event-binding mechanism that binding event-handlers outside of the core Angular Zone in order to suppress the automatic change-detection. How wonderful (in very rare cases)!

Want to use code from this post? Check out the license.

Reader Comments

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel