Möchte man heutzutage eine Single Page Application im Web-Browser entwickeln, sind Frameworks wie Angular kaum noch wegzudenken. Das Behandeln von User Interaktionen, wie sie beispielsweise bei einem Formular oder durch das Klicken auf ein Steuerelement vorkommen, werden durch Angular deutlich erleichtert.
Die Entwicklung mit Frameworks stellt Web-Entwickler vor ganz neue Herausforderungen: Wurden vor 10 Jahren noch viele einzelne HTML-Seiten an den Web-Browser ausgeliefert, wird bei einer Single Page Application nur noch eine einzige, sogenannte index.html ausgeliefert und sämtlicher Inhalt mithilfe von JavaScript neu gerendert. Die Hauptverantwortlichkeit für das Darstellen der Webseite liegt nun im Web-Browser, und das dazu benötigte JavaScript wird vollständig ausgeliefert. Leider bedeutet das auch, dass bei einer schwachen Internetverbindung die sogenannte initiale Ladedauer einer Anwendung bei einem sehr großen JavaScript-Bundle, welches heruntergeladen werden muss, sehr lang sein kann.
JavaScript wurde somit zur “kostbarsten” Ressource. In der Entwicklung von Webanwendungen möchte man natürlich die ausgelieferte Größe des JavaScript-Bundles so gering wie möglich halten. Auch das Angular-Team nahm sich dieser Herausforderung gezielt an und entwickelte einen vollständig neuen Compiler: Ivy.
Während mit Angular 8 Ivy nur als Opt-in-Möglichkeit zur Verfügung stand, entweder durch das Setzen des –enable-Ivy Flags bei der Erstellung des Projektes, oder mit Hilfe der compileroptions in der tsconfig.json, wird nun der neue Angular-Compiler ab Version 9 per Default ausgeliefert.
Neben der verringerten Bundle-Größe bringt Ivy eine Reihe von weiteren Optimierungen mit sich:
- Schnellere Rebuilds durch die separate Kompilierung jeder einzelnen Datei ohne weitere Abhängigkeiten
- Einfache Gestaltung des Debuggings durch Breakpoints im Template
- Verbesserte Typprüfung in den Templates
Dieser Artikel soll einige der Optimierungen des neuen Compilers näher beleuchten und Aufschluss über die neuen Möglichkeiten geben, die uns nun als Angular-Entwickler mit Ivy zur Verfügung stehen.
Weitere Experten-Beiträge zu Angular finden Sie in unserem kostenlosen E-Book
Aufgaben und Vorteile der neuen Komponentenentwicklung
Mit Ivy führt das Core-Team nun schon den dritten Angular-Compiler in das Framework ein (in Version 2 wurde die Template Engine als Compiler eingeführt und ab Version 4 die sogenannte ViewEngine, welche nun in Version 9 von Ivy vollständig abgelöst wird). Doch was genau ist ein Compiler eigentlich, und welche Aufgaben verbergen sich hinter der Kompilierung des Angular-Codes?
In Angular schreiben wir einen sogenannten Template Code, welcher definiert und deklariert, wie unsere Komponente später im Web-Browser gerendert werden soll. Der Compiler generiert hieraus die gesamte DOM-Struktur und verknüpft diese mit den Daten, die wir programmatisch mithilfe des Controller-Codes hineingeben. Hier sehen wir ein einfaches Beispiel eines Angular-Template-Codes und dessen JavaScript-Code, wie ihn ein Compiler beispielsweise generiert:
Mithilfe dieses JavaScript Codes erhält unser Web-Browser nun genaue Anweisungen, wie er den HTML-Baum unserer Anwendung aufbauen soll.
Da nicht jeder Browser bereits die aktuellste ECMAScript-Version ausführen kann, muss man gegebenenfalls die benötigte Version, zu der unser Angular-Code kompiliert werden soll, in der tsconfig.json setzen (zu finden als sogenannte “target”-Version). Die Webseite CanIUse gibt hierbei eine gute Übersicht über die JavaScript-Features und welche Web-Browser diese bereits unterstützen.
Neben der Transformierung des Angular-spezifischen Codes in hocheffizienten und performanten JavaScript-Code bietet ein Compiler darüber hinaus noch einige weitere Funktionalitäten. So führt er beispielsweise auch eine statische Code-Analyse durch und stellt neben der Datentyp-Überprüfung sicher, dass ein Objekt auch tatsächlich die Attribute besitzt, welche man versucht, darauf aufzurufen.
Die genaue Vorgehensweise würde hierbei den Rahmen dieses Artikels sprengen. Für einen tieferen Einblick empfehle ich den Talk “Deep Dive into the Angular Compiler“ von Alex Rickabaugh auf der AngularConnect 2019. Der generierte Code wird zusätzlich komprimiert, minifiziert und unleserlich gemacht und als das bereits erwähnte JavaScript-Bundle an den Web-Browser ausgeliefert.
Verkleinerung des JavaScript-Bundles
Einer der Hauptgründe für die Entwicklung eines neuen Angular-Compilers war die Verringerung der Größe eben dieses Bundles. Um also zu verstehen, warum es mit Angular Ivy gelingt, ein kleineres JavaScript-Bundle zu generieren, müssen wir den generierten JavaScript-Code genauer unter die Lupe nehmen. Während man bei Angular 8 noch standardmäßig mit der ViewEngine als Angular-Compiler arbeiten musste und Ivy nur als Opt-In Möglichkeit zur Verfügung stand, bekommt man nun mit Angular 9 Ivy als Standard-Compiler mitgeliefert.
Wie man sein bestehendes Angular-Projekt auf die neue Version upgradet, kann in der offiziellen Angular-Dokumentation nachgelesen werden. Für einen ersten einfachen Vergleich habe ich ein neues Projekt mit Angular Version 9 angelegt und es jeweils einmal mit Ivy und einmal mit der ViewEngine kompiliert.
Schauen wir uns die entsprechende package.json an:
Package.json unseres Beispielprojektes mit Angular 9
Um dieses Projekt mit Ivy zu kompilieren, musste ich nichts Weiteres tun, als den Angular-Compiler manuell innerhalb des Roots-Verzeichnisses meiner Angular-Applikation aufzurufen:
> ng new ivy-app
> cd ivy-app
> ngc
Um dasselbe Projekt nun mit der ViewEngine kompilieren zu können, musste ich zunächst Ivy in den CompilerOptions der tsconfig.json deaktivieren. Mit einem erneuten Aufruf des Compilers erhielt ich daraufhin den von der ViewEngine kompilierten JavaScript Code.
Bei einem direkten Vergleich der beiden Kompilate sieht man bereits, dass der neue Angular-Compiler hält, was er verspricht (das Kompilat liegt hierbei in einem nicht minifizierten oder komprimierten Format vor). So ist der mit Ivy generierte Code in unserem Beispielprojekt nahezu 50% kleiner, als der generierte Code seines Vorgängers.
Verwendeter Compiler | Größe des src/- Ordners |
ViewEngine | 92Kb |
Ivy | 50Kb |
Wirft man einen Blick auf die generierten Dateien, kann man zudem sehen, dass die ViewEngine für unsere AppComponent ganze vier verschiedene Dateien (zwei JavaScript- und zwei JSON-Dateien) generiert, wohingegen Ivy lediglich nur eine einzige JavaScript Datei erstellt.
In der app.component.js, welche von der ViewEngine generiert wurde, befindet sich hierbei lediglich der Controller-Code unserer Komponente. Sämtliche Anleitungen zum Aufbau der HTML-Struktur finden wir in der app.component.ngfactory.js. Die beiden JSON-Dateien geben uns darüber hinaus noch Aufschluss, welche Abhängigkeiten unsere Komponente zusätzlich benötigt, um korrekt in dem Web-Browser dargestellt werden zu können.
Wenn wir uns den Ivy-generierten Code ansehen, stellen wir fest, dass funktionaler Code, sowie die Anleitung zur Erstellung des Templates innerhalb derselben JavaScript-Datei zu finden sind.
Mit Ivy landet Controller-Code und Code für das Rendern des HTML-Baumes in einer Datei.
Mit Ivy wurden sogenannte statische Attribute eingeführt, welche beispielsweise eine Komponente, eine Direktive oder eine Pipe mit ihren Metadaten und ihrem Aufbau beschreiben. In unserem Listing ist es das ɵcmp–Attribut der AppComponent. Dieses Attribut erwartet eine Komponenten-Definition, welche man mit dem Aufruf der Funktion ɵɵdefineComponent des Angular-Core Frameworks erhält. Werfen wir einen Blick auf das Objekt mit dem die Funktion aufgerufen wird, sehen wir die AppComponent_Template–Funktion, welche für den Aufbau unseres HTML-Templates verantwortlich ist.
Dabei werden weitere sogenannte Template-Funktionen des Angular-Frameworks aufgerufen, wie beispielsweise ɵɵelementStart, welches unseren div-HTML-Knoten erstellt oder ɵɵtext, das für die Erstellung eines Text-Knoten in unserem Document Object Model sorgt. Ebenso können wir erkennen, dass unsere Template-Funktion auf zwei unterschiedliche Zustände reagiert: Create und Update.
Während bei dem ersten Zustand das gesamte HTML aufgebaut wird, ist der Update-Zustand für die String-Interpolation, also dem eigentlichen Einfügen des Wertes der title-Variable in das HTML, zuständig, sowie die gesamte Change Detection.
Des Weiteren ist auffällig, dass das Zeichen ɵ (griechisches Theta) Teil des Funktion-Namens ist. Dies soll lediglich aussagen, dass es sich um eine private API des Core Frameworks handelt und sich im Laufe der nächsten Release Candidates noch ändern kann und deshalb die Nutzung dieser API mit Vorsicht zu genießen ist.
Diese API wurde mit Ivy eingeführt, doch welchen Vorteil bezüglich der Bundle-Size erhalten wir nun durch sie?
Um diese Frage beantworten zu können, müssen wir uns vorerst noch einmal ansehen, wie die HTML-Struktur mithilfe der ViewEngine bisher gerendert wurde. Betrachten wir ein ganz simples HTML-Template und den von der ViewEngine kompilierten JavaScript-Code, sehen wir innerhalb der JavaScript-Datei einen Funktionsaufruf mit einem Array als Übergabeparameter. Dieses Array beinhaltet mehrere sogenannte Template-Instruktionen. In der Instruktion elementRef können wir beispielsweise den genauen Aufbau des HTML-Knotens div einsehen.
Sehen wir uns nun einmal an, wie das Angular-Framework bisher mithilfe dieser Template-Instruktionen den DOM-Baum rendert. Hier sehen wir den sehr vereinfachten Code der Core- Library:
Auszug aus dem Angular-Framework zum Rendern der Komponenten
Das heißt: für jede Instruktion innerhalb eines Template-Codes wird ein einfaches switch-case durchlaufen und die entsprechende Funktion für das HTML-Rendering aufgerufen. Dies ist ein Design-Ansatz der häufig bei Interpretern eingesetzt wird und womöglich deshalb auch hier dementsprechend implementiert wurde.
Der größte Nachteil bei diesem Ansatz ist jedoch, dass zum Zeitpunkt der Kompilierung nicht festgestellt werden kann, welche dieser Funktionen für das Rendern eines HTML-Knotens aufgerufen werden und welche nicht. Konkret heißt das, dass in unserem Beispiel der gesamte JavaScript-Source Code für die Funktion createPipe in das Bundle kompiliert und somit auch an den Web-Browser ausgeliefert wird, obwohl wir diese Funktion niemals benutzen.
In diesem sehr vereinfachten Beispiel gibt es nur drei Cases, insgesamt sind es allerdings weit über 15 verschiedene Möglichkeiten und Funktionen, die eventuell mit ausgeliefert werden, obwohl wir diese eigentlich nicht benötigen (für einen genaueren Blick in die Funktion createViewNodes kann diese eingesehen werden, indem man das Angular-Projekt mit ng build kompiliert. Die Funktion befindet sich daraufhin in der vendor-xxx.js Datei).
Die ViewEngine folgt demnach nicht dem Prinzip des sogenannten “Tree-Shaking” (auch “Dead Code Elimination” genannt), welches besagt, dass Code, der nicht benutzt — also nicht aufgerufen — wird, nicht mit in das Bundle kompiliert wird.
Und wie ist es bei Ivy? Genau hier kommt die neue API ins Spiel: mithilfe der neuen Engine rufen die Komponenten nun selbst direkt den Source-Code zu dem Rendern der HTML-Knoten auf. Das bedeutet, dass bereits zum Zeitpunkt der Kompilierung genau bestimmt werden kann, welche Funktionen eine Komponente benötigt, um gerendert zu werden. Daraus folgt wiederum, dass Funktionen, die von keiner Komponente aufgerufen werden, aus dem Angular-Core-Bundle entfernt werden können.
Definiert man also in der app.component.html-Datei lediglich HTML- und Text-Knoten, wie in unserem Einführungsbeispiel, ist der Vergleich der Bundle-Sizes zwischen dem Vorgänger ViewEngine und Ivy signifikant. Man muss allerdings auch erwähnen, dass die Neuerung dieser Dead-Code-Eliminierung hierbei lediglich Template-Source-Code betreffen. Modul-Source-Code, wie er beispielsweise in dem ReactiveFormModule oder dem HttpClientModule vorkommt, wurde zuvor bereits nach diesem Prinzip reduziert.
Schnellere Rebuilds von Angular-Apps
Ein weiteres Feature, welches mit Ivy nun ausgeliefert wird, ist das schnellere Rebuilden von unserer Angular-Applikation. Dies wird ermöglicht durch die separate Kompilierung jeder einzelnen Datei ohne weitere Abhängigkeiten. Was vorerst recht logisch erscheint, sah in dem von der ViewEngine-generierten Code allerdings noch anders aus. Zu Demonstrationszwecke haben wir hierfür unser HTML-Template mit einer einfachen ngIf-Direktive erweitert und das Projekt erneut kompiliert.
Hier ein Auszug des kompilierten JavaScript Codes:
Diese Komponente beinhaltet auch die Information der Abhängigkeiten der ngIf-Direktive
Neben der bereits bekannten Template-Instruktion für das Rendern des div-HTML-Knotens, sehen wir nun zusätzlich eine Definition der ngIf-Direktive. Allerdings erhält diese Instruktion noch einen weiteren Parameter: [i1.ViewContainerRef, i1.templateRef]. Dieses Array ist allerdings dabei keine Abhängigkeit der Komponente direkt, sondern der ngIf-Direktive.
Demzufolge beinhaltet der Komponenten-Code nicht nur seine eigene Abhängigkeit, sondern auch die Abhängigkeiten ihrer Abhängigkeiten. Die Auswirkungen bei dem Ändern der Abhängigkeiten der benutzten Direktive sind hierbei schnell ersichtlich: Sämtliche Komponenten, die diese Direktive benutzen, müssen ebenfalls neu kompiliert werden. Dabei spricht man von einer sogenannten globalen Kompilierung.
Hier sehen wir nun das Ivy-kompilierte Äquivalent:
Lokalitätsprinzip: Die Komponente kennt nur noch ihre direkten Abhängigkeiten
Hierbei werden lediglich die direkten lokalen Abhängigkeiten der Komponente deklariert: Es handelt sich um das sogenannte Lokalitätsprinzip. Die neue API liefert dementsprechend auch wieder eine Funktion für die Definierung der Direktive: ɵɵdefineDirective. Diese wiederum beinhaltet sämtliche Informationen, die ein ngIf benötigt, um dargestellt zu werden (wie beispielsweise den ViewContainerRef und templateRef).
Durch die klare Abtrennung der Abhängigkeiten mit Hilfe dieser sauber-strukturierten API, ist es von nun an möglich, nur noch die Dateien neu zu kompilieren, welche sich tatsächlich verändert haben. Doch das ist noch nicht alles: durch die klare Abtrennung können externe Libraries bereits Ahead-of-time kompiliert und in unsere Angular-Applikation importiert werden.
Zusätzlich erwähnenswert ist noch, dass neben dem Lokalitätsprinzip natürlich auch das Kompilieren zu einer einzigen JavaScript-Datei dem schnelleren Rebuilden beiträgt. Der Compiler muss nicht mehr vier Dateien und deren Referenzen zueinander erzeugen.
Verbessertes Debugging
Die Reduzierung der Bundle-Größe und das schnellere Rebuilden der Angular-Applikation waren zwar der Hauptfokus des Angular-Teams für die neue Rendering Engine, erwähnenswert ist zusätzlich allerdings auch noch die Verbesserung des Debuggings der Angular-Anwendung: Die Templates der Komponenten werden dabei im Stack Trace des Browsers nun mehr sichtbar, sodass Fehlermeldungen eindeutiger sind und sich das Debugging durch Breakpoints im Template einfacher gestaltet.
Kämpfte man sich dabei bisher durch einen sehr komplexen und verworrenen ViewEngine-generierten JavaScript Code, ist der gesamte Funktions-Aufruf Stacktrace dank der neu eingeführten API leichter lesbar und vor allem nachvollziehbar.
Sämtliche JavaScript-Fehlermeldungen, für die man bisher seine Browser-Developer-Tools öffnen musste, werden seit Angular 9 zusätzlich ebenso in der Kommandozeile sichtbar, nachdem man seine Angular-Applikation mit dem Befehl ng serve gestartet hat.
Mit Angular 9 und der neuen Rendering Engine wird also alles besser? Der eine oder andere wird sich womöglich noch die Frage stellen, wie es dies bezüglich um all die Abhängigkeiten einer Angular-Applikation steht. Viele Third-Party Libraries lassen sich dabei noch nicht mit Ivy kompilieren und erscheinen inkompatibel zu der neuen Engine. Aber auch hierfür hat das Angular-Team eine Lösung gefunden.
Eine radikale Umstellung der gesamten Rendering-Engine bringt natürlich vielerlei Herausforderungen mit sich. So mussten die Angular-Entwickler dafür sorgen, den Übergang so sanft wie möglich zu gestalten. Backwards-Kompatibilität war hierbei das Ziel, und so bietet Angular zwei unterschiedliche Compiler an:
- Ngtsc
- Ngcc
Der Ngtsc ist hierbei der bereits beschriebene Ivy Compiler, welcher den Angular-Code, der bereits in einem Ivy-kompatiblen Format vorliegt, kompiliert.
Interessant ist allerding der Ngcc, der sogenannte Angular-Kompatibilitäts-Compiler. Dieser sorgt dafür, das Libraries innerhalb des node_modules Ordners, welche nicht mit Ivy kompiliert wurden, so verändert werden, dass diese Ivy-kompatibel sind.
Beispielsweise wandelt er sämtliche Decorators (@Pipe, @Component, @NgModule etc) in die entsprechenden statischen Attribute: definePipe, defineComponent, defineDirective um.
Dies ermöglicht die Nutzung von “legacy” Projekten innerhalb eines mit Ivy-kompilierten Projekts.
Zusätzlich hat das Angular-Team mit dem ersten Release Candidate offiziell dazu aufgerufen, aktiv an der Kontribution von Ivy teilzunehmen. Das gesamte Team arbeitet daran, die Migration nach Ivy so schnell und vor allem, so stabil wie möglich zu gestalten.
So hat das Team mittlerweile ein Feature in die Angular-CLI eingebaut, welches erlaubt, Statistiken über die Nutzung und Kompilierung von Angular-Projekten zu erheben. Dies dient lediglich der Verbesserung von Ivy und Angular selbst.
Natürlich ist diese Erhebung vollkommen freiwillig und kann jederzeit auch wieder deaktiviert werden.
Das eigene Projekt auf Ivy zu migrieren, birgt sicherlich noch einige Hürden, die erst nach und nach von dem Team behoben werden können. Dabei hat unter den Angular-Entwicklern Ivy allerdings höchste Priorität. Umso wichtiger ist es, Probleme und Herausforderungen, auf die man während der Umstellung stößt, dem Angular-Projekt als ‘Issue’ zu melden.
Zusammenfassung und Ausblick
Wie wir gesehen haben, bringt uns die Umstellung auf die neue Rendering Engine eine Reihe von Vorteilen: Die Größe unseres Bundles, welches wir an den Web-Browser ausliefern, kann hierbei signifikant kleiner sein, als noch zu Zeiten der ViewEngine. Das liegt, wie wir gelernt haben, an der Möglichkeit, nicht benutzten Code zur Kreierung unserer HTML-Elemente nicht mitliefern zu müssen, da mit Hilfe von Ivy bereits zum Kompilierung-Zeitpunkt feststeht, welche Framework-Funktionen unsere Komponente benötigt, und welche nicht.
Dank der neuen API und den statischen Attributen zur Definition einer Komponente, einer Direktive oder einer Pipe müssen mit Ivy nun nicht mehr Abhängigkeiten von Abhängigkeiten innerhalb einer Komponente deklariert werden. Eine Komponente beinhaltet nur noch die Informationen ihrer direkten Abhängigkeiten und muss lediglich nur noch kompiliert werden, wenn sich etwas an der Komponente selbst verändert. Dieses sogenannte Lokalitätsprinzip ermöglicht das schnellere Rebuilden der Angular-Applikation, da nicht mehr das komplette Projekt global gebaut werden muss. Auch externe Bibliotheken können dabei bereits optimiert und vorkompiliert importiert werden.
Neben den verbesserten Debugging-Möglichkeiten haben wir auch den Angular Kompatibilitäts Compiler (ngcc) kennengelernt. Dieser hilft uns, Legacy-Code in unserem node_modules Ordner in ein für Ivy-lesbares Format zu überführen. Damit wird uns ermöglicht, selbst ältere Anwendungen mit dem neuen Renderer zu nutzen.
Während dieser Artikel nur eine kleine Übersicht der neuen Angular-Engine geben konnte, verbirgt sich hinter Ivy noch einiges mehr: Mit der neuen API kann man beispielsweise auch ganz einfach Komponenten zu jedem Zeitpunkt der Angular-Anwendung dynamisch erstellen und laden: Das Prinzip der sogenannten High Order Components.
Ebenso ist es vorstellbar, dass mit Ivy in Zukunft die Angular-Entwicklung vollkommen von dem Konzept der sogenannten Angular-Module losgelöst sein wird. Hierbei verweise ich auf die Artikel-Serie von Manfred Steyer “Architecture with Ivy: A possible future without Angular Modules”, welche weiterführend auf High Order Components und das Entwickeln ohne Angular-Module eingeht und dies erläutert.
Gratis E-Book Angular zum Download
Weitere Experten-Beiträge zu Angular finden Sie in unserem E-Book. Jetzt kostenlos downloaden.
- Angular-Apps mit der Ivy Rendering-Engine - 18. Juni 2020