Artikelserie
- Einleitung
- Lazy Modules und Routen
- Eigene Lösung mit Web Components Custom Elements ⬅
Idee des Nachladens mit Berechtigungskonzept
Ein nicht unerheblicher Teil von vielen Angular-Anwendungen enthält Funktionalität, die dem potentiellen Nutzer nur in Abhängigkeit seiner Benutzerrechte zur Verfügung gestellt werden soll. Während die Anwendung die gesamte Feature-Bandbreite enthält, soll nun erst zur Laufzeit geprüft werden, welche Berechtigungen der jeweilige Nutzer hat. Diese entscheiden dann über den abrufbaren Funktionsumfang.
Im vorliegenden Beispiel soll das Reporting
Feature nur in Abhängigkeit von den Benutzerrechten angezeigt werden. Das im ersten Anwendungsszenario gezeigte Verhalten der Angular-CLI, die einen ladefähigen Chunk erstellt, der in Folge von der Route aktiviert wird, muss nun teilweise selbst implementiert werden. Hierzu wird das Reporting
-Modul als Web Component Bundle verpackt und kann dann dynamisch geladen und aktiviert werden.
Für das vorliegende Beispiel gehen wir davon aus, dass eine gemockte API die Information liefert, dass das Reporting Modul vorhanden ist (siehe oben).
Was sind Web Components?
Web Components sind Zusammenstellungen von Technologien, die die Erstellung wiederverwendbarer und individueller (HTML) Elemente (Custom Elements) ermöglichen. Web Components können so abgegrenzte Funktionalität bündeln.
Vorgehensweise
Ein Custom Element kann erzeugt werden, wenn es zuvor mit Hilfe der Custom Elements Registry API beim Browser registriert wurde. Diese API stellt eine define()
Methode bereit, die zwei Parameter erwartet: den Selektor eines Elementes (hier foo-bar
) und eine Klasse, die das Element repräsentiert (hier FoobarElement
).
class FoobarElement extends HTMLElement {
constructor(){
super();
const div = document.createElement('div');
div.innerText = '42';
this.attachShadow({mode: 'open'}).appendChild(div);
}
}
customElements.define('foo-bar', FoobarElement)
Bei der Implementierung ist zu beachten, dass ein Selektor mindestens einen Bindestrich (Minus) als Trennzeichen enthalten muss: eine valide Namensgebung wäre foo-bar
, wohingegen foobar
invalid ist. Mit dem Selektor kann nun ein Element vom Typ FoobarElement
erzeugt werden:
const foobar = document.createElement('foo-bar');
document.body.appendChild(foobar);
Die im Angular Framework (via @angular/elements
) bereitgestellte Funktion createCustomElement
erwartet neben dem Selektor eine Component (hier foobarComponent
) und eine Referenz auf den Injector der App (hier injector
). Dieser zweite Parameter ist ein Konfigurationsobjekt, das eine Referenz auf den Injector der App (Web Component Bundle) hält. Die createCustomElement
-Funktion erzeugt so einen dynamischen Wrapper um die Angular Component. Dieser Wrapper kann später vom Browser als Factory für unsere Component verwenden werden.
const factory = createCustomElement(FoobarComponent, { injector })
customElements.define('foo-bar', factory);
Nachladen und Aktivierung der Angular-Module
Die nachfolgende Animation zeigt den Ablauf des Nachladens und der Aktivierung des Reporting
-Moduls im Zusammenspiel mit der gemockten Server API.
Nachladen
import {Component, ElementRef, HostListener, OnDetsroy, OnInit, ViewContainerRef} from '@angular/core';
import {ActivatedRoute} from '@angular/router';
import {combineLatest, Subscription} from 'rxjs';
import {Context} from '../../services/context';
import {ModuleService} from '../../services/module.service';
@Component({
selector: 'labs-dynamic-layout',
template: '',
styleUrls: ['./dynamic-layout.component.scss'],
})
export class DynamicLayoutComponent implements OnInit, OnDestroy {
private subscription = Subscription.EMPTY;
constructor(
readonly moduleService: ModuleService,
readonly elementRef: ElementRef,
readonly activatedRoute: ActivatedRoute,
readonly context: Context,
readonly viewContainerRef: ViewContainerRef,
) {
}
public ngOnInit(): void {
this.subscription = combineLatest(this.moduleService.modules, this.activatedRoute.params).subscribe(([modules, { id }]) => {
this.viewContainerRef.clear();
this.moduleService.load(modules.find(current => current.id === id), this.elementRef.nativeElement);
});
}
@HostListener('document:activate-module', ['$event.detail'])
public bootstrap(module: { init: (parentContext: any) => void }) {
module.init(this.context);
}
public ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}
Die Route für die Funktionalität Reporting bekommt module ID als Parameter mit. Navigiert der Nutzer auf diese Route, wird eine DynamicLayoutComponent
aktiviert.
// app-routing.module.ts
{ path: 'module/:id', component: DynamicLayoutComponent},
In ngOnInit
findet eine Subscription auf modules und die aktuelle module ID als Parameter der URL statt. Wurde das Module über den ModuleService
geladen und die aktuelle Module ID breitgestellt, so kann
- der aktuelle ViewContainer mit
viewContainerRef.clear()
bereinigt werden. - Sowie ein Element mit dem
labs-reporting
Selektor in derload()
-Methode erstellt werden. - Und zuletzt, mittels der
load()
-Methode, die JavaScript-Dateien eines Modules über HTTP geladen werden. Dazu werden script-Elemente für die Bundle Files erstellt (vgl. unten). Danach führt sich das geladene Bundle selbst aus und initialisiert dasReportingModule
.
// module-service.ts
public load({ files, url, selector }: Module, element: HTMLElement): void {
const entryComponent = document.createElement(selector);
element.appendChild(entryComponent);
files.forEach(file => {
const fileUrl = `http://localhost:4201${url}/${file}`;
const script = document.createElement('script');
script.type = 'module';
script.src = fileUrl;
element.appendChild(script);
},
);
}
Aktivierung mit Custom Events
Das Reporting-Modul wirft über die activate
-Methode ein Custom Event mit dem Namen activate-module
:
// reporting.module.ts
ngDoBootstrap() {
ModuleService.activate(components, this.injector, Context);
}
Die DynamicLayoutComponent
lauscht auf das Aktivierungs-Event und startet die Modulinitialisierung mit dem geteilten State als Parameter:
// dynamic-layout.component.ts
@HostListener('document:activate-module', ['$event.detail'])
public bootstrap(module: { init: (parentContext: any) => void }) {
module.init(this.context);
}
Nun kommt die oben angesprochene define()
-Methode der Custom Elements Registry API wieder ins Spiel. Die Modulinitialisierung registriert die ReportingComponent
in der CustomElementRegistry
. Dazu wird der mitgegebene labs-reporting
-Selektor verwendet:
// module.service.ts
static activate(components: { [selector: string]: Type }, parentInjector: Injector, contextType: Type): void {
document.dispatchEvent(new CustomEvent('activate-module', {
detail: {
init: (context) => {
const injector = Injector.create({ providers: [{ provide: contextType, useFactory: () => context }], parent: parentInjector });
Object.keys(components).forEach(selector => {
customElements.define(selector, createCustomElement(components[selector], { injector }));
});
},
},
}),
);
}
Da vor der Initialisierung der ReportingComponent
bereits ein Element mit dem Selektor ‘labs-reporting’ erstellt wurde, wird nun ReportingComponent
direkt zum Leben erweckt – das Reporting
-Modul läuft.
Größenvergleich
Die Nutzung von Web Component Bundles bietet die Möglichkeit, Funktionalität in die Anwendung einzubringen, deren Feature Bundle zunächst nicht bekannt sein muß. Die jeweiligen Modul-Files werden über den Module-Service durch den Server bereitgestellt. Dadurch kann hier ein separater Build verwendet werden. Das Web Component Bundle ist jedoch größer, da es eine Angular Runtime enthält.
Zum Vergleich eine Gegenüberstellung der App samt dem Lazy-Modul für Settings
und dem Reporting
-Modul als Web Component Bundle:
Die App mit dem Lazy-Modul für Settings
ist in Summe nur ~356 KB (gzipped 105 KB) gross. Davon belegt der Settings
-Modul-Chunk nur ~2 KB.
Im Gegensatz dazu ist das Reporting
-Modul, das als Web Component Bundle implementiert wurde, allein ~165 KB (gzipped 53 KB) gross – die Gesamtgröße erreicht knappe 521 KB (gzipped 158 KB).
Resümee
Zusammenfassend lassen sich folgende Vor- und Nachteile der beiden Wege zum dynamischen Nachladen von Angular-Modulen zur Laufzeiten aufzeigen:
Lazy Modules (Router)
➕ Lassen sich einfach definieren und verwenden
➕ Sind – was die Dateigröße angeht – sehr kompakt
➕ Injector und somit State lässt sich einfach teilen
➖ Host Anwendung muss die Module zur Buildzeit der Anwendung kennen
➖ Lazy Module (Chunk) muss auf dem gleichen Server liegen
Web Component Bundles
➕ Anwendung muss die Feature Bundles nicht kennen
➕ Bundle kann von einem anderen Server geladen werden
➕ Separater Build
➖ Bundle enthält Angular Runtime und ist dadurch größer
Hierdurch ergibt sich schon ein kleiner Leitfaden für die eigenen Projekte:
Lazy Modules (Router) dürften in Projekten ausreichen, in denen eine kompakte Anwendung von einem Team entwickelt wird. Entwickeln mehrere Teams in einem Projekt verschiedene große Module eventuell auch mit unterschiedlichen Frameworks wie Angular, React oder Vue, die nur leichtgewichtig miteinander gekoppelt sind, so wären die Web Component Bundles ein möglicher Weg zur Optimierung.