Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Using Expando DOM Properties To Power The IntersectionObserver API In Angular 9.1.6

By Ben Nadel on

Earlier this week, I took a look at using expando DOM properties in Angular 9.1.6. And, as I explained in that post, while your application's View-Model is the source-of truth in the vast majority of cases, there are instances in which the View-Model is not sufficient. One such case is when using the IntersectionObserver API. The IntersectionObserver API uses and returns Element references. Which means we need a way to map a an Element reference to some sort of callback. And, that's exactly where our expando DOM properties come into play. To see this in action, I've created a simple IntersectionObserver demo that lazy-loads image src attributes in Angular 9.1.6.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

ASIDE: This is not the first time I've looked at lazy-loading images using the IntersectionObserver API. In my first look, a few years ago, I had a much more in-depth approach that allowed various "viewports" to be defined and injected at various levels within the component tree. In contrast, this current demo uses the browser's Viewport as "the viewport". Frankly, I'm not sold on the idea that the increased complexity of my previous approach is even a value-add.

In my previous post on expando DOM properties in Angular, I created a Service - Expando - that will add and remove unique properties on a given DOM Element. These injected properties ended up in the DOM tree looking like this:

<div data-expando1589369494588="1"> Hello </div>

... where the property is prefixed with data-expando and then suffixed with a time-stamp so as to make them unique to the instance of the Angular application. The Expando service works by storing an internal counter that it increments every time a new expando DOM property is injected.

ASIDE: I did not create this idea - I stole it directly from the jQuery playbook. jQuery is awesome and changed the web landscape for ever. Deal with it :P

In this demo, I'm going to use this expando DOM property to facilitate interactions with the IntersectionObserver API. The IntersectionObserver API keeps track of a collection of Element references. And then, when any of those Element references intersect with the browser's viewport, a callback is invoked for the intersecting elements:

new IntersectionObserver(
	( entries: IntersectionObserverEntry[] ) : void => {

		// ... do something with "entries[ i ].isIntersecting" ...
		// ... do something with "entries[ i ].target" ...

	}
);

Now, in our demo, we need to take this list of Element references and translate it into some additional workflows. In my old post, I accomplished this using a Map object in which I mapped an Element reference to a Callback. But, in this new version, I'm going to use a simple Object that maps expando properties to Callbacks:

callbackIndex[ expandoID ] = Callback

Then, when the Element references are passed to the IntersectionObserver handler, I can do the following:

  1. Extract expando DOM property from Element reference.
  2. Extract Callback from callbackIndex Object using expando property.
  3. Invoke Callback.

To see this in action, let's look at my ViewportIntersectionService class. This is the "base class" for the lazy-loading of various properties. This class doesn't actually implement any lazy-loading in and of itself - it just provides the "viewport intersection" logic. Other Angular directives will then consume this Service in order to respond to viewport exposure.

When a consumer of this service calls .addTarget(), the ViewportIntersectionService injects the expando DOM property in order to associate the given Callback with the given Element. Then, it starts tracking that Element using an underlying IntersectionObserver instance:

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

// Import the application components and services.
import { Expando } from "./expando";

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

interface Callback {
	(): void;
}

interface CallbackIndex {
	[key: number]: Callback;
}

@Injectable({
	providedIn: "root"
})
export class ViewportIntersectionService {
	
	private callbackIndex: CallbackIndex;
	private expando: Expando;
	private observer: IntersectionObserver | null;

	// I initialize the viewport intersection service.
	constructor( expando: Expando ) {

		this.callbackIndex = Object.create( null );
		this.expando = expando;
		this.observer = null;

		// If the IntersectionObserver API is available in this browser (is not supported
		// in IE11), then let's instantiate a single instance of it that we will use
		// across the entire application.
		if ( window.IntersectionObserver ) {

			// CAUTION: The IntersectionObserver appears to be wired-up inside the
			// Angular NgZone. Which means, when the intersection callbacks get fired, a
			// change-detection is triggered. This is likely unnecessary processing. I
			// tried to get this hooked-up OUTSIDE the NgZone; but, I couldn't seem to
			// get it working.
			this.observer = new IntersectionObserver(
				( entries ) => {

					this.handleEntries( entries );

				}
			);

		}

	}

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

	// I track the given element for intersection with the browser's viewport. The
	// callback is invoked the FIRST TIME the element intersects with the viewport. The
	// callback is ONLY INVOKED ONCE and the target is automatically removed from the
	// service afterwards.
	public addTarget( target: Element, callback: Callback ) : void {

		// If the IntersectionObserver API isn't supported, invoke the callback
		// immediately so that we don't have to track the callback internally.
		if ( ! this.observer ) {

			this.safelyInvokeCallback( callback );
			return;

		}

		this.setCallback( target, callback );
		this.observer.observe( target );

	}


