Lazy Loading Images With The IntersectionObserver API In Angular 5.0.0

The other day, I was listening to a podcast about Flexbox with Rachel Andrew and Jen Simmons in which one of them (I can't remember which one) mentioned something called the IntersectionObserver API. From what they described, the IntersectionObserver API sounded like something that would be perfect for high-performance lazy-loading of images. In the past, I've looked at lazy-loading images in Angular.js 1.x; and, it's a complicated choreography of scroll actions, timers, life-cycle hooks, and the calculation of many bounding boxes. Which is exactly what the IntersectionObserver API is designed to encapsulate! As such, I wanted to see how we might be able to make the IntersectionObserver API available through a set of Angular 5 directives and services.

Run this demo in my JavaScript Demos project on GitHub.

The goal of the IntersectionObserver API is to track the visibility of a set of targets in relation to a given viewport. The viewport may be the browser's viewport; or, it may be an arbitrary element in the DOM that contains the target elements. As consumers of the API, we define the observer root, the target elements, and provide a callback. The IntersectionObserver then invokes our callback whenever the visibility of some subset of the target elements changes.

It's easy to see that 90% of the lazy-loading-images task is being lifted off of our shoulders by the IntersectionObserver API. The last 10% of lazy-loading images requires us to tell the observer when to start and stop watching a given target; and, how to update the IMG src property when said target becomes visible. To do this, I'm going to create a [lazySrc] attribute directive that will replace the IMG element's native src attribute. This directive will then register itself with an IntersectionObserver-based abstraction that will turn around and call the directive when the associated element has become visible:

// Import the core angular services. import { Directive } from "@angular/core"; import { ElementRef } from "@angular/core"; import { OnDestroy } from "@angular/core"; import { OnInit } from "@angular/core"; import { Renderer2 } from "@angular/core"; // Import the application components and services. import { LazyTarget } from "./lazy-viewport.ts"; import { LazyViewport } from "./lazy-viewport.ts"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // @Directive({ selector: "[lazySrc]", inputs: [ "src: lazySrc", "visibleClass: lazySrcVisible" ] }) export class LazySrcDirective implements OnInit, OnDestroy, LazyTarget { public element: Element; public src: string; public visibleClass: string; private lazyViewport: LazyViewport; private renderer: Renderer2; // I initialize the lazy-src directive. constructor( elementRef: ElementRef, lazyViewport: LazyViewport, renderer: Renderer2 ) { this.element = elementRef.nativeElement; this.lazyViewport = lazyViewport; this.renderer = renderer; this.src = ""; this.visibleClass = ""; } // --- // PUBLIC METHODS. // --- // I get called once when the directive is being destroyed. public ngOnDestroy() : void { // If we haven't detached from the LazyViewport, do so now. ( this.lazyViewport ) && this.lazyViewport.removeTarget( this ); } // I get called once after the inputs have been bound for the first time. public ngOnInit() : void { // Attached this directive the LazyViewport so that we can be alerted to changes // in this element's visibility on the page. this.lazyViewport.addTarget( this ); } // I get called by the LazyViewport service when the element associated with this // directive has its visibility changed. public updateVisibility( isVisible: boolean, ratio: number ) : void { // When this target starts being tracked by the viewport, the initial visibility // will be reported, even if it is not visible. As such, let's ignore the first // visibility update. if ( ! isVisible ) { return; } // Now that the element is visible, load the underlying SRC value. And, since we // no longer need to worry about loading, we can detach from the LazyViewport. this.lazyViewport.removeTarget( this ); this.lazyViewport = null; this.renderer.setProperty( this.element, "src", this.src ); // If an active class has been provided, add it to the element. ( this.visibleClass ) && this.renderer.addClass( this.element, this.visibleClass ); } }

A lot of this directive is boiler-plate(ish). But, if you look at the ngOnInit() life-cycle hook, you can see that this directive is registering itself with an injected instance of the LazyViewport service:

this.lazyViewport.addTarget( this );

The LazyViewport service requires that the registered directive implement the LazyTarget interface, which exposes the the Element to target and an updateVisibility() callback method. The LazyViewport service then configures the IntersectionObserver - if it's supported by the browser - registeres its own callback and, when the callback is invoked, turns around and invokes the updateVisibility() method associated with the targeted element's directive instance.

export interface LazyTarget { element: Element; updateVisibility: ( isVisible: boolean, ratio: number ) => void; } export class LazyViewport { private observer: IntersectionObserver; private targets: Map<Element, LazyTarget>; // I initialize the lazy-viewport service. constructor() { this.observer = null; // The IntersectionObserver watches Elements. However, when an element visibility // changes, we have to alert an Angular Directive instance. As such, we're going // to keep a map of Elements-to-Directives. This way, when our observer callback // is invoked, we'll be able to extract the appropriate Directive from the // Element-based observer entries collection. this.targets = new Map(); } // --- // PUBLIC METHODS. // --- // I add the given LazyTarget implementation to the collection of objects being // tracked by the IntersectionObserver. public addTarget( target: LazyTarget ) : void { if ( this.observer ) { this.targets.set( target.element, target ); this.observer.observe( target.element ); // If we don't actually have an observer (lacking browser support), then we're // going to punt on the feature for now and just immediately tell the target // that it is visible on the page. } else { target.updateVisibility( true, 1.0 ); } } // I setup the IntersectionObserver with the given element as the root. public setup( element: Element = null, offset: number = 0 ) : void { // While the IntersectionObserver is supported in the modern browsers, it will // never be added to Internet Explorer (IE) and is not in my version of Safari // (at the time of this post). As such, we'll only use it if it's available. // And, if it's not, we'll fall-back to non-lazy behaviors. if ( ! global[ "IntersectionObserver" ] ) { return; } this.observer = new IntersectionObserver( this.handleIntersectionUpdate, { root: element, rootMargin: `${ offset }px` } ); } // I remove the given LazyTarget implementation from the collection of objects being // tracked by the IntersectionObserver. public removeTarget( target: LazyTarget ) : void { // If the IntersectionObserver isn't supported, we never started tracking the // given target in the first place. if ( this.observer ) { this.targets.delete( target.element ); this.observer.unobserve( target.element ); } } // I teardown this service instance. public teardown() : void { if ( this.observer ) { this.observer.disconnect(); this.observer = null; } this.targets.clear(); this.targets = null; } // --- // PRIVATE METHODS. // --- // I handle changes in the visibility for elements being tracked by the intersection // observer. // -- // CAUTION: Using fat-arrow binding for method. private handleIntersectionUpdate = ( entries: IntersectionObserverEntry[] ) : void => { for ( var entry of entries ) { var lazyTarget = this.targets.get( entry.target ); ( lazyTarget ) && lazyTarget.updateVisibility( entry.isIntersecting, entry.intersectionRatio ); } } }

As of this writing, the IntersectionObserver API is only supported in my Chrome and Firefox browsers. My Safari browser, which I think is a version behind, doesn't support it. And, IE will never support it. As such, my LazyViewport service has to be a bit defensive. There is an official IntersectionObserver polyfill, but I haven't tried it. That said, most of the code in this service just adds and removes targets - it doesn't actually handle any of the intersection logic - that's all done by the IntersectionObserver API.

By default, the injected LazyViewport instance will be associated with the browser's viewport because we don't provide a root element when providing the LazyViewport service in the LazyModule:

// Import the core angular services. import { NgModule } from "@angular/core"; // Import the application components and services. import { LazySrcDirective } from "./lazy-src.directive.ts"; import { LazyViewport } from "./lazy-viewport.ts"; import { LazyViewportDirective } from "./lazy-viewport.directive.ts"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // @NgModule({ declarations: [ LazySrcDirective, LazyViewportDirective ], exports: [ LazySrcDirective, LazyViewportDirective ], providers: [ // Setup the default LazyViewport instance without an associated element. This // will create a IntersectionObserver that uses the browser's viewport as the // observer root. This way, an instance of LazyViewport is always available for // injection into other directives and services. // -- // NOTE: This service will be overridden at lower-levels in the component tree // whenever a [lazyViewport] directive is applied. { provide: LazyViewport, useFactory: function() { var viewport = new LazyViewport(); viewport.setup( /* No root. */ ); return( viewport ); } } ] }) export class LazyModule { // ... }

... but, here's where Angular's dependency-injection gets really exciting: any element in the component tree can override the LazyViewport provider, thereby causing descendant [lazySrc] instances to be associated with a more local viewport. To facilitate overriding of the LazyViewport service in a subtree of the component graph, I created a [lazyViewport] directive:

// Import the core angular services. import { Directive } from "@angular/core"; import { ElementRef } from "@angular/core"; import { OnDestroy } from "@angular/core"; import { OnInit } from "@angular/core"; // Import the application components and services. import { LazyViewport } from "./lazy-viewport.ts"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // @Directive({ selector: "[lazyViewport]", inputs: [ "offset: lazyViewportOffset" ], // The primary role of this directive is to override the default LazyViewport // instance at this point in the component tree. This way, any lazy-directives // that are descendants of this element will receive this instance when using // dependency-injection. providers: [ { provide: LazyViewport, useClass: LazyViewport } ] }) export class LazyViewportDirective implements OnInit, OnDestroy { public offset: number; private elementRef: ElementRef; private lazyViewport: LazyViewport; // I initialize the lazy-viewport directive. constructor( elementRef: ElementRef, lazyViewport: LazyViewport ) { this.elementRef = elementRef; this.lazyViewport = lazyViewport; this.offset = 0; } // --- // PUBLIC METHODS. // --- // I get called once when the directive is being destroyed. public ngOnDestroy() : void { this.lazyViewport.teardown(); } // I get called once after the inputs have been bound for the first time. public ngOnInit() : void { // Ensure that the offset value is numeric when we go to initialize the viewport. if ( isNaN( +this.offset ) ) { console.warn( new Error( `[lazyViewportOffset] must be a number. Currently defined as [${ this.offset }].` ) ); this.offset = 0; } // Now that this LazyViewport directive has overridden the instance of // LazyViewport in the dependency-injection tree, we have to initialize it // to use the current element as the observer root. this.lazyViewport.setup( this.elementRef.nativeElement, +this.offset ); } }

As you can see, this directive defines a providers collection which overrides the injectable service associated with the LazyViewport dependency-injection token. By doing this, any [lazySrc] directive lower down in the component tree will get this LazyService override-instance rather than the global one:

this.lazyViewport.setup( this.elementRef.nativeElement, +this.offset );

At this time, I think it makes more sense to always go with the global instance. But, I just love how Angular's dependency-injection tree makes it so easy to change the viewport. Dependency-injection is just such a win!

Once I import the LazyModule into my AppModule (not worth showing), I can then use the [lazySrc] directive on my IMG tags. In this case, I've created a scrolling list of contacts with avatars. The avatars will only load when the contact is scrolled into view:

// Import the core angular services. import { Component } from "@angular/core"; // ----------------------------------------------------------------------------------- // // ----------------------------------------------------------------------------------- // interface Contact { id: number; name: string; avatarUrl: string; } @Component({ selector: "my-app", styleUrls: [ "./app.component.less" ], template: ` <p> <a (click)="toggleContacts()">Toggle Contacts</a> </p> <ul *ngIf="isShowingContacts" class="contacts"> <li *ngFor="let contact of contacts"> <img [lazySrc]="contact.avatarUrl" lazySrcVisible="visible" /> <span>{{ contact.name }} - {{ contact.id }}</span> </li> </ul> <p> <a (click)="popContact()">Pop Contact</a> — <a (click)="pushContact()">Push Contact</a> </p> ` }) export class AppComponent { public contacts: Contact[]; public isShowingContacts: boolean; public maxID: number; // I initialize the app component. constructor() { this.contacts = []; this.isShowingContacts = false; this.maxID = 0; for ( var i = 1 ; i < 50 ; i++ ) { this.pushContact(); } } // --- // PUBLIC METHODS. // --- // I remove a contact from the top of the collection. public popContact() : void { this.contacts.shift(); } // I add a new contact to the bottom of the collection. public pushContact() : void { this.contacts.push({ id: ++this.maxID, name: "Frances McDormand", avatarUrl: `./app/frances-mcdormand.jpg?id=${ this.maxID }` }); } // I toggle the showing of the contact list. public toggleContacts() : void { this.isShowingContacts = ! this.isShowingContacts; } }

As you can see, instead of using a traditional src attribute, my IMG tag is using the [lazySrc] directive:

<img [lazySrc]="contact.avatarUrl" lazySrcVisible="visible" />

Now, when we load the application and toggle the contact list, we can see from the network activity that only the visible portion of the avatars have loaded:

As you can see from the network activity, only the four visible avatars have actually been loaded over the network. The rest don't get loaded until you scroll them into view, at which time the IntersectionObserver API let's our LazyViewport service know; which, in turn, let's our [lazySrc] directives know.

That is just insanely awesome! And, if we start using the polyfilly, then the code gets even more simple as we can make more assumptions about support and code less defensively.

As a final note, passing around Document Object Model elements is always a little stressful because this is where so many memory leaks have come from historically. As such, I wanted to peek at the memory usage to see how the IntersectionObserver behaves when adding and removing targets. The following graph is Chrome's performance monitoring that I recorded while toggling the contact list into and out of existence.

As you can see, when I toggled the contact list, thereby adding and removing targets to and from the IntersectionObserver, respectively, the memory graph jumped up as we would expect. But, the important part is that when I triggered a Garbage Collection, the baseline memory usage dropped back down to the same level, indicating that there are no values suck in memory purgatory.

I have to say, I am really loving this IntersectionObserver API. It has solid support in the most popular browsers; and, it has polyfills that can be applied in lieu of support. It's going to make lazy-loading of images (and other types of content) so much easier.

Tweet This Titillating read by @BenNadel - Lazy Loading Images With The IntersectionObserver API In Angular 5.0.0 Woot woot — you rock the party that rocks the body!







