Webanwendungs-Entwicklung: So gelingt der Sprung von AngularJS zu Angular 10

Seite 2: Wenn eine Angular-App in einer Angular-App läuft

Inhaltsverzeichnis

Die größte Herausforderung stellte die Einbettung des Portals in das CMS dar. Der standardmäßige Weg in Angular wäre, das Portal als Angular-Library zu erstellen, die sich anschließend als Dependency im CMS laden ließe. In diesem Anwendungsfall bestand jedoch eine wichtige Anforderung in einem separaten Deployment des Portals über das CMS. Das machte die Verwendung des Angular-Library-Ansatzes unmöglich, da sie vor dem Build-Vorgang die Installation der Library in der Anwendung erfordert.

In AngularJS lässt sich das gewünschte Verhalten über das Laden der Portal-Bundles im CMS erreichen, was das Laden aller Abhängigkeiten in den Scope und somit deren Verfügbarkeit mit sich bringt. Sie lassen sich dann über die jeweiligen String-basierten Identifier in die Modulstruktur von AngularJS integrieren. Angular ist restriktiver geworden, deshalb ist zunächst der Import der Module nötig, die in die Modulstruktur zu laden sind. Aufgrund der AOT-Kompilierung muss Angular wissen, welche Module zu integrieren sind und wo diese liegen. Um ein separates Portal-Deployment weiterhin zu ermöglichen, ist es nötig, das Portal erst zur Laufzeit zu laden. Dafür lässt sich Webpack Lazy Loading nutzen:

import(`/shared/dist/sharedlibrary/bundles/${props.modulePath}`)

Selbst unter Einsatz von Webpack Lazy Loading erfolgt während der AOT-Kompilierung die Erstellung eines Bundles, das Angular zur Laufzeit beim Import lädt. Dieses Verhalten verhindert das separate Deployment des Portals, da Angular zum Zeitpunkt der Kompilierung des CMS den Quellcode des Portals mitkompiliert und als Bundle bereitstellt. Deshalb ist eine Erweiterung des Import-Statements nötig, um Webpack anzuweisen, die Import-Funktion zum Zeitpunkt der Kompilierung zu ignorieren und erst zur Laufzeit zu laden. Dieses Verhalten lässt sich durch folgende Ergänzung mittels webpackIgnore erreichen:

import(/*webpackIgnore: true*/`/shared/dist/sharedlibrary/bundles/${props.modulePath}`)

Dadurch überträgt Angular das Bundle zur Laufzeit über HTTP vom Webserver. Das Erstellen des Portals als Angular Library erfolgt beim Build-Vorgang unter anderem als UMD-Bundle (Universal Module Definition):

dist/portal
|--bundles
	|-- shared-library.umd.min.js

Die Universal Module Definition (UMD) definiert ein einheitliches Format, das sich in Angular über Webpack Lazy Loading laden lässt. Das ermöglicht das separate Bauen und Deployen des Portals. Die geladene Library lässt sich danach über das Window-Objekt beziehen.

const portal = window['portal'];

Das gewünschte Verhalten ist seit Webpack 5 auch mit Webpack Module Federation umsetzbar. Die Entwicklung der oben genannten Lösung war nötig, da zum Zeitpunkt der Migration Webpack Module Federation nur angekündigt war. Allerdings waren auch die ersten Versionen von Webpack 5 nach Aussagen von dessen Entwicklern weder Feature-complete noch fehlerfrei. Sie veröffentlichten die neue Hauptversion lediglich, da alle Breaking Changes eingearbeitet waren und der Funktionsumfang der Neuerungen ein Major-Upgrade erforderte. Bis zur Angular-Version 12 ist Yarn zur Nutzung von Webpack 5 erforderlich. Erschwerend kommt hinzu, dass zu dem Zeitpunkt der Migration noch eine Kombination von Grunt und Webpack im Einsatz war und somit die Funktionalitäten des Angular-CLI nicht nutzbar waren. Nach der vollständigen Umstellung des CMS und des Portals auf Angular sollte das Angular CLI Grunt ablösen. Nach dieser Umstellung sollte dann auch die Einbettung des Portals in das CMS mithilfe von Webpack Module Federation erfolgen.

Bei der Entwicklung der beiden Anwendungen kam die Template Engine Thymeleaf, deren serverseitige Ausführung vor der Übertragung des Templates in den Browser stattfindet, in Kombination mit AngularJS zum Einsatz. Dabei lässt sich Thymeleaf im CMS auf der einen Seite nutzen, um serverseitig durch Attribute wie th:if oder th:text Inhalte in die einzelnen Partials (HTML-Seite in AngularJS) einzubetten. Der Plan lautet, das CMS im Zuge der Migration vollständig in eine SPA, die mit einer REST-API kommuniziert, umzubauen.

