Angular-Renaissance Teil 3: Die erweiterte Template-Syntax

Angular 17 hat die Template-Syntax erweitert und ebnet damit den Weg für die zukünftige Signalkomponente. Der Artikel zeigt, wie man die Syntax verwendet.

In Pocket speichern vorlesen Druckansicht

(Bild: iX)

Lesezeit: 10 Min.
Von
  • Rainer Hahnekamp
Inhaltsverzeichnis

Der dritte und letzte Teil in der Angular-Renaissance-Artikelserie widmet sich der erweiterten Template-Syntax, die seit Angular 17.0 als Developer Preview vorliegt. Dieser Neuzugang mag im Vergleich zur Hydration und Signalen unspektakulär erscheinen.

Ein Teilbereich davon sind die Deferrable Views – eine Innovation, mit der Angular eine Pionierrolle einnimmt. Code Splitting teilt die Anwendung in mehrere JavaScript-Dateien auf, wodurch nicht alles sofort geladen wird. Diese Technik ist nichts Neues, doch die Möglichkeit durch Deferrable Views, Code Splitting direkt im HTML und sogar nach verschiedenen Kriterien zu definieren, sucht ihresgleichen. In Angular 18 haben die Deferrable Views in dieser Woche den Preview-Modus verlassen.

Rainer Hahnekamp

Rainer Hahnekamp ist Trainer und Berater im Expertennetzwerk von AngularArchitects.io und dort für Schulungen rund um Angular verantwortlich. Darüber hinaus gibt er mit ng-news auf YouTube einen wöchentlichen Kurzüberblick über relevante Ereignisse im Angular-Umfeld.

Angular-Renaissance – dreiteilige Serie

Angular hat sich immer sehr stark an den HTML-Standard angelehnt und ist nur in Ausnahmefällen davon abgewichen. Mit Angular 17 ist es mit dieser Konformität allerdings vorbei.

Template Blocks, wie die erweiterte Syntax auch genannt wird, ersetzen die Structural Directives *ngIf, *ngFor und *ngSwitch durch @if, @for sowie @switch. Das vereinfacht zum einen die Syntax, andererseits würden Structural Directives bei den kommenden Signalkomponenten (siehe Teil 2) aufgrund interner technischer Gründe auch nicht mehr funktionieren.

Mit den Anweisungen @if, @for und @switch, die sich mit dem HTML vermischen, ist der Kontrollfluss direkt eingebettet. Kontrollstrukturen liegen also jetzt außerhalb der Tags.

So sieht beispielsweise ein Template vor Angular 17 aus:

<ul>
  <ng-container *ngFor="let country of countries">
    <li *ngIf="country.locale === filteredLocale">{{ country.name }}</li>
  </ng-container>
</ul>

Mit der neuen Template-Syntax ergibt sich nun folgender Code:

<ul>
  @for (country of countries; track country.name) {
    @if (country.locale === filteredLocale) {
      <li>{{ country.name }}</li>
    }
  }
</ul>

Das Template einer Komponente besteht somit aus einer Mischung von Programmcode und HTML. Ähnlichkeiten zur JavaScript-Syntaxerweiterung JSX, wie man sie von React her kennt, sind durchaus gewollt.

Die Wahl des @-Zeichens ist vom Framework Svelte inspiriert. Hier hat sich die Community durchgesetzt, denn das Angular-Team hatte ursprünglich eine andere Syntax im Sinn.

Bei dieser Änderung ist ein wesentlicher Punkt die Frage nach dem Wieso. Es mag sein, dass die neue Syntax ein wenig benutzerfreundlicher wirkt, muss man aber deswegen nun wieder seinen kompletten Code durchforsten und ändern? Die Gründe liegen ganz woanders: Die Syntaxänderung ist primär eine Weichenstellung für die zukünftige Signalkomponente.

