IntersectionObserver API Performance: Many vs. Shared In Angular 11.0.5
source link: https://www.bennadel.com/blog/3954-intersectionobserver-api-performance-many-vs-shared-in-angular-11-0-5.htm
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
IntersectionObserver API Performance: Many vs. Shared In Angular 11.0.5
Tags:
Just before Christmas, I started to experiment with using NgSwitch
and the IntersectionObserver
API to defer template bindings in Angular. The hope being that I could reduce digest complexity which may lead to better performance when dealing with very large data-sets. In that experiment, each Element received its own instance of the IntersectionObserver
; which, at the end, may have lead to its own performance bottleneck. To dig a little deeper into this latter thought, I wanted to compare performance when using many IntersectionObserver
instances vs. one shared instance for a large data-set in Angular 11.0.5.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In order to test the performance of many vs. a shared IntersectionObserver
instance, I'm going to revamp my previous demo to include a version in which the UL
(List) element creates an IntersectionObserver
instance which is then injected (so to speak) into the LI
(Item) elements. I'm then going to be able to switch back-and-forth between the two different versions and, using Chrome's Dev Tools Performance features, see how they two different approaches compare.
The first version of the demo uses the approach from the previous post in which each LI
(Item) element uses its own instance of the IntersectionObserver
API:
<h2> Demo A Component </h2>
<p> Using one <code>IntersectionObserver</code> instance <strong>per Element</strong>. </p>
<!-- In this demo, every LI element gets its own IntersectionObserver. --> <ul class="items"> <li *ngFor="let value of items" class="items__item" bnIntersectionObserverA #intersection="intersection" [ngSwitch]="intersection.isIntersecting">
<span *ngSwitchCase="intersection.IS_INTERSECTING"> {{ value }} </span>
</li> </ul>
As you can see, each LI
has its own bnIntersectionObserverA
which, as we'll see in a minute, instantiates its own IntersectionObserver
instance which then calls .observe()
on its own host element.
Now, in the second version of this demo, the overall structures is the same; however, the UL
element also has a directive which works in conjunction with the LI
directive to share a single instance of the IntersectionObserver
API:
<h2> Demo B Component </h2>
<p> Using one <code>IntersectionObserver</code> instance <strong>per List</strong>. </p>
<!-- In this demo, there is an IntersectionObserver provided by the UL element. Then, every LI element injects the UL-provided instance. --> <ul bnIntersectionObserverBList class="items"> <li *ngFor="let value of items" class="items__item" bnIntersectionObserverB #intersection="intersection" [ngSwitch]="intersection.isIntersecting">
<span *ngSwitchCase="intersection.IS_INTERSECTING"> {{ value }} </span>
</li> </ul>
As you can see in this version, there are two directives at play:
-
bnIntersectionObserverBList
- Located on theUL
element, this directive instantiates the sharedIntersectionObserver
API. This directive instance is then injected into theLI
directive. -
bnIntersectionObserverB
- Located on theLI
elements, this directive uses the aforementionedUL
directive to observe intersection changes in its own host element(s).
In the end, both approaches are feeding into the NgSwitch
/ NgSwitchCase
directives that are deferring template bindings for the {{value}}
interpolation within the LI
content.
The App component then allows me to switch back-and-forth between these two demo components:
<p> <strong>Show Demo</strong>: <a (click)="showDemo( 'OnePerElement' )">One per Element</a> , <a (click)="showDemo( 'OnePerList' )">One per List</a> </p>
<div [ngSwitch]="demo"> <demo-a *ngSwitchCase="( 'OnePerElement' )"></demo-a> <demo-b *ngSwitchCase="( 'OnePerList' )"></demo-b> </div>
Now that we see how the templates work, let's dive into the actual IntersectionObserver
manifestation. The first directive, which grants an individual instance of the IntersectionObserver
to each LI
element is basically a copy from my previous blog post. In it, it passes the host element to the .observe()
method, which then alters the public isIntersecting
property that we're ultimately consuming in the above NgSwitch
statement:
// Import the core angular services. import { Directive } from "@angular/core"; import { ElementRef } from "@angular/core";
// ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- //
@Directive({ selector: "[bnIntersectionObserverA]", exportAs: "intersection" }) export class IntersectionObserverADirective {
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 );
}
}
In the second demo, we have two directives that work in tandem to provide the deferred binding. Since these work hand-in-hand, I'm defining them in the same TypeScript file. The first directive is the one bound to the UL
(List) element; the second directive is the one bound to the LI
(Item) element(s). The former is then dependency-injected into the latter:
// Import the core angular services. import { Directive } from "@angular/core"; import { ElementRef } from "@angular/core";
// ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- //
@Directive({ selector: "[bnIntersectionObserverBList]" }) export class IntersectionObserverBListDirective {
private mapping: Map<Element, Function>; private observer: IntersectionObserver;
// I initialize the intersection observer parent directive. constructor() {
// As each observable child attaches itself to the parent observer, we need to // map Elements to Callbacks so that when an Element's intersection changes, // we'll know which callback to invoke. For this, we'll use an ES6 Map. this.mapping = new Map();
this.observer = new IntersectionObserver( ( entries: IntersectionObserverEntry[] ) => {
for ( var entry of entries ) {
var callback = this.mapping.get( entry.target );
( callback && callback( entry.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" } );
}
// --- // PUBLIC METHODS. // ---
// I add the given Element for intersection observation. When the intersection status // changes, the given callback is invoked with the new status. public add( element: HTMLElement, callback: Function ) : void {
this.mapping.set( element, callback ); this.observer.observe( element );
}
// I get called once when the host element is being destroyed. public ngOnDestroy() : void {
this.mapping.clear(); this.observer.disconnect();
}
// I remove the given Element from intersection observation. public remove( element: HTMLElement ) : void {
this.mapping.delete( element ); this.observer.unobserve( element );
}
}
// ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- //
@Directive({ selector: "[bnIntersectionObserverB]", exportAs: "intersection" }) export class IntersectionObserverBDirective {
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 parent: IntersectionObserverBListDirective;
// I initialize the intersection observer directive. constructor( parent: IntersectionObserverBListDirective, elementRef: ElementRef ) {
this.parent = parent; this.elementRef = elementRef;
// 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.parent.remove( this.elementRef.nativeElement );
}
// I get called once after the inputs have been bound for the first time. public ngOnInit() : void {
// In this demo, instead of using an IntersectionObserver per Element, we're // going to use a shared observer in the parent element. However, we're still // going to use a CALLBACK style approach so that we're only reducing the number // of IntersectionObserver instances, not the number of Function calls. this.parent.add( this.elementRef.nativeElement, ( isIntersecting: boolean ) => {
this.isIntersecting = isIntersecting;
} );
}
}
The mechanics of this version work exactly the same as the mechanics of the first version: each LI
element is being observed for intersection changes. And, when a change is observed, a callback is invoked which updates the isIntersecting
property. The only meaningful difference is that the IntersectionObserver
API in this version is shared.
Now, if we open this up in the browser and we switch between the two demos (both of which are rendering 1,000 list items), we can immediately see a significant performance difference:
Both versions, when running in production mode, are actually quite fast. However, you can see that the first demo, which uses one InstersectionObserver
instance per LI
element is noticeably slower. In fact, in some of the toggling-over to the second demo, which uses a shared IntersectionObserver
instance, you can barely see any flashed of the deferred template-bindings.
If we then re-run this toggling with Chrome's Performance monitoring turned on, we can see the magnitude of the difference:
It takes almost one-quarter of a second to switch Demo A, which uses many instances of the IntersectionObserver
API. Contrast this with the performance when switching over to Demo B:
It's not quite an order of magnitude faster; but, it's just over 30-milliseconds to render the version of the demo in which we are using a shared instance of the IntersectionObserver
API.
At this point, it may become clear that sharing IntersectionObserver
instances has better performance. But, if we dig a bit deeper, it becomes a bit fuzzier as to where the actual performance bottleneck exists. If we go back to the Chrome Performance monitoring and look at the flame-graph of the Demo A switch, here's what we see:
Way down deep in the flame-graph we can see many, many change-detection digests. This is because we're binding our IntersectionObserver
inside the Angular Zone (NgZone
) which means that every time the handler callback is invoked, Angular runs change-detection to reconcile the template bindings. And, since Demo A has a one-to-one match of IntersectionObserver
instance to LI
(Item) elements, it means that we basically run 1,000 digests every time we switch over to Demo A.
In comparison, if we look at the flame-graph for the Demo B switch, we see this:
As you can see, when switching over to Demo B, which uses a shared IntersectionObserver
API, we have the same number of callbacks; but, we only end up triggering a single change-detection digest. This is because we only every bind a single InstersectionObserver
handler (which, in turn, invokes all of the callbacks). So, instead of having 1,000 handlers in the first demo, we have a single handler which invokes 1,000 callbacks. It's the same number of method invocations; but, two orders-of-magnitude fewer digests.
At first blush, if seems like we answer the question of performance: one shared IntersectionObserver
API is much faster than many instances of the IntersectionObserver
API. However, when we dig deeper, we can see that the performance bottleneck isn't clearly in the IntersectionObserver
- it could be that the performance bottleneck is actually the Angular template reconciliation and the number of change-detection digests. Which begs the question: what happens if we bind the IntersectionObserver
outside of the NgZone
and then mark Elements "for check" upon change? Which is exactly what I hope to explore in my next blog post.
UPDATE: Performance Difference in the Firefox Browser
I do all of my R&D in the Chrome Browser since I tend to enjoy its developer tools much more. And, after publishing this exploration, I happened to take a look at in in Firefox; and, what I saw was that the difference in performance was greatly magnified:
As you can see, in the Firefox browser, the shared instance of the IntersectionObserver
API is massively faster. Again, this may still be a change-detection issue more than an IntersectionObserver
issue; but, the performance difference here (in Firefox) is as clear as day-and-night.
@All,
This morning, I tried to go into this demo and bind the IntersectionObserver
outside the NgZone
. And then, whenever I
changed the .isIntersecting
property, call the
changeDetectorRef.detectChanges()
. But, this runs into
the exactly same problem: loads of change-detection digests. Since
each observer runs asynchronously, all of the callbacks end up
happening in different event-loop ticks, which means (I believe) that
even if I am more explicit in when digests are triggered, there is
still going to be a separate digest for every single element
with deferred bindings.
All to say, I think the one shared observer is really the only way to get the good performance on this technique.
Name:
Email:
( I keep this private )
Website:
Comment:
Subscribe to comments.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK