Caricare componenti Angular in maniera lazy senza il routing direttamente dall'HTML

nigrosimone

Nigro Simone

Posted on April 15, 2023

Caricare componenti Angular in maniera lazy senza il routing direttamente dall'HTML

Immaginate di avere un componente Angular molto pesante, che ad esempio usa una o più librerie, questo componente inciderebbe molto sul caricamento iniziale dell'applicazione anche se magari non deve essere fin da subito disponibile, perché ad esempio viene utilizzato solo cliccando su un determinato bottone.
In questi casi può essere utile caricarlo in maniera lazy, cioè scaricare il codice necessario al suo funzionamento (HTML, JavaScript e CSS) solo quando realmente necessario e non direttamente nel bundle dell'applicazione.
L'approccio classico in questi casi è quello di destinare una rotta e caricarla in maniera lazy:

const routes: Routes = [{
  path: 'feature',
  loadChildren: () => import('./feature.module').then(m => m.FeatureModule)
}];
Enter fullscreen mode Exit fullscreen mode

Non sempre però è possibile o opportuno creare una rotta specifica, a volte può essere utile poterlo caricare lazy anche senza l'ausilio di una rotta. L'approccio lazy routing di Angular ci suggerisce però la strada per implementare una soluzione lazy inline cioè direttamente da template HTML, utilizzando il metodo import()1.

Il metodo import() permette di caricare moduli ECMAScript (da non confondere con gli NgModule di Angular) in maniera dinamica, cioè solo quando viene chiamata la funzione e non in maniera statica come avviene normalmente.

Per capire la differenza, immaginiamo di avere ad esempio il seguente modulo ECMAScript:

export const sum = (a, b) => a + b;
Enter fullscreen mode Exit fullscreen mode

possiamo importarlo staticamente in questo modo

import { sum } from './sum';
console.log(sum(1, 2)); // 3
Enter fullscreen mode Exit fullscreen mode

oppure dinamicamente in questo modo

import('./sum').then(module => console.log(module.sum(1, 2)) // 3 ); 
Enter fullscreen mode Exit fullscreen mode

Notiamo che importando dinamicamente un modulo, dobbiamo attendere che la promise restituita da import() sia risolta prima di poter utilizzare il modulo e quindi i suoi export, questo perché lo script deve essere fisicamente scaricato prima del suo utilizzo, mentre nell'import statico possiamo utilizzarlo immediatamente, perché questo è sin da subito disponibile perché già compilato nel bundle iniziale dell'applicazione che è stato scaricato al primo avvio.
Questo implica che tutti gli import statici contribuiscono ad appesantire il bundle.

Il metodo import() quindi può essere comodo in tutti quei contesti in cui bisogna utilizzare un modulo pesate, che magari viene usato solo in determinati punti dell'applicazione, la cosa interessante è che il modulo sarà scaricato solo la prima volta, per poi essere salvato in cache e quindi nei successivi utilizzi non peserà il tempo di download.

Abbiamo quindi visto come funziona import() e che viene già normalmente usato su Angular per caricare in maniera lazy delle rotte. Analizzando nel dettaglio il caricamento lazy delle rotte Angular:

const routes: Routes = [{
  path: 'feature',
  loadChildren: () => import('./feature.module').then(m => m.FeatureModule)
}];
Enter fullscreen mode Exit fullscreen mode

possiamo notare che a loadChildren viene assegnata una funzione che sarà invocata quando la rotta sarà risolta, questa funzione a sua volta scaricherà il modulo ECMAScript con import('./feature.module') che esporta la classe FeatureModule che è un NgModule, es.:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FeatureRoutingModule } from './feature-routing.module';
import { FeatureComponent } from './feature.component';

@NgModule({
  imports: [
    CommonModule,
    FeatureRoutingModule
  ],
  declarations: [FeatureComponent]
})
export class FeatureModule { }
Enter fullscreen mode Exit fullscreen mode

Possiamo sfruttare quindi questo flusso, per caricare qualsiasi NgModule in maniera lazy per poi creare a runtime i componenti che questo NgModule esporta.

Dobbiamo prima però capire bene, la differenza tra modulo ECMAScript e NgModule, i due costrutti sono concettualmente simili ma hanno implicazioni diverse.
Con un ECMAScript module possiamo isolare del codice ed esportare solo le parti che vogliamo rendere disponibili a chi importerà il modulo, ad esempio in questo modulo:

import { baz } from './baz';
const foo = 1;
const bar = 2;
export { bar, baz }
Enter fullscreen mode Exit fullscreen mode

