zurück zum Artikel

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

Lukas Adler

(Bild: Sepp photography/Shutterstock.com)

AngularJS für die Frontend-Entwicklung fällt aus dem Support, der Umstieg auf Angular 10 lohnt sich also. Wir zeigen, was bei der Migration zu beachten ist.

In aktuellen Webanwendungen sind Frontend-Frameworks für die Umsetzung unternehmenskritischer Funktionen nicht mehr wegzudenken. Eines davon ist AngularJS, ein 2010 erschienenes, robustes und über viele Jahre gewachsenes Frontend-Framework. AngularJS bietet gegenüber der Entwicklung von Webanwendungen mit reinem JavaScript einige Vorteile wie wiederverwendbare Komponenten und Direktiven, Two-Way Bindings und Dependency Injection. Durch diese und weitere Vorteile erlangte AngularJS eine große Popularität, die ihren Höhepunkt im Jahre 2016 erreichte, als der von Google entwickelte Nachfolger Angular 2 erschien.

Der Long-Term Support (LTS) für die letzte Version von AngularJS 1.8 begann am 01.07.2018. Die AngularJS-Entwickler verlängerten den für drei Jahre vorgesehenen Support-Zeitraum aufgrund der COVID-19-Pandemie nochmals um sechs Monate. Das bedeutet, dass AngularJS zukünftig keine offiziellen Fixes mehr erhalten wird, was zu Inkompatibilitäten mit neuen Browser- oder Library-Versionen führen kann. Auch das Beheben möglicher Schwachstellen durch offizielle Patches soll entfallen [1]. Somit stellt sich die Frage, wie die Migration des Frameworks zur neueren Angular-Version zu bewerkstelligen ist. Dieser Artikel zeigt die Migration von AngularJS zu Angular 10 [2] in einem großen monolithischen ContentManagementSystem (CMS) und geht auf mögliche Fallstricke ein.

Im vorliegenden Praxisbeispiel bestand die Herausforderung darin, zwei verschachtelte AngularJS-Anwendungen auf Angular-Anwendungen zu aktualisieren. Die erste Anwendung war das CMS. Es bot neben anderen Funktionen die Möglichkeit, die Oberfläche von Hotspots für verschiedene Partner frei zu konfigurieren. Die zweite Anwendung, nachfolgend als Portal bezeichnet, stellte die eigentliche Benutzeroberfläche der Hotspots dar. Der Kern des CMS bestand aus einem Editor, in dem sich die Benutzeroberfläche des Portals mithilfe von Overlays, Dialogen und What-You-See-Is-What-You-Get-Editoren (WYSIWYG) bearbeiten ließ. Das stark auf den Kunden zugeschnittene Produkt hatte seinen Fokus auf einem intuitiven und leicht zu bedienenden UI-Design. Der Bearbeitungsmodus durch Overlays erforderte es, die Anwendungen ineinander zu verschachteln. Dazu lud AngularJS die Bundles (Menge von JavaScript-Dateien) des gebauten Portals im Editor des CMS. Dieses Einbetten des Portals in den Editor lässt sich als App-in-App-Struktur bezeichnen.

Zur Risikoeinschätzung fand zunächst eine Studie statt, um die Möglichkeit einer Angular-Migration der monolithischen Anwendung zu evaluieren. Als Basis der Analyse diente eine Tabelle mit den Spalten "Anwendungsbereich", "Abhängigkeiten", "vermutete Probleme" und "potenzielle Lösungsansätze". Die Analyse identifizierte 59 zu prüfende Anwendungsbereiche. Dabei soll der Fokus im Folgenden auf den größten Komplexitätstreibern und deren Lösungsansätzen liegen.

Aufgrund der Größe der beiden Anwendungen erstreckte sich die Migration von AngularJS zu Angular über einen längeren Zeitraum. Für die inkrementelle Aktualisierung der einzelnen Bausteine kam für den Übergangszeitraum eine hybride App zum Einsatz. Dazu stellt Angular das upgrade/static-Paket zur Verfügung [3], das das Betreiben einer AngularJS-Anwendung in einer Angular-Anwendung erlaubt. Für über Jahre gewachsene monolithische Anwendungen bietet das den großen Vorteil, dass es nicht nötig ist, sie in einem Zug auf Angular 10 zu aktualisieren ("Big Bang"-Ansatz). Für das inkrementelle Upgrade gibt es verschiedene Hilfsfunktionen, um aktualisierte Angular-Komponenten und -Services über eine Downgrade-Methode in AngularJS zu verwenden [4] (s. Abb. 1).

Hilfsfunktionen für ein inkrementelles Upgrade auf Angular 10 (Abb. 1)

Hilfsfunktionen für ein inkrementelles Upgrade auf Angular 10 (Abb. 1)

(Bild: Angular)

In einem ersten Schritt sind die für Angular notwendigen Pakete zu installieren, wie hier auszugsweise gezeigt. Das upgrade/static-Paket kommt an dieser Stelle zur Geltung, da es alle Funktionalitäten für den Betrieb einer hybriden Anwendung enthält.

"@angular/upgrade": "^10.2.1",
"@angular/core": "^10.2.1",
"@angular/common": "^10.2.1",
...

Danach ist das Importieren von UpgradeModule aus dem upgrade/static-Paket erforderlich (Auszug):

