Caricare componenti Angular in maniera lazy senza il routing direttamente dall'HTML
Tags: angular
Published: 2023-04-15 13:46:30

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_:
```ts
const routes: Routes = [{
  path: 'feature',
  loadChildren: () => import('./feature.module').then(m => m.FeatureModule)
}];
```
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()`<sup>[1](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)</sup>.

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:
```ts
export const sum = (a, b) => a + b;
```
possiamo importarlo staticamente in questo modo
```ts
import { sum } from './sum';
console.log(sum(1, 2)); // 3
```
oppure dinamicamente in questo modo
```ts
import('./sum').then(module => console.log(module.sum(1, 2)) // 3 ); 
```
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:
```ts
const routes: Routes = [{
  path: 'feature',
  loadChildren: () => import('./feature.module').then(m => m.FeatureModule)
}];
```
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.: 
```ts
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 { }
```
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:
```ts
import { baz } from './baz';
const foo = 1;
const bar = 2;
export { bar, baz }
```
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`:
```ts
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 { }
```
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:

```ts
{
  imports: [CommonModule],
  declarations: [MyComponent],
  exports: [MyComponent]
}
```
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`.

```ts
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;
}
```
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:
```ts
lazyImporter = () => import('./my.module').then(
  // ECMAScript module
  ECMAModule => {
    return { 
      module: ECMAModule.MyModule, // NgModule
      component: ECMAModule.MyComponent // Componente
    }
  }
);
```
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`:

```ts
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
    }
}
```
fatto questo possiamo concentrarci sul metodo `load` che farà il grosso del lavoro e sul metodo `unload`:
```ts
  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();
    }
  }
```

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()`<sup>[2](https://angular.io/api/core/ComponentRef#setinput)</sup>:

```ts
  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]);
      }
    }
  }
```

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:

```ts
  // 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();
    }
  }
```

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:
```ts
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;
};
```

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:

```ts

  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();
  }
```
Il nostro componente e ormai terminato, non dimentichiamoci però sull'`OnDestroy` di disiscrivere le sottoscrizioni:

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

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`:

```ts
export type LazyLoadImporter = () => Promise<{
  component: Type<any>;
  // module opzionale 👇
  module?: Type<any>;
}>;
```

e rendiamolo opzionale anche nel metodo `load()`:

```ts
 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();
  }
```

Il codice finale sarà quindi:

```ts
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();
    }
  }
}
```

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

```ts
// 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();
}
```
e andiamolo a caricare

```ts
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);
```

Prova su [stackblitz](https://stackblitz.com/edit/angular-hz6d5c)

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