Caricare componenti Angular in maniera lazy senza il routing direttamente dall'HTML
Nigro Simone
Posted on April 15, 2023
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)
}];
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;
possiamo importarlo staticamente in questo modo
import { sum } from './sum';
console.log(sum(1, 2)); // 3
oppure dinamicamente in questo modo
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:
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.:
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:
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
:
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:
{
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
.
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:
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
:
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
:
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()
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]);
}
}
}
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();
}
}
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;
};
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();
}
Il nostro componente e ormai terminato, non dimentichiamoci però sull'OnDestroy
di disiscrivere le sottoscrizioni:
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
:
export type LazyLoadImporter = () => Promise<{
component: Type<any>;
// module opzionale 👇
module?: Type<any>;
}>;
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();
}
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();
}
}
}
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();
}
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);
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
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
November 29, 2024