	// I remove the given target from the service tracking. 
	public removeTarget( target: Element ) : void {

		// If the IntersectionObserver API isn't supported, there's nothing to do
		// since we aren't tracking any of the callbacks internally.
		if ( ! this.observer ) {

			return;

		}

		this.unsetCallback( target );
		this.observer.unobserve( target );

	}

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

	// I handle changes to the viewport-element intersections.
	private handleEntries( entries: IntersectionObserverEntry[] ) : void {

		// NOTE: This check isn't necessary - this method will never get called if the
		// IntersectionObserver API isn't available. However, I'm including it here so
		// that TypeScript doesn't complain below. I could have used the "definitely 
		// defined assertion"; but, the other methods have this check, so I'm just
		// throwing it in here for consistency.
		if ( ! this.observer ) {

			return;

		}

		for ( var i = 0, length = entries.length ; i < length ; i++ ) {

			var entry = entries[ i ];
			var target = entry.target;

			// The moment that the target element overlaps with the viewport, we are
			// going to invoke the callback and remove the target from the service. This
			// way, each element should only be handled ONCE in its life-time.
			if ( entry.isIntersecting ) {

				this.safelyInvokeCallback( this.unsetCallback( target ) );
				this.observer.unobserve( target );

			}

		}

	}


	// I invoke the given callback, safely logging any errors.
	private safelyInvokeCallback( callback: Callback ) : void {

		try {

			callback();

		} catch ( error ) {

			// TODO: This should really be handled via an injected Logger. But, for the
			// sake of simplicity, I'm just using the browser's native logger.
			console.error( error );

		}

	}


	// I map the callback to the given target element, and store the callback for later
	// execution (once the element enters the viewport).
	private setCallback( target: Element, callback: Callback ) : void {

		// The Expando service injects a unique ID into the Element / DOM which we can
		// then use as the unique look-up in our callback index.
		var expandoID = this.expando.add( target );

		this.callbackIndex[ expandoID ] = callback;

	}


	// I break the association between the given target element and the cached callback,
	// returning the cached callback to the calling context.
	private unsetCallback( target: Element ) : Callback {

		// Remember, the Expando service injected a unique ID into the DOM. When we ask
		// the Expando service to remove that injected ID, it returns it. We can now use
		// that ID to look-up the cached callback.
		var expandoID = this.expando.remove( target );

		var callback = this.callbackIndex[ expandoID ];
		delete( this.callbackIndex[ expandoID ] );

		return( callback );

	}

}

As you can see, once a target is added to this class, it gets tracked using the IntersectionObserver API. And, when one (or more) of the targets enters the browser's viewport, the .handleEntries() method is called. Here, the service checks to see if a given Element is "in view"; and, if so, it extracts the expando DOM property, looks-up the Callback, and then invokes it.

NOTE: This service is specifically designed to aid in the lazy-loading of other DOM properties - it is not intended to be a generic abstraction of the IntersectionObserver API. As such, it has logic baked into it (such as automatically removing targets once they enter the viewport) that wouldn't scale to other forms of interaction. If, for example, you wanted to create a position:sticky directive in Angular, I would do so with a completely separate service.

Like I said before, the ViewportIntersectionService class doesn't actually do any lazy-loading on its own - it just provides the viewport-intersection logic. This logic can now be consumed by other Directives that are designed to lazy-load specific DOM properties. In this demo, I've created one that defers the loading of a [src] attribute until the host element intersects with the viewport.

In the following ViewportIntersectionSrcDirective class, the ngOnInit() Angular life-cycle method adds the host element to the injected ViewportIntersectionService instance. Then, when the host element intersects with the browser's viewport, this Directive sets the [src] property:

// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";

// Import the application components and services.
import { Expando } from "./expando";
import { ViewportIntersectionService } from "./viewport-intersection.service";

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

@Directive({
	selector: "[appViewportIntersectionSrc]",
	inputs: [ "appViewportIntersectionSrc" ]
})
export class ViewportIntersectionSrcDirective {
	
	// NOTE: Using the "Definite Assignment Assertion" to tell TypeScript that this INPUT
	// property will be assigned by the time we go to reference it (even though we don't
	// have an explicit assignment in the constructor).
	public appViewportIntersectionSrc!: string;

	private elementRef: ElementRef;
	private hasSrcBeenSet: boolean;
	private viewportIntersectionService: ViewportIntersectionService;

	// I initialize the viewport intersection src directive.
	constructor(
		elementRef: ElementRef,
		viewportIntersectionService: ViewportIntersectionService
		) {

		this.elementRef = elementRef;
		this.viewportIntersectionService = viewportIntersectionService;

		this.hasSrcBeenSet = false;

	}

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

