DEV Community

Ihor Filippov
Ihor Filippov

Posted on • Updated on

Deferred loading technique in Angular

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
Enter fullscreen mode Exit fullscreen mode

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

lazy.module

@NgModule({
  declarations: [LazyComponent],
})
export class LazyModule {}
Enter fullscreen mode Exit fullscreen mode

lazy.component

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

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"]
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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!`)
  }
}
Enter fullscreen mode Exit fullscreen mode

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}!`);
  }
}
Enter fullscreen mode Exit fullscreen mode
  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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }`]
})
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 .

Top comments (1)

Collapse
 
culttm profile image
Дмитрий

Nice!