@NgModule({
  imports: [UpgradeModule, …

AngularJS setzt das Bootstrapping der Anwendung im Template über die ng-app-Direktive um. Angular ersetzt das durch den nachfolgend gezeigten Code. Zuerst erfolgt das Setzen der AngularJS-Version, danach der Anstoß für das Bootstrapping des Root-Moduls:

setAngularJSGlobal(angular);
platformBrowserDynamic().bootstrapModule(AppModule);

Da das CMS noch keine Single-Page-Anwendung (SPA) ist, prüft es für jede Seite einzeln, ob die AngularJS- oder die Angular-Anwendung zu laden ist. Im Development-Modus prüft es, ob die Root-Komponente von Angular im Template gesetzt ist und ob ein Eintrag im Local Storage vorhanden ist. Wenn beides zutrifft, lädt das CMS die Angular-Anwendung; ansonsten lädt es die AngularJS-Anwendung. Über den Eintrag im Local Storage lässt sich steuern, welche Anwendung zu laden ist:

if (hasAngularRootComponent && (isAngular)) {
      	app.bootstrap(AppComponent); return;
}
this.downgradeResourcesToAngJs();
this.upgrade.bootstrap(angularRoot, [cmsApp], { strictDi: true });

In beiden AngularJS-Anwendungen lädt der TypeScript Loader [5] die Typescript-Dateien, Webpack und der raw-loader [6] die dazugehörigen Templates der Komponenten. Der raw-loader lädt Dateien in einen String. Das importierte HTML-Template in AngularJS sieht aus wie folgt:

import template from './cms.component.html';

Mit dem TypeScript Loader und dem raw-loader funktioniert das Laden von Templates auch in Angular 10, hier ein Decorator mit importiertem Template:

@Component({
  selector: 'cms-root',  template: template })
Der Komponenten-Decorator von Angular verwendet aber üblicherweise eine [code]templateUrl[/code]:
@Component({
  selector: cms-root, templateUrl: './cms.component.html'})

Der im AngularJS-Portal verwendete TypeScript Loader versucht, die templateUrls über HTTP abzufragen. Das resultiert in einem HTTP-404-Fehler, da auf diesem Weg die Templates nicht aufzufinden sind, wenn sie nicht im korrekten Verzeichnis für statische Inhalte liegen. Stattdessen soll sich der relative Pfad auf das Dateisystem beziehen. Dieses Problem lässt sich durch den Einsatz des angular2-template-loader lösen, der die templateUrl durch ein Inline Template ersetzt [7].

Damit die Templates über einen relativen Pfad zu laden sind, verwendet die hybride App das Angular Compiler Webpack Plugin (ngtools/webpack) [8], um das Laden der Templates vom Dateisystem über die URL zu ermöglichen. Der wichtigste Vorteil von ngtools/webpack ist allerdings die Ahead-of-Time-Kompilierung (AOT). Zuletzt ruft das Angular Compiler Webpack Plugin noch den Angular compatibility compiler (ngcc) [9] auf. Er sorgt für die Ivy-kompatible Kompilierung aller Libraries. Falls ngtools/webpack nicht alle Anwendungen Ivy-kompatibel kompiliert hat, kann das auch als Postinstall-Schritt erfolgen:

"postinstall": "ngcc -l debug --async false",

Das Importieren von Templates über den raw-loader in Kombination mit dem Angular Compiler Webpack Plugin ist nicht mehr möglich, da das Template vor der Kompilierung feststehen muss [10], und führt zu folgender Fehlermeldung: "template must be a string Value could not be determined statically." Das Laden der Templates über die templateUrl ist ohnehin der bevorzugte Weg.

Dieser Ansatz zeigt somit, wie sich eine hybride Anwendung realisieren lässt, in der eine AngularJS-Anwendung in einer Angular-Anwendung läuft. Des Weiteren ermöglicht er das Laden von Inline Templates (AngularJS-Anwendung) und templateURLs (Angular-Anwendung).

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 [11]. 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.

Neben den beschriebenen Herausforderungen gibt es weitere Punkte, die für eine erfolgreiche Migration zu beachten sind, wie etwa die Routing-Strategie zweier verschachtelter SPAs, die Migration von Grunt und Webpack auf das Angular CLI oder die Migration des CMS von einer Thymeleaf-basierten Anwendung zu einer Single-Page Application. Zum jetzigen Zeitpunkt ist die Migration des Portals bereits abgeschlossen und es war möglich, den hybriden Modus zu verlassen. Das Portal ist somit nun eine reine Angular-Anwendung. Auch im CMS ist bereits die Migration einzelner Bereiche abgeschlossen, und je nach Seite kommt eine AngularJS- oder eine Angular-Anwendung zum Einsatz.

Abschließend lässt sich sagen, dass sich in diesem Fall der Einsatz der upgrade/static-Library gelohnt hat. Die Anwendung ließ sich dadurch Stück für Stück migrieren, ohne an Funktionsumfang zu verlieren. Zudem war es möglich, neue Features direkt in der hybriden Anwendung zu implementieren. Für kleinere Frontend-Applikationen wäre es auch eine valide Möglichkeit, die Migration in einem Zug ohne die Verwendung einer hybriden App durchzuführen. Durch das neue Aufsetzen des Projekts wären die Vorteile des Angular CLI direkt nutzbar und die Zeiten für die aufwendige Integration der Angular-Anwendung würden entfallen. Letztendlich sollte der gewählte Ansatz auf verschiedenen Faktoren wie der Dauer der Release-Zyklen, dem Funktionsumfang, der Komplexität der Anwendung und der Teamstärke basieren.

Lukas Adler
ist IT-Consultant und Certified Professional for Software Architecture bei CGI in Sulzbach [12]. Er betreut zahlreiche Kundenprojekte in der Telekommunikationsindustrie und ist Experte für Full-Stack-Softwareentwicklung und Architekturthemen.

(mai [13])


URL dieses Artikels:
https://www.heise.de/-6196548

Links in diesem Artikel:
[1] https://docs.angularjs.org/misc/version-support-status
[2] https://www.heise.de/news/JavaScript-Eine-kleinere-Angular-10-Version-soll-den-Releaseplan-einhalten-4794890.html
[3] https://www.npmjs.com/package/@angular/upgrade
[4] https://angular.io/guide/upgrade
[5] https://www.npmjs.com/package/ts-loader
[6] https://v4.webpack.js.org/loaders/raw-loader/
[7] https://www.npmjs.com/package/angular2-template-loader
[8] https://www.npmjs.com/package/@ngtools/webpack
[9] https://angular.io/guide/ivy#ivy-and-libraries
[10] https://indepth.dev/posts/1293/improved-error-logging-by-the-angular-aot-compiler
[11] https://webpack.js.org/blog/2020-10-10-webpack-5-release/
[12] https://www.cgi.com/de
[13] mailto:mai@heise.de