Das Angular-Team hatte bereits weit vor Version 16 – und selbst bevor es die Signale ankündigte – Prototypen und Pläne für die Signalkomponente in der Schublade. Dabei dürfte sich offenbar herausgestellt haben, dass die Implementierung der aktuellen Structural Directives eine Nachbesserung benötigte, um in den Signalkomponenten laufen zu können.

Ferner war das Angular-Team mit der technischen Umsetzung dieser Directives nicht sehr glücklich. Zum Beispiel lässt die Developer Experience bei einem *ngIf zu wünschen übrig. Ein einfaches else ist syntaktisch schon eine kleine Herausforderung, und ein elseif gab es sowieso nie.

Dazu kommt, dass die Kontrollflusskommandos immer explizit importiert sein müssen. Nativ unterstützt das Template diese Funktionalität nicht. Bei modernen Frameworks gehört dies aber mittlerweile zum Standard. Eine native Unterstützung hätte auch Vorteile hinsichtlich der Performance, da der Angular-Compiler sich darum kümmern würde.

Es gibt also gewichtige Gründe für die erweiterte Syntax und dafür, dass Angular-Developer diese auch so bald wie möglich einsetzen.

Beispielsweise sind bei dem neuen @If mehrere Verzweigungen kein Problem:

<ul>
    @for (country of countries;track country.name) {
        @if (country.name === 'Austria') {
            <li>Österreich</li>
        } @else if (country.locale === filteredLocale) {
            <li>{{ country.name }}</li>
        } @else {
            <li class="unfiltered">{{ country.name }}</li>
        }
    }
</ul>

Möchte man nun so schnell wie möglich zur Tat schreiten, muss man bei bestehendem Code die einzelnen Templates durchforsten. Hier hilft es, dass Google mehrere Tausend Angular-Anwendungen intern betreibt und das Angular-Team auch den hausinternen Support leistet. Eine der internen Regeln besagt, dass dieser bei Breaking Changes das entsprechende Update durchführen muss. Das ist natürlich bei einer derartigen Menge manuell unmöglich, wodurch bei Angular standardmäßig automatische Skripts mitlaufen, die den Quellcode der Anwendungen umschreiben.

Die Structural Directives sind nicht als veraltet (deprecated) markiert und ein Umstieg somit nicht zwingend. Dennoch gibt es ein Skript, das die Migration automatisch durchführt. Dafür reicht die Ausführung des Befehls

ng g @angular/core:control-flow

Die Extended-Template-Syntax befindet sich noch in der Developer Preview. Das Angular-Team sieht jedoch grundsätzlich keine Instabilitäten oder Risiken. Als einer der Hauptgründe für den Status als Developer Preview gilt ein neuer Algorithmus in @for, der das Rendering beschleunigt.

Deferred Loading ist das "Star Feature" in Angular 17.0. Es macht von den neuen Möglichkeiten des Templates Gebrauch und erlaubt, deklarativ und direkt im Template Komponenten als lazy loading zu definieren.

Zudem bietet es Kriterien an, wann diese Komponente einerseits geladen und andererseits aktiviert werden soll.

Der vielseitige Katalog an Kriterien schließt ein, dass beispielsweise Angular mit dem Laden beginnt, wenn der Platzhalter oder ein DOM-Element im Viewport, also sichtbar, auftaucht. Andere Kriterien sind ein Klick, ein Maus-Hover, der Ablauf einer voreingestellten Zeitspanne oder eine Funktion, bei der die Logik individuell programmierbar ist.

Die Komponente und ihre Abhängigkeiten in @defer müssen standalone sein. Container für @defer kann jedoch eine NgModule-deklarierte Komponente sein.

Es folgt ein einfaches Beispiel zum Einstimmen.

Darin wird über eine Liste von Holidays iteriert, wobei jedes einzelne Element über eine UI-Komponente gerendert wird. Die Liste rendert jedoch nur dann, wenn die Benutzer zuvor die Allgemeinen Geschäftsbedingungen (AGB) per Klick auf einen Button bestätigt haben.

