Skip to content

Instantly share code, notes, and snippets.

@bennadel
Created November 6, 2017 00:13
Show Gist options
  • Save bennadel/5157610fd9b360bf2e796070e39447af to your computer and use it in GitHub Desktop.
Save bennadel/5157610fd9b360bf2e796070e39447af to your computer and use it in GitHub Desktop.
Lazy Loading Images With The IntersectionObserver API In Angular 5.0.0
// 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>
&mdash;
<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;
}
}
// 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 );
}
}
// 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 );
}
}
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
);
}
}
}
// 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 {
// ...
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment