Angular, React und der Wert von Übergängen
Ob ein Framework-Wechsel zum Umzug oder zum Neubau wird, entscheidet sich daran, wie billig zwei Welten monatelang nebeneinander laufen können.
Ein Beschluss wie „wir schreiben das Frontend komplett neu" richtet seinen Schaden nicht sofort an, sondern mit etwa neun Monaten Verzögerung. Dann existieren zwei halbe Produkte: ein altes AngularJS-Frontend, offiziell eingefroren, das trotzdem laufend Hotfixes bekommt, weil zahlende Nutzer damit arbeiten – und ein neues, das bei gefühlten sechzig Prozent steht und keinen Release-Termin hat, weil „erst alles fertig sein muss". Jede Fachanforderung wird ab da doppelt verhandelt: einmal für die alte Welt, einmal für die, die noch nicht ausgeliefert ist. Das Team teilt sich still in Bewahrer und Neubauer, und beide Gruppen werden langsamer.
Ich habe diesen Verlauf inzwischen mehr als einmal aus der Nähe gesehen, zuletzt in einem Produktteam mit einer über Jahre gewachsenen AngularJS-Anwendung. Der Fehler lag nie im Ziel-Framework. Er lag in einem Wort, das im Beschluss fehlte: Übergang. Ein Framework-Wechsel ist in der Praxis kein Umschreiben, sondern ein Zustand, in dem zwei Welten monatelang parallel laufen. Und ob dieser Zustand billig oder ruinös wird, hat mit Angular oder React erstaunlich wenig zu tun.
Der Treiber: AngularJS bekommt ein Ablaufdatum
Seit Ende Januar ist offiziell, was sich lange abgezeichnet hat: AngularJS 1.x bekommt mit Version 1.7 einen letzten Entwicklungszyklus und wechselt danach in eine dreijährige Long-Term-Support-Phase. Ab dann gibt es nur noch Sicherheitskorrekturen und Anpassungen an neue Browser- und jQuery-Versionen, keine Features mehr. Für Teams mit großen 1.x-Anwendungen ist das der Moment, in dem aus „irgendwann müssen wir da raus" ein Termin wird.
Entsprechend viele Migrationsentscheidungen fallen gerade. Ob das Ziel Angular 5 heißt (Angular 6 steht für die kommenden Monate an) oder React 16 – die Richtung finde ich zweitrangig. Interessant ist, wie die Entscheidung formuliert wird. „Wir schreiben neu" ist ein Projekt mit Enddatum, und dieses Enddatum reißt fast immer. „Wir betreiben beide Welten parallel und verschieben Stück für Stück" ist dagegen ein Zustand, den man steuern, messen und notfalls anhalten kann.
Umschreiben oder umziehen
Damit bin ich bei der eigentlichen These: Die Qualität einer Frontend-Architektur zeigt sich nicht daran, wie modern ihr Framework ist, sondern daran, wie billig der Parallelbetrieb zweier Frameworks wäre. Wer Fachlogik framework-frei gehalten hat – reine Module, Services ohne DI-Magie, Zustand außerhalb der Komponenten – zieht um. Wer Berechnungen, Validierungen und API-Aufrufe in Controller, Direktiven und Komponenten gegossen hat, schreibt neu, ob er will oder nicht. Das Framework war dann nie ein Rahmen, sondern der Träger des gesamten Gebäudes, und Träger tauscht man nicht im laufenden Betrieb.
In dem erwähnten Produktteam haben wir vor dem ersten Migrationsschritt zwei Wochen lang gar nicht migriert. Wir haben Fachlogik aus Direktiven herausgezogen und in schlichte ES-Module verschoben, mit den bestehenden AngularJS-Services als dünner Hülle darüber. Erst danach war überhaupt entscheidbar, welcher Teil ein Umzug wird und welcher ein Neubau bleiben muss. Die Unterscheidung war ernüchternd konkret: Was ohne angular.module-Import auskam, ließ sich verschieben wie ein Möbelstück. Alles andere musste einzeln bewertet werden.
Hybridbetrieb mit ngUpgrade
Für die Richtung AngularJS nach Angular ist Parallelbetrieb kein Trick, sondern ein dokumentierter, offiziell unterstützter Modus. Das UpgradeModule startet die alte Anwendung innerhalb der neuen:
// app/app.module.ts — Angular 5 hosting AngularJS 1.6
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { UpgradeModule } from '@angular/upgrade/static';
@NgModule({
imports: [BrowserModule, UpgradeModule],
})
export class AppModule {
constructor(private upgrade: UpgradeModule) {}
ngDoBootstrap() {
// AngularJS boots inside Angular, not the other way around
this.upgrade.bootstrap(document.documentElement, ['orderApp'], { strictDi: true });
}
}
Ab diesem Punkt laufen beide Frameworks im selben DOM-Baum. Neue Angular-Komponenten lassen sich in alte Templates hineinreichen:
// expose a new Angular component to old AngularJS templates
import { downgradeComponent } from '@angular/upgrade/static';
import { InvoiceFooterComponent } from './invoice-footer.component';
angular
.module('orderApp')
.directive('invoiceFooter', downgradeComponent({ component: InvoiceFooterComponent }));
Umgekehrt lassen sich alte Services in die neue Dependency Injection hochreichen. Das klingt bequem, und genau darin liegt die Gefahr: Wer die Brücke zu oft benutzt, verflechtet beide Welten enger, als sie es vor der Migration waren. Zwei Change-Detection-Mechanismen laufen gleichzeitig: der Digest-Zyklus von AngularJS und die Zone-basierte Erkennung von Angular. Jedes Ereignis, das die Grenze quert, wird in beide Welten übersetzt, und Fehler an dieser Naht sind unangenehm zu suchen. Der Hybridbetrieb ist ein Werkzeug, um eine Übergangszeit zu überstehen – nicht, um sie zu verlängern. Er braucht ein Ende, das jemand ausspricht.
Route für Route: der Strangler-Ansatz
Die Idee dahinter ist deutlich älter als jedes der beteiligten Frameworks. Martin Fowler hat schon 2004 beschrieben, wie neue Systeme alte umwachsen wie eine Würgefeige ihren Wirtsbaum – die Strangler Application. Auf ein Frontend übersetzt heißt das: Eine schmale Shell besitzt den Router und entscheidet pro Route, welche Welt rendert. Für genau dieses Muster gibt es mit single-spa inzwischen sogar ein eigenes kleines Framework, aber im Kern reicht Routing-Disziplin.
flowchart LR B[Browser] --> S[Router-Shell] S --> O["/orders<br/>AngularJS 1.6"] S --> I["/invoices<br/>Angular 5"] S --> R["/reports<br/>Angular 5"] O -. "Route für Route<br/>wandert um" .-> I
Der Schnitt entlang von Routen hat drei Eigenschaften, die ich an keinem anderen Migrationsschnitt in dieser Kombination gefunden habe:
- Jeder Schritt ist einzeln zurücknehmbar – eine Route zurückzuschalten ist ein Router-Eintrag, kein Rollback-Drama.
- Der Fortschritt ist messbar und für alle sichtbar: so viele Routen alt, so viele neu.
- Die Grenze ist fachlich, nicht technisch. „Rechnungen sind umgezogen" versteht auch der Produktverantwortliche; „der Direktiven-Layer ist portiert" versteht niemand außerhalb des Teams.
Die Big-Bang-Alternative hat keine dieser Eigenschaften. Sie hat nur einen Stichtag, und Stichtage verteidigen sich nicht selbst.
Nebenbei ändert der Routen-Schnitt auch die Teamdynamik. Statt einer Bewahrer- und einer Neubauer-Gruppe gibt es ein Team, das dieselbe Anwendung betreibt und dabei Route für Route umbaut. Jeder abgeschlossene Schritt landet in Produktion und wird von echten Nutzern getreten – die Rückmeldung kommt nach Wochen, nicht nach anderthalb Jahren. Das ist unspektakulär, aber genau darin liegt der Wert.
Fachlogik, die den Umzug überlebt
Das billigste Umzugsgut ist Code, der von keinem Framework weiß. In der erwähnten Anwendung war die Rechnungssummen-Logik so ein Kandidat – vorher verteilt über eine Direktive und zwei Controller, nachher ein einziges Modul:
// billing/invoice-totals.js — plain ES module, no framework imports
export function calculateInvoiceTotal(items, taxRate) {
const net = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
const tax = net * taxRate;
return { net, tax, gross: net + tax };
}
Die alte Welt nutzt das Modul über eine AngularJS-Komponente:
// old world: AngularJS 1.6 component wrapping the plain module
import { calculateInvoiceTotal } from '../billing/invoice-totals';
angular.module('orderApp').component('invoiceFooter', {
bindings: { items: '<', taxRate: '<' },
controller: function InvoiceFooterController() {
this.$onChanges = () => {
this.totals = calculateInvoiceTotal(this.items, this.taxRate);
};
},
template: '<strong>{{ $ctrl.totals.gross | currency }}</strong>',
});
Die neue Welt – hier bewusst React 16, um zu zeigen, dass die Richtung egal ist – nutzt dasselbe Modul unverändert:
// new world: React 16 component, same module untouched
import { calculateInvoiceTotal } from '../billing/invoice-totals';
export function InvoiceFooter({ items, taxRate }) {
const totals = calculateInvoiceTotal(items, taxRate);
return <strong>{totals.gross.toFixed(2)} €</strong>;
}
Die Abhängigkeitsrichtung ist dabei die eigentliche Regel, nicht das Modulformat:
flowchart TD OLD["AngularJS-Komponenten<br/>alte Welt"] --> CORE["Fachlogik-Module<br/>ohne Framework-Import"] NEW["React- oder Angular-Komponenten<br/>neue Welt"] --> CORE CORE -. "verboten:<br/>Kern kennt kein UI" .-> OLD
Framework-frei halten lässt sich mehr, als der erste Blick vermuten lässt: Berechnungen und Validierungen als reine Funktionen, der API-Zugriff hinter einem eigenen Modul statt $http oder HttpClient direkt in Komponenten, der Anwendungszustand in einem eigenen Store. Redux ist hier ein gutes Beispiel, weil es absichtlich framework-neutral gebaut ist – react-redux und ng-redux sind nur dünne Bindings. Ein Redux-Store samt Reducern zieht ohne eine geänderte Zeile von AngularJS nach React um. Was übrig bleibt, sind dünne Komponenten, die Daten annehmen und Ereignisse abgeben. Genau die sind es dann auch, die neu geschrieben werden – und das ist der Teil der Arbeit, der schnell geht und wenig Risiko trägt.
Custom Elements als neutrale Brücke
Wer nicht AngularJS nach Angular migriert, sondern etwa Angular und React im selben Produkt versöhnen muss, hat kein UpgradeModule. Die neutralste Brücke, die sich hier abzeichnet, ist die Web-Plattform selbst: Custom Elements. Eine Komponente, die als <invoice-footer> im DOM registriert ist, kann von jeder Welt gerendert werden – React schreibt das Tag einfach ins JSX, AngularJS in ein Template, ein serverseitig gerendertes CMS in seinen HTML-Strom.
Mit Angular Elements ist genau dafür ein offizielles Labs-Projekt angekündigt, das mit Angular 6 greifbar werden soll: Angular-Komponenten als Custom Elements verpacken, ohne Adapter je Ziel-Framework. Ich finde die Richtung bemerkenswert, weil sie die Brücke aus dem Framework-Streit herauszieht und auf einen Browser-Standard stellt. Ehrlich bleiben muss man bei der Browser-Lage: Custom Elements v1 laufen in Chrome und Safari nativ, anderswo braucht es aktuell noch Polyfills. Für interne Anwendungen ist das meist tragbar, für öffentliche Seiten will es abgewogen sein.
Der ehrliche Preis: zwei Bundles
Parallelbetrieb ist nicht gratis, und ich halte nichts davon, das kleinzureden. Wer AngularJS und Angular – oder Angular und React – gleichzeitig ausliefert, schickt zwei Framework-Laufzeiten über die Leitung. Je nach Kombination sind das schnell 150 Kilobyte zusätzliches komprimiertes JavaScript, bevor die erste eigene Zeile geladen ist. Dazu kommen zwei Rendering-Modelle im selben Browser-Tab, doppelte Datums- und Zahlformatierung, im schlechten Fall doppelte HTTP-Schichten mit getrennten Caches.
Das frisch erschienene webpack 4 macht wenigstens die Bundle-Seite besser handhabbar: splitChunks ersetzt das alte CommonsChunkPlugin und zieht gemeinsam genutzte Module – etwa die framework-freien Fachlogik-Pakete – automatisch in geteilte Chunks. Aber Werkzeuge lindern den Preis nur, sie heben ihn nicht auf. Deshalb gehört zu einem ehrlichen Übergang ein Budget: eine gemessene Obergrenze für das Gesamtgewicht, ein Datum, ab dem das alte Bundle nicht mehr wachsen darf, und ein Zielquartal, in dem es verschwindet. Der Parallelbetrieb ist ein Kredit. Man darf ihn aufnehmen – man sollte nur den Zins kennen und den Tilgungsplan aufschreiben.
Was ich daraus mitnehme
Die LTS-Ankündigung für AngularJS hat vielen Teams gerade eine Deadline verpasst, und Deadlines verleiten zu Heldenbeschlüssen. Der Big-Bang-Rewrite ist so ein Heldenbeschluss: Er fühlt sich am Tag der Entscheidung gut an und neun Monate später nach zwei halben Produkten. Der Übergang ist die unspektakuläre Alternative – Hybridbetrieb wo es ihn gibt, Strangler-Schnitt entlang der Routen, Custom Elements als Brücke, ein ehrlich beziffertes Ladegewicht.
Was mich an dem Thema aber eigentlich beschäftigt, ist der Blick zurück auf die eigene Architektur. Der Parallelbetrieb ist ein Prüfstand: Er macht sichtbar, wie viel der Anwendung wirklich uns gehört und wie viel dem Framework. Fachlogik in reinen Modulen, Zustand außerhalb der Komponenten, API-Zugriff hinter eigenen Grenzen – das zahlt sich nicht erst bei der Migration aus, aber bei der Migration wird es unbarmherzig abgerechnet. Und da nach diesem Wechsel irgendwann der nächste kommt, schreibe ich die neue Welt gleich so, dass sie den übernächsten Übergang billig macht. Das ist vielleicht die nützlichste Frage an jeden Frontend-Entwurf, ganz ohne Migrationsdruck: Wie teuer wäre es, neben dieser Anwendung eine zweite Welt zu starten?
Weiterführende Quellen
- Angular-Dokumentation: Upgrading from AngularJS: https://angular.io/guide/upgrade
- Martin Fowler: StranglerApplication (2004): https://martinfowler.com/bliki/StranglerApplication.html
- Angular-Blog: Stable AngularJS and Long Term Support: https://blog.angular.io/stable-angularjs-and-long-term-support-7e077635ee9c
- single-spa: https://single-spa.js.org/
Kommentare