loading...

Deferred loading technique in Angular

igorfilippov3 profile image Igor Filippov Updated on ・4 min read

Introduction

Angular is very powerful framework. It has a lot of stuff to make your product's life much easier. But with great facilities, you get great responsibility.

At my current project, at CodeGym we were faced with the fact that angular produces a significantly big javascript bundle which negatively affects our UX and Page Speed Insights metrics.

You can learn more about this at Web Vitals

I suppose you already know about lazyload technique via router's loadChildren and code splitting via one-module-per-component for shared modules.

In this article I want to tell you one more technique which can help you to make your project better.

Let's go!

I assume that your already have @angular/cli installed.

We will start from scratch. First create new project:

ng new example
cd example

In the src/app folder create our lazy module, with one component.

lazy.module

@NgModule({
  declarations: [LazyComponent],
})
export class LazyModule {}

lazy.component

@Component({
  selector: "app-lazy",
  template: `
    <div> Hello, I am lazy component!</div>
  `,
})
export class LazyComponent {}

Then, we need to create a deferred loading component. It will be a wrapper for our lazy component.

@Component({
  selector: "app-deferred-loading",
  template: `<div #container></div>`,
})
export class DeferredLoadingComponent implements OnInit {
  @ViewChild("container", {read: ViewContainerRef}) container: ViewContainerRef;

  constructor(
    private compiler: Compiler,
    private injector: Injector,
  ) { }

  ngOnInit(): void {
    this.load();
  }

  async load(): Promise<void> {
    const { module, component } = await this.getContent();
    const moduleFactory = await this.compiler.compileModuleAsync(module);
    const moduleRef = moduleFactory.create(this.injector);
    const componentFactory = moduleRef.componentFactoryResolver.resolveComponentFactory(component);
    const { hostView, instance } = componentFactory.create(this.injector);
    this.container.insert(hostView);
  }

  private async getContent(): Promise<{ module: any, component: any }> {
    const [moduleChunk, componentChunk] = await Promise.all([
      import("./lazy/lazy.module"),
      import("./lazy/lazy.component")
    ]);
    return {
      module: moduleChunk["LazyModule"],
      component: componentChunk["LazyComponent"]
    };
  }
}

We have to load both module and component, because I want to show you how to deal with not single component, but a whole widget with its own services and child components.

Unfortunately, we cannot simply load the code and start using it, because each angular module have its own compilation context. That's why we have to solve this with a jit compiler .

First, we compile a module and resolve its providers.
Second, we resolve component and dynamically inject it in the DOM.

Now we can use it in our app.component.ts

@Component({
  selector: 'app-root',
  template: `
    <app-deferred-loading *ngIf="isReadyForLazyComponent"></app-deferred-loading>
    <button (click)="load()">Load and bootstrap</button>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  isReadyForLazyComponent: boolean;

  load(): void {
    this.isReadyForLazyComponent = true;
  }
}

After button click javascript code loads, compiles and angular renders brand new lazy component.

Challenge - 1

What if we want to pass some data or even to interact with app.component from lazy.component?

I do not know if it is the best way to handle such situation, but it works:

  1. Modify app.component to send data to input and to listen for output
@Component({
  selector: 'app-root',
  template: `
    <button (click)="load()">Load and bootstrap</button>
    <app-deferred-loading *ngIf="isReadyForLazyComponent" [props]="props"></app-deferred-loading>
  `,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  isReadyForLazyComponent: boolean;

  props = {
    name: "Spike",
    onClick: this.handleLazyComponentClick.bind(this),
  };

  load(): void {
    this.isReadyForLazyComponent = true;
  }

  handleLazyComponentClick(val): void {
    console.log(`${val}: from lazy component!`)
  }
}

2.Modify lazy.component to recieve and emit data

@Component({
  selector: "app-lazy",
  template: `
    <div>
      <hr>
      <div> Hello, I am lazy component!</div>
      <button (click)="handleClick()">Data from child</button>
      <hr>
    </div>
  `,
})
export class LazyComponent {
  @Output() onClick: EventEmitter<string> = new EventEmitter();
  @Input() name: string;

  handleClick(): void {
    this.onClick.emit(`My name is ${this.name}!`);
  }
}
  1. Then connect app.component and lazy.component with deferred-loading.component
@Component({
  selector: "app-deferred-loading",
  template: `<div #container></div>`,
})
export class DeferredLoadingComponent implements OnInit, OnDestroy {
  ...

  @Input() props: any;

  private isDestroyed$: Subject<void> = new Subject();

  ...

  async load(): Promise<void> {
    ...

    Object.entries(this.props).forEach(([key, value]: [string, any]) => {
      if (instance[key] && instance[key].observers) {
        instance[key]
          .pipe(takeUntil(this.isDestroyed$))
          .subscribe((e) => value(e));
      } else {
        instance[key] = value;
      }
    });

    this.container.insert(hostView);
  }

  private async getContent(): Promise<{ module: any, component: any }> {
    ...
  }

  ngOnDestroy(): void {
    this.isDestroyed$.next();
    this.isDestroyed$.complete();
  }
}

Now we can pass data to lazy.component input and listen for it's output
It is great.

Challenge - 2

What if we need to load our stuff not by click, but by getting into the viewport?

In this case, Intersection Observer comes to the rescue.

First, we have to prepare our app.component

  @Component({
  selector: 'app-root',
  template: `
    <button (click)="load()">Load and bootstrap</button>
    <div class="first-content"></div>
    <app-deferred-loading [props]="props"></app-deferred-loading>
  `,
  styles: [`.first-content {
    background-color: cornflowerblue;
    width: 100%;
    height: 120vh;
  }`]
})

Than, edit deferred-loading.component

...
export class DeferredLoadingComponent implements OnInit, OnDestroy {
  ....

  private intersectionObserver: IntersectionObserver;
  private isDestroyed$: Subject<void> = new Subject();

  constructor(
    private compiler: Compiler,
    private injector: Injector,
    private element: ElementRef,
    @Inject(PLATFORM_ID) private platformId: Object,
  ) { }

  ngOnInit(): void {
    if (isPlatformBrowser(this.platformId)) {
      if ("IntersectionObserver" in window) {
        this.intersectionObserver = this.createIntersectionObserver();
        this.intersectionObserver.observe(this.element.nativeElement);
      } else {
        this.load();
      }
    }
  }

  ...

  private createIntersectionObserver(): IntersectionObserver {
    return new IntersectionObserver(entries => this.checkForIntersection(entries));
  }

  private checkForIntersection(entries: IntersectionObserverEntry[]) {
    entries.forEach((entry: IntersectionObserverEntry) => {
      if (this.isIntersecting(entry)) {
        this.load();
        this.intersectionObserver.unobserve(this.element.nativeElement);
      }
    });
  }

  private isIntersecting(entry: IntersectionObserverEntry): boolean {
    return (<any>entry).isIntersecting && entry.target === this.element.nativeElement;
  } 

  ngOnDestroy(): void {
    ...
    if (this.intersectionObserver) {
      this.intersectionObserver.unobserve(this.element.nativeElement);
    }
  }
}

It is standart technique, introduced in Lazy Loading Images and Video .

Now, lazy.component will be bootstrapped on the page, only when it gets into the viewport.

I hope my article will help somebody to make his product better. :)

P.S. Source code can be found at github .

Posted on by:

igorfilippov3 profile

Igor Filippov

@igorfilippov3

Front End Developer at CodeGym

Discussion

markdown guide