chi lo importerà utilizzare solo bar e baz che sono esportati con export, ma non potrà utilizzare foo che non viene esportato, da notare inoltre che baz è importata da un altro modulo che la esporta, ma è anche ulteriormente esportata anche da questo modulo.

In un NgModule concettualmente avviene lo stesso processo, abbiamo degli import di ECMAScript module che poi vengono "smistati" all'interno del decoratore @NgModule(), definendo quindi a livello del framework Angular e della su dependencies injection, cosa questo NgModule importa e cosa esporta. Prendiamo questo esempio di NgModule:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MyComponent } from './my.component';

@NgModule({
  imports: [CommonModule],
  declarations: [MyComponent],
  exports: [MyComponent]
})
export class MyModule { }
Enter fullscreen mode Exit fullscreen mode

notiamo degli import di ECMAScript module iniziali e un ECMAScript export finale della classe MyModule, ma notiamo anche la presenza del decoratore @NgModule() che altro non è che una funzione che accetta un oggetto in input così definito:

{
  imports: [CommonModule],
  declarations: [MyComponent],
  exports: [MyComponent]
}
Enter fullscreen mode Exit fullscreen mode

ritroviamo quindi alcuni termini familiari come imports e exports che esprimono, come negli ECMAScript module, cosà questo NgModule importerà e cosa esporterà agli altri NgModule che lo importeranno nei loro imports, in più notiamo la presenza del nodo declarations che in Angular definisce cosa potrà essere utilizzato nei template HTML, quindi se ne deduce che:
MyModule in quanto NgModule importa l'NgModule chiamato CommonModule (che contiene direttive come NgIf, NgFor, etc.) e esporta il componente MyComponent che potrà quindi essere creato a runtime e dato che è presente anche nelle declarations potrà essere utilizzato anche nel template HTML tramite il suo selettore, ma in quanto ECMAScript module esporta solo la classe MyModule.

Le idee a questo punto potrebbero essere un po' confuse, ma probabilmente andando avanti diventerà tutto più chiaro. Partiamo quindi con il descrivere cosa andremo ad implementare.

L'idea è di avere un componente che chiameremo <lazy-load></lazy-load> che si preoccuperà di caricare in modo lazy qualsiasi modulo e di creare a runtime il componente desiderato e di iniettarlo nell'HTML nella posizione in cui si trova <lazy-load></lazy-load>. Per far questo, <lazy-load></lazy-load> avrà un @Input() tramite il quale passeremo il metodo da chiamare per caricare il modulo da caricare così come avviene per loadChildren nel routing.

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

export type LazyLoadImporter = () => Promise<{ component: Type<any>, module: Type<any> }>;

@Component({
  selector: 'lazy-load',
  template: ``
})
export class LazyLoadComponent {
     @Input() lazyImporter: LazyLoadImporter;
}
Enter fullscreen mode Exit fullscreen mode

Quindi lazyImporter dovrà essere una funzione che ritorna una promise che a sua volta ritornerà un oggetto composto da un componente Angular e un NgModule, in altre parole, dovrà essere una funzione così definita:

lazyImporter = () => import('./my.module').then(
  // ECMAScript module
  ECMAModule => {
    return { 
      module: ECMAModule.MyModule, // NgModule
      component: ECMAModule.MyComponent // Componente
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Quindi implementiamo ngOnChanges prevedendo un metodo per "caricare" e uno per "scaricare" il componente in base al fatto se l'input lazyImporter sia valorizzato o meno e aggiungiamo nell'HTML del template il contenitore che ospiterà il componente caricato esponendolo con @ViewChild(), mentre una property componentRef conterrà l'istanza del componente creata a runtime e nel costruttore ci facciamo iniettare l'Injector di riferimento per la risoluzione delle dependencies injection:

import { Component, Input, OnChanges, SimpleChanges, ViewChild, ViewContainerRef, ComponentRef, Injector, Type } from '@angular/core';

export type LazyLoadImporter = () => Promise<{ component: Type<any>, module: Type<any> }>;

@Component({
  selector: 'lazy-load',
  template: `
    <!-- contenitore che ospiterà il componente caricato -->
    <ng-container #vcRef></ng-container>
`
})
export class LazyLoadComponent implements OnChanges {

    // template reference al contenitore che ospiterà il componente caricato
    @ViewChild('vcRef', { read: ViewContainerRef }) private vcRef!: ViewContainerRef;

    // metodo per il caricamento lazy
    @Input() lazyImporter!: LazyLoadImporter;

    // istanza del componente creato a runtime
    public componentRef!: ComponentRef<any>;

    // Injector per la risoluzione della dependencies injection
    constructor(private injector: Injector) { }

    ngOnChanges(changes: SimpleChanges) {
      // se cambia l'input lazyImporter
      if (changes.lazyImporter) {
        // se valorizzato carichiamo il componente altrimenti lo scarichiamo
        if (changes.lazyImporter.currentValue) {
          this.load(changes.lazyImporter.currentValue);
        } else {
          this.unload();
        }
      }
    }

    async load(lazyImporter: LazyLoadImporter) {
      // ... da implementare
    }

    unload() {
      // ... da implementare
    }
}
Enter fullscreen mode Exit fullscreen mode

fatto questo possiamo concentrarci sul metodo load che farà il grosso del lavoro e sul metodo unload:

  async load(lazyImporter: LazyLoadImporter) {
      // distruggiamo eventuali componenti precedentemente creati
      this.unload();

      // scarichiamo l'ECMAScript module
      const result = await lazyImporter();

      // creiamo l'NgModule
      const lazyModuleRef = createNgModule(result.module, this.injector);

      // creiamo il componente all'interno della vista
      this.componentRef = this.vcRef.createComponent(result.component, { ngModuleRef: lazyModuleRef, injector: this.injector });
  }

  unload() {
    // se presente nella vista un componente
    if (this.componentRef) {
      // ripuliamo la vista
      this.vcRef.clear();
    }
  }
Enter fullscreen mode Exit fullscreen mode

Il grosso è fatto, dobbiamo solo risolvere due questioni e cioè la gestione degli @Input() e degli @Output() del componente creato.
Essendo che vogliamo implementare una soluzione quanto più generica possibile, non possiamo "bindare" uno ad uno tutti gli input e gli output ma dobbiamo trovare una soluzione di "comodo", cioè avere un unico @Input() e un unico @Output().

Partiamo dall'input. Prevediamo una property componentInput che sarà un Record<string, any> (quindi un oggetto chiave/valore), dove la chiave sarà la proprietà in input del componente caricato e il valore il valore da settare all'input. Per fare questo costruiremo l'input come un setter che ad ogni cambiamento chiamerà un nostro metodo setInput() che per per ogni chiave del record setterà sul componente l'input di riferimento con l'API di Angular setInput()2:

  private _componentInput!: Record<string, any>;
  // input unico per valorizzare tutti gli input
  @Input()
  set componentInput(value: Record<string, any>) {
    this._componentInput = value;
    this.setInput();
  }

  private setInput() {
    // se abbiamo il componente e gli input
    if (this.componentRef && this._componentInput) {
      // per ogni chiave del record
      for (const property in this._componentInput) {
        // settiamo sul componente l'input
        this.componentRef.setInput(property, this._componentInput[property]);
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

Ora è il turno dell'output. Anche in questo caso prevediamo una property unica componentOutput che emetterà un Record<string, any> (quindi un oggetto chiave/valore), dove la chiave sarà la proprietà in output del componente caricato e il valore, il valore emesso dall'output. Accumuliamo però tutte le sottoscrizioni in un array per prevedere una routine unica di unsubscribe da chiamare all'occorrenza:

  // output unico per dispacchare tutti gli output
  @Output() componentOutput: EventEmitter<LazyLoadOutput> = new EventEmitter();

  // array di sottoscrizioni
  private subOutput: Array<Subscription> = [];

  private setOutput() {
    // disiscriviamo tutte le sottoscrizioni
    this.unsubscribe();
    // se abbiamo il componente
    if (this.componentRef) {
      // per ogni property
      for (const property in this.componentRef.instance) {
        const compRefProp = this.componentRef.instance[property];
        // se la property è di tipo  EventEmitter
        if (compRefProp instanceof EventEmitter) {
          this.subOutput.push(
            // la sottoscriviamo e emettiamo l'output
            compRefProp.subscribe((value) =>
              this.componentOutput.emit({ property: property, value: value })
            )
          );
        }
      }
    }
  }

  private unsubscribe() {
    // disiscriviamo tutte le sottoscrizioni
    for (const subOutput of this.subOutput) {
      subOutput.unsubscribe();
    }
  }
Enter fullscreen mode Exit fullscreen mode

L'interfaccia LazyLoadOutput sarà quindi un Record<string, any> di base, ma daremo la possibilità tramite definizione del generic di associare il corretto valore e la corretta property, estraendo le property dal componente caricato e facendo inferenza sull'EventEmitter per estrarre il valore emesso:

type ExtractEventEmitter<P> = P extends EventEmitter<infer T> ? T : never;
export type LazyLoadOutput<T = Record<string, any>> = {
  property: keyof T;
  value: ExtractEventEmitter<T[keyof T]> | any;
};
Enter fullscreen mode Exit fullscreen mode

A questo punto rimettiamo mano al meto load() ed aggiungiamo anche lì una chiamata a setInput() e a setOutput() così da avere tutto ben inizializzato già dopo la fase di creazione del componente:


  async load(lazyImporter: LazyLoadImporter) {
    // distruggiamo eventuali componenti precedentemente creati
    this.unload();

    // scarichiamo l'ECMAScript module
    const result = await lazyImporter();

    // creiamo l'NgModule
    const lazyModuleRef = createNgModule(result.module, this.injector);

    // creiamo il componente all'interno della vista
    this.componentRef = this.vcRef.createComponent(result.component, {
      ngModuleRef: lazyModuleRef,
      injector: this.injector,
    });

    // settimao gli input 👇
    this.setInput();
    // sottoscriviamo gli output 👇
    this.setOutput();
  }
Enter fullscreen mode Exit fullscreen mode

Il nostro componente e ormai terminato, non dimentichiamoci però sull'OnDestroy di disiscrivere le sottoscrizioni:

  ngOnDestroy(): void {
    this.unsubscribe();
  }
Enter fullscreen mode Exit fullscreen mode

Ora, possiamo chiudere con una piccola ma importante modifica. Da Angular 15 sono stati introdotti i componenti standalone che non prevedono la creazioni di un NgModule. Rendiamo quindi opzionale la presenza del module nell'interfaccia LazyLoadImporter:

export type LazyLoadImporter = () => Promise<{
  component: Type<any>;
  // module opzionale 👇
  module?: Type<any>;
}>;
Enter fullscreen mode Exit fullscreen mode

e rendiamolo opzionale anche nel metodo load():

 async load(lazyImporter: LazyLoadImporter) {
    // distruggiamo eventuali componenti precedentemente creati
    this.unload();

    // scarichiamo l'ECMAScript module
    const result = await lazyImporter();

    // creiamo l'NgModule se presente 👇
    const lazyModuleRef = result.module
      ? createNgModule(result.module, this.injector)
      : undefined;

    // creiamo il componente all'interno della vista
    this.componentRef = this.vcRef.createComponent(result.component, {
      ngModuleRef: lazyModuleRef,
      injector: this.injector,
    });

    // settimao gli input
    this.setInput();
    // sottoscriviamo gli output
    this.setOutput();
  }
Enter fullscreen mode Exit fullscreen mode

Il codice finale sarà quindi:

import {
  Component,
  ComponentRef,
  createNgModule,
  EventEmitter,
  Injector,
  Input,
  OnChanges,
  OnDestroy,
  Output,
  SimpleChanges,
  Type,
  ViewChild,
  ViewContainerRef,
} from '@angular/core';
import { Subscription } from 'rxjs';

export type LazyLoadImporter = () => Promise<{
  component: Type<any>;
  module?: Type<any>;
}>;

type ExtractEventEmitter<P> = P extends EventEmitter<infer T> ? T : never;
export type LazyLoadOutput<T = Record<string, any>> = {
  property: keyof T;
  value: ExtractEventEmitter<T[keyof T]> | any;
};

@Component({
  selector: 'lazy-load',
  template: `
    <!-- contenitore che ospiterà il componente caricato -->
    <ng-container #vcRef></ng-container>
`,
  standalone: true,
})
export class LazyLoadComponent implements OnChanges, OnDestroy {
  // template reference al contenitore che ospiterà il componente caricato
  @ViewChild('vcRef', { read: ViewContainerRef })
  private vcRef!: ViewContainerRef;

  // metodo per il caricamento lazy
  @Input() lazyImporter!: LazyLoadImporter;

  // output unico per dispacchare tutti gli output
  @Output() componentOutput: EventEmitter<LazyLoadOutput> = new EventEmitter();

  private _componentInput!: Record<string, any>;
  // input unico per valorizzare tutti gli input
  @Input()
  set componentInput(value: Record<string, any>) {
    this._componentInput = value;
    this.setInput();
  }

  // istanza del componente creato a runtime
  public componentRef!: ComponentRef<any>;

  // array di sottoscrizioni
  private subOutput: Array<Subscription> = [];

  // Injector per la risoluzione della dependencies injection
  constructor(private injector: Injector) {}

  ngOnChanges(changes: SimpleChanges) {
    // se cambia l'input lazyImporter
    if (changes.lazyImporter) {
      // se valorizzato carichiamo il componente altrimenti lo scarichiamo
      if (changes.lazyImporter.currentValue) {
        this.load(changes.lazyImporter.currentValue);
      } else {
        this.unload();
      }
    }
  }

  ngOnDestroy(): void {
    this.unsubscribe();
  }

  async load(lazyImporter: LazyLoadImporter) {
    // distruggiamo eventuali componenti precedentemente creati
    this.unload();

    // scarichiamo l'ECMAScript module
    const result = await lazyImporter();

    // creiamo l'NgModule se presente
    const lazyModuleRef = result.module
      ? createNgModule(result.module, this.injector)
      : undefined;

    // creiamo il componente all'interno della vista
    this.componentRef = this.vcRef.createComponent(result.component, {
      ngModuleRef: lazyModuleRef,
      injector: this.injector,
    });

    // settimao gli input
    this.setInput();
    // sottoscriviamo gli output
    this.setOutput();
  }

  unload() {
    this.unsubscribe();
    // se presente nella vista un componente
    if (this.componentRef) {
      // ripuliamo la vista
      this.vcRef.clear();
    }
  }

  private setInput() {
    // se abbiamo il componente e gli input
    if (this.componentRef && this._componentInput) {
      // per ogni chiave del record
      for (const property in this._componentInput) {
        // settiamo sul componente l'input
        this.componentRef.setInput(property, this._componentInput[property]);
      }
    }
  }

  private setOutput() {
    // disiscriviamo tutte le sottoscrizioni
    this.unsubscribe();
    // se abbiamo il componente
    if (this.componentRef) {
      // per ogni property
      for (const property in this.componentRef.instance) {
        const compRefProp = this.componentRef.instance[property];
        // se la property è di tipo  EventEmitter
        if (compRefProp instanceof EventEmitter) {
          this.subOutput.push(
            // la sottoscriviamo e emettiamo l'output
            compRefProp.subscribe((value) =>
              this.componentOutput.emit({ property: property, value: value })
            )
          );
        }
      }
    }
  }

  private unsubscribe() {
    // disiscriviamo tutte le sottoscrizioni
    for (const subOutput of this.subOutput) {
      subOutput.unsubscribe();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Non manca che provarlo sul campo, quindi creiamo un nuovo componente da caricare lazy:

// test.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';

@Component({
  selector: 'test',
  template: ` ciao {{name}}! <button (click)="saluta.emit('Ciao sono ' + name)">Saluta</button>`,
  standalone: true,
})
export class LazyLoadComponent {
  @Input() name: string = '';
  @Output() saluta = new EventEmitter();
}
Enter fullscreen mode Exit fullscreen mode

e andiamolo a caricare

import 'zone.js/dist/zone';
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';
import {
  LazyLoadComponent,
  LazyLoadImporter,
  LazyLoadOutput,
} from './lazy-load.component';

// Con import type importiamo solo le definizioni e non il modulo.
// Typescript in fase di transpilazione rimuoverà completamente l'istruzione
import type { TestComponent } from './test.component';

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, LazyLoadComponent],
  template: `
    <lazy-load 
      [lazyImporter]="lazyImporter" 
      [componentInput]="{name}"
      (componentOutput)="onComponentOutput($event)"
    ></lazy-load>
  `,
})
export class App {
  lazyImporter: LazyLoadImporter = () =>
    import('./test.component').then((m) => ({
      component: m.TestComponent,
    }));

  public name = 'Simone';

  onComponentOutput(event: LazyLoadOutput<TestComponent>) {
    alert(`L'output "${event.property}" ha emesso "${event.value}"`);
  }
}

bootstrapApplication(App);
Enter fullscreen mode Exit fullscreen mode

Prova su stackblitz

Per concludere, ho realizzato anche un libreria https://www.npmjs.com/package/ng-lazy-load-component che mette a disposizione un componente generico per il caricamento lazy di componenti, la libreria mette a disposizione qualche altra feature interessante, il codice è anche disponibile su GitHub https://github.com/nigrosimone/ng-lazy-load-component

💖 💪 🙅 🚩
nigrosimone
Nigro Simone

Posted on April 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related