	// I get called once when the host element is being unmounted.
	// --
	// CAUTION: Depending on the application architecture, the "destroy" method can be
	// called before the "init" method ever gets called.
	public ngOnDestroy() {

		if ( ! this.hasSrcBeenSet ) {

			this.viewportIntersectionService.removeTarget( this.elementRef.nativeElement );

		}

	}


	// I get called once after the inputs have been found for the first time.
	public ngOnInit() : void {

		// Using the core ViewportIntersectionService, we're going to defer the setting
		// of the [src] attribute until the Host Element intersects with the browser's
		// viewport.
		this.viewportIntersectionService.addTarget(
			this.elementRef.nativeElement,
			() => {

				this.elementRef.nativeElement
					.setAttribute( "src", this.appViewportIntersectionSrc )
				;
				// Flag that the [src] property has been set so that we don't bother
				// trying to detach the host element from the viewport-intersection
				// service during the destroy handler.
				this.hasSrcBeenSet = true;

			}
		);

	}

}

And, now that we have a Directive that lazy-loads the [src] property; and a Service that manages the browser's viewport intersection logic; we can start to lazy-load images in our App component:

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

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

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	template:
	`
		<p>
			<a (click)="toggle()">Toggle Images</a>
		</p>

		<ng-template [ngIf]="isShowingImages">

			<!--
				NOTE: In the following IMG tag, we're NOT SETTING the SRC attribute.
				Instead, we're using the [appViewportIntersectionSrc] directive to defer
				the loading of the [src] attribute until after the IMG tag intersects
				with the browser's viewport.
			-->
			<p *ngFor="let imageSource of imageSources">
				<img
					[appViewportIntersectionSrc]="imageSource"
					width="400"
					height="400"
					(load)="logImageLoad( imageSource )"
				/>
			</p>

		</ng-template>
	`
})
export class AppComponent {

	public imageSources: string[];
	public isShowingImages: boolean;

	// I initialize the app component.
	constructor() {

		// NOTE: As we generate the image source values, we're making them unique per
		// page-load so that the demo will always load new images even if the browser's
		// cache is enabled. This way, we get to see the network activity.
		this.imageSources = new Array( 50 )
			.fill( Date.now() )
			.map(
				( tickcount, i ) => {

					return( `./assets/schitts-creek.gif?tickcount=${ tickcount }&i=--${ i }` );
				}
			)
		;
		this.isShowingImages = true;

	}

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

	// I log the loading of the given image [src] property.
	public logImageLoad( imageSource: string ) : void {

		console.log(
			`%cImage loaded: %c${ imageSource }`,
			"color: red ; font-weight: bold ;",
			"color: black ; font-weight: normal ;"
		);

	}


	// I toggle the rendering of the Images (so that we can make sure the ngOnDestroy
	// event is working property in our viewport-intersection-src directive).
	public toggle() : void {

		this.isShowingImages = ! this.isShowingImages;

	}

}

As you can see, in this demo, I'm simply looping over a collection of 50-images. They're all the same image; but, I'm using the URL query-string to trick the browser into thinking that they are all different. When I render the images in my Angular template, I'm not using a [src] property. Instead, I'm using my lazy-loading directive:

<img [appViewportIntersectionSrc]="imageSource" />

This Directive adds the img host element to our intersection Service; and, as we scroll down the page in our Angular app, what we can see is that the [src] properties of the images are dynamically adjusted when the various host elements (images) come into view:

Image src proprties are dynamically evaluated, as seen in the Network tab, when the images enter the browser viewport.

As you can see from the browser's Network tab, the Images are only pulled-down over the network once the Image intersects with the browser's viewport.

All thanks to the expando DOM property! Well, and the Service that manages viewport intersection; and, the Directive that translates that logic into the lazy-evaluation of [src] properties.

What's really cool about this separation of concerns is that we can now easily create other Directives that perform lazy-evaluation on other DOM properties. For example, we could create individual directives that lazy-load:

  • Inline style properties for background-image: url().
  • SVG <image> [href] properties.
  • Picture [srcset] properties.

... and whatever else might make sense from a lazy-evaluations standpoint.

In the vast majority of scenarios, Angular applications work by creating a View-model that is the "source of truth"; and then, reconciling that view-model with the HTML templates. Sometimes, however - such as when dealing with the IntersectionObserver API - we have to use the DOM (Document Object Model) as a source of truth. In such cases, we can facilitate reconciliation through the use of expando DOM properties.



Reader Comments

What has two thumbs and hopes you leave a comment? This Guy! (Ben Nadel).

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.