<h2>Choose among our Holidays</h2>
<button>I hereby agree to the terms</button>
<div class="flex flex-wrap justify-evenly">
  @for (holiday of holidays(); track holiday.id) {
    <app-holiday-card
      [holiday]="holiday"
      (addFavourite)="addFavourite($event)"
      (removeFavourite)="removeFavourite($event)" />
  }
</div>

Dies erreicht man durch ein Hinzufügen des neuen @defer-Blocks. In der Konsole zeigt sich dann sogleich, dass der Compiler eine neue JavaScript-Datei erstellt hat, die den Inhalt von @defer samt Abhängigkeiten enthält.

Beim Aufruf der Seite zeigt Angular somit nur den Button an. Durch die Hinzugabe der Bedingung (on interaction(button)) löst erst der Klick auf den Button den Ladevorgang aus.

Wie der Screenshot zeigt, entfallen sofort 15 KByte. Bei einer Gesamtgröße von 35 KByte für das main-Bundle ist das für den Aufwand ein deutlicher Gewinn.

<h2>Choose among our Holidays</h2>
<button mat-raised-button #button>I hereby agree to the terms</button>
<div class="flex flex-wrap justify-evenly">
  @defer (on interaction(button)) {
    @for (holiday of holidays(); track byId($index, holiday)) {
      <app-holiday-card
        [holiday]="holiday"
        (addFavourite)="addFavourite($event)"
        (removeFavourite)="removeFavourite($event)" />
    }
  }
</div>

Aufteilung des JavaScript-Bundles mit @defer

(Bild: Rainer Hahnekamp)

Wie oben angeführt, gibt es mehrere Kriterien. Durch den Austausch von on interaction durch etwa on viewport lassen sich die verschiedenen Arten sehr einfach ausprobieren. Das Kombinieren unterschiedlicher Kriterien ist ebenfalls möglich.

In der Praxis wird man jedoch @defer mit der Erweiterung @placeholder verwenden. Anstatt also den Benutzern eine leere Fläche zu zeigen, kann man eine alternative Komponente oder einen Text darstellen.

Neben dem Placeholder gibt es die Möglichkeit, für den Ladeprozess einen separaten Block zu definieren. Ladeprozess meint die Zeitspanne, bis der Browser die Bundle-Datei geladen hat.

Sollte sich die geladene Komponente von sich aus mit dem Backend verbinden und Daten abfragen, fällt das nicht unter den Ladeprozess, da die Komponente bereits aktiv ist.

Daher wird eher @placeholder eine Rolle spielen und Benutzer mit langsamer Bandbreite werden den Block innerhalb von @loading zu Gesicht bekommen:

<h2>Choose among our Holidays</h2>

@defer (on interaction; on timer(10s)) {
  <div class="flex flex-wrap justify-evenly">
    @for (holiday of holidays(); track byId($index, holiday)) {
      <app-holiday-card
        [holiday]="holiday"
        (addFavourite)="addFavourite($event)"
        (removeFavourite)="removeFavourite($event)"
      />
    }
  </div>
} @placeholder {
  <button mat-raised-button>Show me the holidays.</button>
} @loading {
  <p>Loading Holidays...</p>
}

