Using IntersectionObserver And NgSwitch To Defer Template Bindings In Angular 11.0.5
Showing a really long list of data is usually not that great for the user experience (UX); and, it's usually not that great for the performance of the web page. But, sometimes you don't have a choice. And, in those cases, I often try to find ways to squeeze as much performance as possible out of the rendering. One approach that I've wanted to experiment with lately is the use of the IntersectionObserver
API as a means to defer template bindings within an Angular view. The hope being that, with fewer bindings, we'll hit fewer performance bottlenecks.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
The premise of this experiment is that, within a given "row of data", some of the bindings are "critical" and need to be visible at all times in order to drive things like the CMD+F
(Find) feature of the browser. And, other bindings are secondary and can perhaps be deferred until that row is in view. For example, you may not need to populate a <select>
menu until the parent row is in front of the user's eyeballs.
At first, you might want to create an ngIf
inspired directive that only shows an element if it is within the viewport:
<div *ngIfVisible> deferred rendering </div>
This approach is fundamentally flawed as you quickly run into the chicken-and-egg problem: you can't bind the IntersectionObserver
to the element you are hiding since it will never become visible, and will therefore never intersect with the viewport.
Really what you want to do is observe a parent / ancestor element; and then use that element's intersection to drive the visibility of a child / descendant element. In Angular, we already have a set of directives that operate with a parent/child relationship: ngSwitch
and ngSwitchCase
. To keep this exploration simple, I'm going to piggy-back on these directives, piping (so to speak) an intersection property into an ngSwitch
binding.
To try this out, I'm going to create an attribute directive, [bnIntersectionObserver]
that exposes an intersection.isIntersecting
reference. This reference will then be bound to an ngSwitch
input such that we can use ngSwitchCase
directives to defer template bindings:
<div
bnIntersectionObserver
#intersection="intersection"
[ngSwitch]="intersection.isIntersecting">
<div>
I am always rendered.
</div>
<div *ngSwitchCase="true">
I am ONLY RENDERED when "intersection.isIntersecting" is TRUE.
</div>
</div>
An [bnIntersectionObserver]
attribute directive like this doesn't have a lot of logic in it. It just observes the host element using the IntersectionObserver
API and then updates an isIntersecting
property. And, since we're changing the view-model based on the intersection changes, we aren't even going to bother running this outside of the Angular Zone.
// Import the core angular services.
import { Directive } from "@angular/core";
import { ElementRef } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Directive({
selector: "[bnIntersectionObserver]",
exportAs: "intersection"
})
export class IntersectionObserverDirective {
public isIntersecting: boolean;
// These are just some human-friendly constants to make the HTML template a bit more
// readable when being consumed as part of SWTCH/CASE statements.
public IS_INTERSECTING: boolean = true;
public IS_NOT_INTERSECTING: boolean = false;
private elementRef: ElementRef;
private observer: IntersectionObserver | null;
// I initialize the intersection observer directive.
constructor( elementRef: ElementRef ) {
this.elementRef = elementRef;
this.observer = null;
// By default, we're going to assume that the host element is NOT intersecting.
// Then, we'll use the IntersectionObserver to asynchronously check for changes
// in viewport visibility.
this.isIntersecting = false;
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the host element is being destroyed.
public ngOnDestroy() : void {
this.observer?.disconnect();
this.observer = null;
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
this.observer = new IntersectionObserver(
( entries: IntersectionObserverEntry[] ) => {
// CAUTION: Since we know that we have a 1:1 Observer to Target, we can
// safely assume that the entries array only has one item.
this.isIntersecting = entries[ 0 ].isIntersecting;
},
{
// This classifies the "intersection" as being a bit outside the
// viewport. The intent here is give the elements a little time to react
// to the change before the element is actually visible to the user.
rootMargin: "300px 0px 300px 0px"
}
);
this.observer.observe( this.elementRef.nativeElement );
}
}
As you can see, there's very little going on here; we have a one-to-one relationship between directives and IntersectionObserver
instances (which may or may not be bad performance); and, we just toggle a Boolean value. This directive has no idea how this value is going to be consumed.
In our demo App component, we're then going to create a bunch of User
objects:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface User {
id: number;
name: string;
email: string;
avatarUrl: string;
lastLoginAt: string;
city: string;
state: string;
country: string;
}
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public users: User[];
// I initialize the app component.
constructor() {
this.users = [];
while ( this.users.length < 100 ) {
this.addUser();
}
}
// ---
// PUBLIC METHODS.
// ---
// I add a demo user to the users collection.
public addUser() : void {
this.users.unshift({
id: ( this.users.length + 1 ),
name: "Ben Nadel",
email: "ben@bennadel.com",
avatarUrl: "https://bennadel-cdn.com/images/global/ben-nadel-avatar.jpg",
lastLoginAt: "Today",
city: "Irvington",
state: "New York",
country: "US"
});
}
}
When it comes to rendering these User
objects, we're going to consider the Name and Email data-points to be critical to the browser page behavior (again, to power things like the native Find functionality). But, for data-points like Avatar, Location, and Last Login date, we're going to consider those secondary and try to defer the relevant template bindings using ngSwitchCase
:
<p>
<a (click)="addUser()" class="digest">
Add User ( trigger digest )
</a>
</p>
<ng-template ngFor let-user [ngForOf]="users">
<!--
The [bnIntersectionObserver] directive attaches this host element to an instance
of the IntersectionObserver and exposes itself as the "intersection" reference.
This reference further exposes an ".isIntersecting" property which toggles
between (true) and (false) as the host element intersects with the viewport.
We're then going to pipe that value into the native [ngSwitch] directive in order
to defer some of the template bindings.
-->
<div
bnIntersectionObserver
#intersection="intersection"
[ngSwitch]="intersection.isIntersecting"
class="user">
<div class="user__header">
<div class="user__avatar">
<!-- NOTE: IMG is not loaded until intersecting. -->
<img
*ngSwitchCase="intersection.IS_INTERSECTING"
[src]="user.avatarUrl"
class="user__avatar-image"
/>
</div>
<div class="user__info">
<span class="user__name">
{{ user.name }}
</span>
<span class="user__email">
{{ user.email }}
</span>
</div>
</div>
<!-- NOTE: DIV is not loaded until intersecting. -->
<div *ngSwitchCase="intersection.IS_INTERSECTING" class="user__meta">
<span class="user__meta-item">
<strong class="user__meta-label">
Location:
</strong>
<span class="user__meta-value">
{{ user.city }} {{ user.state }}, {{ user.country }}
</span>
</span>
<span class="user__meta-item">
<strong class="user__meta-label">
Last Login:
</strong>
<span class="user__meta-value">
{{ user.lastLoginAt }}
</span>
</span>
<span class="user__meta-item">
<strong class="user__meta-label">
ID:
</strong>
<span class="user__meta-value">
{{ user.id }}
</span>
</span>
</div>
</div>
</ng-template>
As you can see, some of the data here is always being rendered; and, some of the data is being hidden behind *ngSwitchCase
bindings which will only be rendered once the <div>
element is intersecting with the browser's viewport. And, when we run this in the browser and look at the last item in the list, we can see that the deferred bindings don't show up until we scroll down to the bottom of the document:
It works pretty nicely; and, by using ngSwitch
to power the template bindings, we only need to add the little bit of code to manage the IntersectionObserver
. But, when putting this together in Angular 11.0.5, a few questions that come to mind:
IntersectionObserver
Instance? Or One Per Element?
Should I Use One In this demo, I'm using one IntersectionObserver
instance per Element. Which means, every instance only observes a single element. Does this have a performance impact? Would it have been more efficient to have a single IntersectionObserver
, and then use it to observe all the elements in question?
I am not sure. According to this discussion on the W3C GitHub, I get the sense that it shouldn't make much of a performance difference; though, it may have a memory impact. From what people are saying in that Issue, it seems like the cost of the callbacks is really the most limiting factor.
But, it's probably something worth testing.
IntersectionObserver
"Less Expensive" Than the Template Bindings?
Is the The goal of the IntersectionObserver
here is to reduce the load on the page when a change-detection digest is triggered. And, since a digest runs at least once per view-rendering, the ultimate goal here is to reduce the time it takes for the initial rendering of the list (and, secondarily, to reduce the time it takes to run all subsequent change-detection digests).
That said, I don't know how to effectively test whether the reduced string-interpolation bindings outweigh the cost of the additional directive bindings and the IntersectionObserver
instantiation. It is likely dependent on how many bindings are being deferred in your particular view.
I suspect that this will come down to a "try it and see what it feels like" kind of answer. Until we run into a page that actually has a lot of items rendering, we probably won't be able to effectively tell how this kind of approach changes the user experience (UX).
Want to use code from this post? Check out the license.
Reader Comments
@All,
I've been thinking more about the performance here; and, one thing to consider with the one instance per Element approach (that we have in this demo) is that each callback will trigger Change Detection. This is likely the biggest factor in whether or not this approach is too expensive. I am working on a follow-up demo which tries to show this more clearly.
@All,
As I mentioned above, I wanted to follow-up with a quick look at one shared instance of the
IntersectionObserver
API vs. many instances (one per element):www.bennadel.com/blog/3954-intersectionobserver-api-performance-many-vs-shared-in-angular-11-0-5.htm
The shared one is clearly faster (especially in Firefox). But, the underlying reason may be the number of change-detection digests that get triggered. The root performance bottleneck is still not absolutely clear to me and I'll keep digging in a bit.