Zum anderen verwendet das CMS Thymeleaf bei der Generierung der individuell bearbeiteten Seiten für das Portal. In der sogenannten Vorgenerierung werden über Thymeleaf die Inhalte, die der Kunde auf eine Portalseite platziert hat, in das Template der Angular-Komponente geschrieben. Die Vorgenerierung ermöglicht es, beim Deployment des Portals vollständige Inhalte auszuliefern. Da diese Logik eine große Komplexität beinhaltet und eine kundenspezifische Vorgenerierung der Inhalte auch weiterhin gewünscht ist, soll auch in Zukunft Thymeleaf mit Angular kombiniert werden. Der alternative klassische Ansatz wäre hier, über REST zur Laufzeit dynamisch die Inhalte zu erstellen. Um Thymeleaf weiterhin verwenden zu können, ist es notwendig, die Angular-Komponenten dynamisch mit ihrem eigenen Template zu versehen. Danach muss die dynamische Just-in-Time-Kompilierung (JIT) der Angular-Komponenten mit ihren Templates erfolgen.

Für die beschriebene Vorgenerierung kommt eine dynamische Angular-Komponente zum Einsatz. Dem geht eine Abfrage des referenzierten Templates vom Backend-Server voraus, was das Auswerten aller Thymeleaf-Statements beim Abfragen des Templates erlaubt. Die dynamische Komponente erhält wie eine reguläre Komponente einen Decorator mit allen benötigten Einträgen:

const component = Component({
   selector: 'start-dynamic',
   template: this.template,
   styles: [/*add custom styles here*/],
   providers: [LoginService, SharedDataservice] })(CustomComponent);

Analog zur dynamischen Komponentenerstellung findet ebenso dynamisch die Erstellung eines Moduls statt. In diesem Modul ist die benötigte dynamische Komponente zu deklarieren, die als Ausgangspunkt für die JIT-Kompilierung dient. Die durch die Kompilierung entstehende ComponentFactory lässt sich anschließend dazu verwenden, die dynamische Komponente in das Document Object Model (DOM) einzufügen. Die Kompilierung des dynamischen Moduls und Rückgabe der Factory für die dynamische Komponente stellt sich wie folgt dar:

return new Promise((resolve, reject) => {
this.compiler.compileModuleAndAllComponentsAsync(module)
      .then((factories) => {
      		const componentFactory = factories.componentFactories
            .filter(fac => fac.selector === selector)[0];
            	resolve(componentFactory);
      	},
            (error) => reject(error));
      });

Der nächste Schritt besteht in einer Klasse für die dynamische Komponente:

@Directive()
export class CustomComponent implements OnInit, OnDestroy {

Die Klasse lässt sich mit dem Decorator @Directive annotieren, was aber zu Problemen im Zusammenspiel von AOT- und JIT-Kompilierung führt. Während der AOT-Kompilierung entfallen alle Decorators, was eine Dependency Injection bei der JIT-Kompilierung verhindert und zu folgendem Fehler führt:

const t = ngDevMode ? `This constructor is not compatible with Angular Dependency Injection because its dependency at index ${e} of the parameter list is invalid.
This can happen if the dependency type is a primitive like a string or if an ancestor of this class is missing an Angular decorator.
Please check that 1) the type for the parameter at index ${e} is correct and 2) the correct Angular decorators are defined for this class and its ancestors.` : "invalid";

Der Fehler lässt sich durch die Einführung eines eigenen Decorators für die dynamischen Komponenten beheben:

export const defaultComponentProps = {
};

const Reflect = window['Reflect'];
export function CustomComponentDecorator(_props) {
  _props = Object.assign({}, defaultComponentProps, _props);

  return function (cls) {
    Reflect.defineMetadata('annotations', [_props], cls);
  }
}
@CustomComponentDecorator({})

Dieser Decorator bleibt bei der AOT-Kompilierung bestehen und erlaubt es dem JIT-Compiler, Dependencies in die dynamischen Komponenten zu injecten. Der neue Decorator lässt sich bei den dynamischen Komponenten in Ergänzung mit dem @Directive Decorator verwenden, um sowohl mit der AOT- als auch mit der JIT-Kompilierung kompatibel zu sein. Um zur Laufzeit im obigen Decorator Reflections einsetzen zu können, ist in der TypeScript-Konfiguration das Flag emitDecoratorMetadata auf true zu setzen.

Das beschriebene Vorgehen ermöglichte die Weiterverwendung von Thymeleaf für die Vorgenerierung.