Ein wenig hat sich der Anwendungsfall nun verändert. Der Button ist in den @placeholder gewandert, da dieser Bereich ohnehin immer angezeigt wird. Standardmäßig gilt bei Vorhandensein des Platzhalters auch dieser als Referenz für die Kriterien viewport, interaction etc. Deswegen ist auch die Template-Variable (#button) nicht mehr notwendig.

Ferner gibt es ein zweites Kriterium. Der Ladevorgang benötigt zwar weiterhin den Klick, sollte dies jedoch nicht innerhalb von zehn Sekunden erfolgen, lädt Angular die Holidays automatisch nach.

Um den Effekt von @loading zu sehen, lohnt es sich, über die Browsertools die Netzwerkgeschwindigkeit zu drosseln. Ansonsten vernimmt man nur einen kurzen "Flickereffekt" zwischen der Platzhalter- und der eigentlichen Anzeige. Mit langsamem Netzwerk sieht man für die Dauer des Ladens den Text "Loading Holidays".

Es ist jedoch noch mehr möglich: @loading bietet eine Mindestdauer für die Ladezeit an. Erst wenn der Browser diese überschreitet, aktiviert sich der @loading-Block. Um den Flickereffekt zu verhindern, kann das Loading – falls es überhaupt angezeigt wird – für eine definierbare Zeitspanne sichtbar bleiben, auch wenn in der Zwischenzeit Angular die Komponente vollständig geladen hat.

Eine weitere Möglichkeit ist die prefetch-Option. Damit würde Angular bereits vorzeitig mit dem Laden des Bundles starten, aber auf die Aktivierung warten.

Im folgenden Beispiel sieht man sowohl den Einsatz des Prefetching als auch des parametrisierten @loading:

<h2>Choose among our Holidays</h2>

@defer (on interaction; on timer(10s); prefetch on viewport) {
  <div class="flex flex-wrap justify-evenly">
    @for (holiday of holidays(); track byId($index, holiday)) {
      <app-holiday-card
        [holiday]="holiday"
        (addFavourite)="addFavourite($event)"
        (removeFavourite)="removeFavourite($event)"
      />
    }
  </div>
} @placeholder {
  <button mat-raised-button>Show me the holidays.</button>
} @loading (after 100ms; minimum 1s) {
  <p>Loading Holidays...</p>
}

Angular beginnt hier mit vorzeitigem Laden, sobald der Platzhalter in den Viewport kommt. Die Komponente aktiviert sich jedoch erst nach zehn Sekunden oder wenn die Benutzer auf den Button klicken. Sollte bis dahin das Laden noch nicht im Gange sein, wird es spätestens dann gestartet.

Besteht eine langsame Internetverbindung und das Herunterladen der 15 KByte dauert länger als 100 Millisekunden, erscheint für den Benutzer minimal eine Sekunde lang die Ladeanzeige. Sollte der Ladevorgang länger dauern, bleibt die Ladeanzeige natürlich erhalten.

Trotz der vielen Regeln in diesem Beispiel bleibt der finale Code sehr gut leserlich.

@deferred ist nur der Anfang. Die Möglichkeit, ehemals komplizierte Features per deklarativem Code sehr einfach zu gestalten, zeigt das Potenzial auf.

So verkündete etwa Jeremy Elbourn, Tech Lead des kompletten Angular-Projekts (Framework, CLI, Material), dass es durchaus möglich wäre, Serverkomponenten, wie man sie von React kennt, zu integrieren.

Anstatt eines @defer würde man einen Block beispielsweise als @server markieren. Dieser würde dann ausschließlich am Server rendern und bis auf das statische HTML würde der Serverprozess kein JavaScript an den Browser schicken.

Die aktuell in sozialen Medien zu findende Behauptung, dass "ein wenig Bewegung in Angular gekommen ist", ist untertrieben, denn Angular erfindet sich derzeit neu und schafft es dennoch, abwärtskompatibel zu sein. Wie in dieser Artikelserie erläutert, brachten die Versionen von Angular 17 neue und nützliche Features, sind aber auch Vorbereitungen für kommende Releases in diesem Jahr – darunter das am 23. Mai 2024 vorgestellte Angular 18.

Hydration und Signale sind in ihren Grundfunktionen bereits vorhanden und stabil. Die Hydration soll laut dem Entwicklungsteam zur technisch fortgeschrittensten Progressive Hydration werden. Signale übernehmen immer mehr Funktionen, wie man etwa mit den Signal Inputs bereits sehen kann. Das Meisterstück werden schlussendlich die Signalkomponenten sein, die performancetechnisch auf höchstem Niveau laufen werden.

(mai)