Angular: How to Lazy Load external scripts

Recently, while working on an Angular app, I encountered a situation where I needed to load an external library when a module is loaded lazily. Normally, in such cases a NPM package for the library can be added to the module that is lazily loaded. And therefore there is no need to do extra work.

Sometimes there is no NPM package available for a library. There are a couple of ways to approach the problem. One is to simply download the library files in some folder in the app and then reference it in the index.html. However this means that the library is always loaded in the browser even if the user does not use the module that uses this library.

But there is a better approach which we are going to dive into now. In this approach we will lazily load the library only after the module that uses it is itself lazily loaded in the browser. The library that we are going to load in this app is countdown.js https://github.com/mckamey/countdownjs . It is available on cdnjs. It is able to provide difference between two Dates as descriptive and easy to understand format. We will simply use this library to calculate the time span in seconds since January 1, 2000 to the current Date.

I have created a small demo project in angular called dynamic-scripts-demo. The initial folder structure of the src folder looks like the following

dynamic-scripts-demo

Next we will add a new module called CountdownModule. For this we will create a new folder called modules under the app folder and then another folder inside it called countdown as seen in the image below

we will have a very basic countdown component for now

import { Component } from '@angular/core';

@Component({
  selector: 'app-countdown-test',
  templateUrl: './countdown.component.html',
  styleUrls: ['./countdown.component.scss']
})
export class CountdownComponent {
  constructor() { }
}

we will add this component to the default route in the routing configuration for this module in the countdown-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { CountdownComponent } from './countdown.component';

const routes: Routes = [
  { path: '', pathMatch: 'full', component: CountdownComponent }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class CountdownRoutingModule { }

and then import the routing for this module in the CountdownModule

import { NgModule } from '@angular/core';
import { CountdownComponent } from './countdown.component';
import { CountdownRoutingModule } from './countdown-routing.module';
import { CommonModule } from '@angular/common';

@NgModule({
  declarations: [
    CountdownComponent
  ],
  imports: [
    CommonModule,
    CountdownRoutingModule
  ]
})
export class CountdownModule { }

Since CountdownModule will be lazily loaded we will configure the route for it in app-routing.module.ts

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';

const routes: Routes = [
  { path: 'countdown', loadChildren: './modules/countdown/countdown.module#CountdownModule' }
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

Lets add a button that will trigger this route when clicked. We will do this in app.component.html

<h1>
  {{ title }}!
</h1>

<div routerLinkActive="hide-me">
  <div>Click on the button below to countdown the time upto seconds since January 1, 2000</div>
  <button routerLink="countdown">Start Countdown</button>
</div>

<router-outlet></router-outlet>
[amazon_auto_links id=”529″]

We will hide this button’s ancestor div when this route is active through routerLinkActive directive. When this link is active, this directive will add the hide-me css class to the div.

.hide-me {
  display: none;
}

Next we will create a service that will load the library when required. Lets call it DynamicScriptsService. We will create a new folder under the app folder and call it shared and place the service in there.

dynamic-scripts.service.ts
export class DynamicScriptsService {
  // Returns a Promise that loads the script
  loadDynamicScript(): Promise {
    return new Promise((resolve, reject) => {
      const scriptElement = window.document.createElement('script');
      scriptElement.src = 'https://cdnjs.cloudflare.com/ajax/libs/countdown/2.6.0/countdown.js';

      scriptElement.onload = () => {
        resolve();
      };

      scriptElement.onerror = () => {
        reject();
      };

      window.document.body.appendChild(scriptElement);
    });
  }
}

In the DynamicScriptsService we have added a function called loadDynamicScript. This function returns a Promise that creates a new script element and appends it to the body of the document. It then loads the script and fulfills the Promise. In case the loading of script fails, the Promise is rejected.

Lets add this service to the imports array in AppModule.

...
import { DynamicScriptsService } from './shared/dynamic-scripts.service';

@NgModule({
  ...
  providers: [DynamicScriptsService],
  ...

Finally we need to just call this service function in our CountDownComponent.

import { Component, OnInit } from '@angular/core';
import { DynamicScriptsService } from 'src/app/shared/dynamic-scripts.service';

@Component({
  selector: 'app-countdown-test',
  templateUrl: './countdown.component.html',
  styleUrls: ['./countdown.component.scss']
})
export class CountdownComponent implements OnInit {
  constructor(private dynamicScriptsService: DynamicScriptsService) { }

  isScriptLoaded = false;
  isScriptLoading = false;
  timespan: string;

  ngOnInit() {
    this.isScriptLoading = true;

    this.dynamicScriptsService.loadDynamicScript()
      .then(
        () => {
          this.isScriptLoaded = true;
          this.isScriptLoading = false;

          const countdown = (window as any).countdown;
          this.timespan = countdown(new Date(2000, 0, 1)).toString();
        },
        () => {
          this.isScriptLoaded = false;
          this.isScriptLoading = false;
        }
      );
  }
}
<div *ngIf="isScriptLoaded">{{ timespan }}</div>
<div *ngIf="!isScriptLoading && !isScriptLoaded">Countdown cannot be performed</div>

If the script is loaded successfully, the {{ timespan }} in the template will be resolved to something like 19 years, 6 months, 28 days, 14 hours, 7 minutes and 6 seconds.

Leave a Reply