Komplexe Webanwendungen mit Vue.js, Teil 1

Das JavaScript-Framework Vue.js ist nicht nur einfach erlernbar, sondern bietet auch ausgereifte Funktionen für die Umsetzung komplexer Webanwendungen.

Know-how Norbert Frank  –  1 Kommentare

Vue.js hat Erstaunliches geschafft. Vom ehemaligen Google-Mitarbeiter Evan You gestartet, hat sich das Web-Framework von einem Nischenprodukt zu einer Alternative auf Augenhöhe mit React und Angular entwickelt. Vue.js verbindet konzeptionell viel mit den genannten Frameworks, aber es interpretiert bewährte Konzepte auf eigene Weise und setzt individuelle Schwerpunkte.

Der Artikel "Vue.js: Zeitgemäße und wartbare JavaScript-Client-Anwendungen" hat das Framework vorgestellt und einen ersten Überblick gegeben. Dieser Artikel geht nun weiterführende Konzepte und Herangehensweisen für komplexe Anwendungen an. Denn – so viel sei vorweg gesagt – mit Vue.js lassen sich auch größere Projekte erfolgreich umsetzen.

Ein Projekt initialisieren

Entwickler können Vue.js als JavaScript-Bibliothek ohne weitere Abhängigkeiten direkt im Browser einsetzen. Für größere Projekte kommt jedoch üblicherweise ein webpack-Template zum Einsatz. Ein neues Projekt erstellt man am einfachsten mit dem Werkzeug Vue CLI und erhält ein nach Best Practices vorkonfiguriertes Projekt. Es hat vor kurzem ein größeres Upgrade auf Version 3.0 erhalten und ist nun flexibler als die vorhergehende Variante.

In einem frischen Projekt wird unterhalb des src-Ordners folgender Inhalt erzeugt:

Abbildung 1: Initiale Ordnerstruktur

Die Datei main.js ist der Einstiegspunkt in die Anwendung. Folgender Code initialisiert sie:

new Vue({
render: h => h(App)
}).$mount('#app')

new Vue erzeugt die Root-Instanz und übergibt ihr ein Konfigurationsobjekt. Darin ist eine Renderfunktion definiert, die die App-Komponente ausgibt. Die erstellte Root-Instanz wird dann an das DOM-Element mit der ID app gebunden. Diese Datei initialisiert und registriert in einer größeren Anwendung typischerweise noch andere Module, zum Beispiel den Router, Vuex-Stores und Plug-ins.

In der Single File Component (SFC) App.vue sind Template, Logik und CSS für die oberste Komponente in der Komponentenhierarchie definiert. In einer realen App steht hier häufig der Platzhalter für den Router. Zur Laufzeit ersetzt der Router den Platzhalter durch den Inhalt der aktuellen Seite.

<template> 
<div id="app">
<img src="./assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue' // 1.

export default { // 2.
name: 'app',
components: {
HelloWorld
}
}
</script>

<style>
#app {
/* .. Styling .. */
}
</style>
  1. Die Komponente HelloWorld wird explizit importiert. Alternativ könnte man sie global registrieren, aber das verringert die Übersichtlichkeit. Außer bei sehr generischen Komponenten sind explizite Importe zu bevorzugen.
  2. Das Konfigurationsobjekt der Komponente wird als JavaScript-Modul exportiert. Es konfiguriert alle Aspekte der Komponente. Das Beispiel vergibt nur einen Name und registriert HelloWorld.

Komponenten

Eine Vue.js-Anwendung ist aus einer Hierarchie von Komponenten aufgebaut. Für eine neue Anwendung müssen Entwickler früh entscheiden, wie sie die gewünschten Funktionen sinnvoll in diese unterteilen, ohne eine einzelne zu umfangreich anzulegen. Die Konfiguration der Komponente geschieht über ein Objekt, das innerhalb von SFCs als JavaScript-Modul exportiert wird. Außerhalb von SFCs kann man Komponenten per Vue.component registrieren.

Wie zuvor gezeigt, ist die oberste Komponente innerhalb der Hierarchie die explizit per new Vue initialisierte Vue-Instanz. Auch alle anderen Komponenten der Anwendung sind Vue-Instanzen, wobei sich bei mehrfacher Nutzung einer Komponente alle Auftritte eine Vue-Instanz teilen. Damit sie trotzdem einen privaten Zustand haben, initialisiert man die Properties über eine anonyme Funktion, die ein Objekt zurückgibt. Alle im Objekt definierten Attribute fügt Vue.js der Änderungsverfolgung (Change Detection) hinzu.

Um Änderungen zu überwachen, registriert Vue.js für alle Properties interne Getter- und Setter-Funktionen. Das ermöglicht es Vue.js, alle Änderungen effizient zu verfolgen, ohne einen Vorher-Nachher-Vergleich durchführen zu müssen (Dirty Checking). Das Prinzip hat aber zur Folge, dass es notwendig ist, alle benötigen Properties im Datenmodell vorab zu definieren, um sie automatisch zu erkennen. Möchte man zur Laufzeit Properties hinzufügen, kann man sie per Vue.set registrieren.

Die wichtigsten Definitionsangaben für eine Komponente sind:

  • data: anonyme Funktion, die die Datenattribute der Komponente als Objekt zurückgibt.
  • props: Angabe der Input-Parameter der Komponente. Optional ist die zusätzliche Definition des erwarteten Datentyps und eine Unterscheidung in optionale und Pflichtparameter möglich
  • components: die lokal zu verwendenden Komponenten. Global registrierte Komponenten muss man hier nicht erneut aufführen.
  • computed: Angabe von berechneten Funktionen. Sie laufen synchron ab und Vue.js hält sie automatisch aktuell.
  • watch: Watcher-Funktionen, die die Änderung eines überwachten Werts aufruft. Grundsätzlich sind Computed Properties zu bevorzugen, da sie gepuffert werden und performanter sind. Für Anwendungsfälle, in denen Computed Properties keine Verwendung finden können, beispielsweise bei asynchronen Aufrufen, sind Watch-Funktionen geeignet.
  • Lifecycle-Callbacks: Vue.js definiert mehrere Lifecycle-Callbacks, die entsprechende Lebenszyklen der Komponente hervorrufen: created, mounted, updated und weitere.
  • methods: beliebige eigene Funktionen.

Ein einfaches Beispiel:

export default {
name: "PercentageCounter",
data() {
return {
percentageCount: 0
}
},
props: {
initialPercentageCount: Number,
factor: {
type: Number,
required: true
}
},
computed: {
absoluteValue: function() {
if (this.percentageCount && this.factor)
return this.percentageCount * this.factor
return 0
}
},
watch: {
percentageCount: function(val) {
if (val && val === 100) {
this.setDone()
}
}
},
created() {
if (this.initialPercentageCount)
this.percentageCount = this.initialPercentageCount
},
methods: {
increase() {
this.percentageCount++
},
setDone() {
this.$emit("done", this.absoluteValue)
}
}
}

Zunächst initialisiert man die Eigenschaft percentageCount mit 0 und definiert daraufhin die beiden Props initialPercentageCount und factor. Beide erwarten den Datentyp Number, aber factor ist ein Pflichtwert. Die berechnete Eigenschaft absoluteValue berechnet das Produkt aus factor und percentageCount.

Weiterhin ist eine Watch-Methode für die Eigenschaft percentageCount definiert. Sie überprüft, ob der Wert 100 erreicht ist und ruft im positiven Fall die Methode setDone auf.

Der created-Callback übernimmt initialPercentageCount nach percentageCount, falls es übergeben wurde. Am Ende sind zwei eigene Methoden definiert: increase erhöht percentageCount und setDone gibt den berechneten Wert absoluteValue zurück.

Die Komponente können Entwickler im Template der übergeordneten Komponente beispielsweise wie folgt verwenden:

<PercentageCounter v-bind:initialPercentageCount="98" 
v-bind:factor="1.67" v-on:done="counterDone"/>

Alternativ sind v-bind und v-on durch eine kürzere Syntax ersetzbar:

<PercentageCounter :initialPercentageCount="98" 
:factor="1.67" @done="counterDone"/>

Formulare und Input-Binding

Vue.js unterstützt bidirektionales Databinding (two-way) mit der Direktive v-model. Sie kann ein Input-Control mit einem Attribut im Datenmodell verknüpfen. Vue.js sorgt dafür, dass sie synchron bleiben.

<input v-model="percentageCount" type="string">

Vue.js hat keine eingebauten Validatoren für Eingabefelder, aber es gibt ausgereifte Community-Varianten. Die bekanntesten sind Vuelidate und VeeValidate.

Zum Absenden eines HTML-Formulars wird das Submit-Event verwendet und eine eigene Funktion registriert. Der Modifier prevent verhindert das Auslösen der normalen Übertragung an den Server.

<form v-on:submit.prevent="mySubmitMethod"> 
<input v-model="percentageCount" type="string">
<button type="submit">Senden</button>
</form>

Nach diesem Prinzip ist jede Vue.js-Anwendung aus einer Hierarchie von Komponenten aufgebaut. Größere Anwendungen benötigen jedoch weitere Mechanismen, um Redundanzen zu vermeiden und die Anwendung langfristig wartbar zu halten.

Mixins und Extend

Wenn die Anwendung Teile der Komponentendefinition mehrfach benötigt, bietet es sich an, sie in Mixins auszulagern. Die betroffenen Komponenten importieren das Mixin mit der eigenen Definition "gemixt". Eine Komponente kann auch mehrere Mixins importieren. Das Mixin besteht – wie die Komponente – aus einem Konfigurationsobjekt mit dem gleichen Aufbau, ist aber im Gegensatz zu einer Komponente nicht lauffähig und kann eine beliebige Teilmenge einer Kompontendefinition enthalten:

export default {
created() {
console.log(this.$options.name + " created");
}
}

Das beispielhafte Mixin definiert eine Callback-Methode, die vom created-Event der Komponente aufgerufen wird. Wenn die Komponente ebenfalls eine created-Methode definiert, erfolgt deren Aufruf zusätzlich nach der Methode aus dem Mixin. this.$options.name greift auf den Namen der Komponente zu. Obwohl der Name im Mixin nicht definiert ist, funktioniert es fehlerfrei, da die Methode im Kontext der Komponente abläuft und auf deren volle Definition zugreifen kann.

Alternativ zu Mixins ist die Ableitung von Komponenten voneinander möglich. Eine Komponente gibt dazu in ihrer Definition über das Attribut extends an, von welcher Komponente sie abstammt, und erweitert oder überschreibt dann die gewünschten Bereiche.

Mehr Kontrolle über den Renderprozess

Mit den HTML-Templates der Vue-Komponenten und den integrierten Direktiven (v-if, v-for, v-bind, v-on und weitere) kann man im Alltag nahezu alle Anforderungen abdecken. Gelegentlich ist aber noch etwas mehr Kontrolle erforderlich. Vue.js bietet dafür die Möglichkeit, eigene Direktiven und Filter für den Einsatz in den HTML-Templates zu definieren.

Darüber hinaus gibt es noch Fälle, die sich mit HTML-Templates schwer oder überhaupt nicht umsetzen lassen. Dann kann man auf Renderfunktionen zurückgreifen und den Renderprozess mit eigenen JavaScript-Funktionen flexibel steuern.

Eigene Direktiven

Komponenten sind das Kernkonzept, um eine Anwendung in verschiedene Teile zu untergliedern. Abhängig von den Funktionen können Entwickler die Komponenten generisch und wiederverwendbar realisieren. Es gibt aber Anwendungsfälle, die mit reinen Komponenten nicht realisierbar sind. Das ist zum Beispiel der Fall, wenn man generische Funktionen realisieren möchte, die direkt mit den DOM-Elementen interagieren. Dafür dient in Vue.js das Anlegen eigener Direktiven.

Eigene Direktiven werden analog zu den integrierten Direktiven über v- gefolgt von der Bezeichnung an einem DOM-Element oder einer Komponente angegeben. Die Direktive kann über Callback-Methoden Zugriff auf das zugehörige Element erhalten und es modifizieren. Das erstmalige Binden einer Direktive an das Element ruft den Callback-Hook bind auf.

Das Validierungs-Plug-in VeeValidate nutzt eine Direktive, um die Validierungsregel an einem Input-Feld zu definieren:

<input v-validate="'required'" name="myinput" type="text">

Stark vereinfacht ist die Direktive wie folgt definiert:

export const Validate {
bind (el: HTMLElement, binding, vnode) {
/* Validator initialisieren.
binding erlaubt den Zugriff auf Argumente. */
},
update (el: HTMLElement, binding, vnode) {
/* Validator ausführen.
Jedes Update des zugehörigen Elements ruft den Callback auf. Man kann auf den alten und neuen
Wert von Parametern zugreifen. */
}
}

/* Global verfügbar machen – alternativ lassen sich Direktiven auch
innerhalb einer Komponente definieren. */
Vue.directive('validate', Validate)

Andere Beispiele von Direktiven verändern abhängig von Parametern die CSS-Attribute des Elements, fokussieren es oder registrieren Drag&Drop-Funktionen. Dynamische CSS-Anpassungen können Entwickler in den meisten Fällen allerdings auch ohne Direktiven über Class- und Style-Binding realisieren. Direktiven sind besonders geeignet, um generische Funktionen zu kapseln, die sich dann flexibel für verschiedene Komponenten und DOM-Elemente verwenden lassen.

Filter

Die Bezeichnung ist irreführend. Es geht nicht darum, Daten zu filtern, sondern sie für die Ausgabe zu formatieren. Vue.js enthält zwar keine mitgelieferten Filter, aber das Hinzufügen eigener Filter ist denkbar einfach.

Man kann zum Beispiel einen globalen Filter registrieren, der über die Intl-API des Browsers eine Zahl im landestypischen Format ausgibt:

Vue.filter('number', n =>  {
return new Intl.NumberFormat().format(n)
});

Den Filter kann man wie folgt anwenden:

<div>Ergebnis: {{someNumberValue | number}}</div>

Dies gibt dann für den deutschsprachigen Raum Zahlen mit Tausender-Trennzeichen und zwei Nachkommastellen aus. Die Ausgabe ist mit der Übergabe von Argumenten dynamisch steuerbar.

Renderfunktionen und JSX

Der Template-Compilier kompiliert die HTML-Templates von Vue.js automatisch zu Renderfunktionen. Unter der Haube arbeitet Vue.js daher vergleichbar zu React. Renderfunktionen gehören integral zu Vue.js und man kann sie auch alternativ zu HTML-Templates verwenden. Es ist sogar möglich, dafür JSX einzusetzen.

Das einfachste Beispiel einer Renderfunktion war zu Beginn des Artikels bei der Initialisierung der Anwendung zu sehen. Dort bestand die Renderfunktion lediglich aus der Ausgabe der App-Komponenten ohne die Definition weiterer Logik. Anstatt fertige Komponenten auszugeben, kann eine Renderfunktion auch beliebige HTML-Strukturen erzeugen.

Das folgende Beispiel gibt eine HTML-Liste aus:

export default {
name: "HelloList",
data() {
return {
languages: ["JavaScript", "Rust", "Java", "Python", "C#"]
}
},
render(h) {
return h("ul", this.languages.map(language => h("li", language)))
}
}

Die Renderfunktion empfängt per Argument eine Funktion, die üblicherweise mit h abgekürzt wird. Etwas sprechender wäre der Name createElement. Die Funktion kann eigene DOM-Elemente erzeugen. Sie erwartet als erstes Argument den Namen des HTML-Elements oder eine Vue-Komponente, optional weitere Daten und ein Array der Nachfahren des Elements.

Wie man am Beispiel sehen kann, benötigt man für Renderfunktionen keine Konstrukte wie v-if oder v-for, sondern kann es mit Standard-JavaScript-Mitteln ausdrücken.

Die gleiche Renderfunktion mit JSX sieht wie folgt aus:

render(h) {
return (
<ul>
{this.languages.map((item) => {
return <li>{item}</li>
})}
</ul>
)
}

Renderfunktionen sind ein mächtiges Werkzeug für komplexe Anforderungen. Die grundsätzliche Empfehlung lautet, sie nur einzusetzen, wenn HTML-Templates an ihre Grenzen kommen.

Funktionale Komponenten

Obwohl Vue.js grundsätzlich sehr performant ist, belegt jede zusätzliche Komponente Speicherressourcen. Nicht jede Komponente benötigt aber eine eigene Vue-Instanz und Change Detection. Der Einsatz funktionaler Komponenten kann die Ressourcen dafür sparen.

Zur Laufzeit erhalten funktionale Komponenten keine Vue-Instanz. Vielmehr verhalten sich derartige Komponenten als wären sie Teil der übergeordneten Komponente. Funktionale Komponenten eignen sich daher besonders für einfache und generische UI-Bausteine wie Buttons, Listenelemente oder Dropdown-Controls. Ein praktisches Beispiel einer funktionalen Komponente ist der Platzhalter für Routing-Views <router-view/>.

Man kann funktionale Komponenten durch das Ergänzen des Attributs functional im Template-Tag mit einem Template erstellen. Häufig realisieren Entwickler funktionale Komponenten allerdings mit eigenen Renderfunktionen.

Eine funktionale Komponente hat keine eigene Instanz und daher auch keinen this-Kontext. Um auf Properties, Ereignishandler und andere Definitionen zuzugreifen, erhält eine funktionale Komponente ein Objekt context.

Eine beispielhafte Renderfunktion ist als funktionale Komponente wie folgt aufgebaut:

export default {
name: 'HelloList',
functional: true,
props: {
languages: Array
},
render(h, context) {
return h("ul", context.props.languages.map(language => h("li", language)))
}
}

Die Renderfunktion erhält das Objekt context als zweites Attribut und der Zugriff auf das Array erfolgt nun nicht mit this.languages, sondern mit context.props.languages.

Slots und Scoped Slots

In größeren Anwendungen ist es immer eine Herausforderung, Redundanz zu vermeiden. Man sollte daher anstreben, möglichst generische Komponenten zu entwickeln. Ein wichtiges Werkzeug dafür sind Slots. In ihrer einfachen Form sind Slots Platzhalter innerhalb des Templates einer Komponente. Die übergeordnete Komponente kann sie mit Inhalt füllen. So kann zum Beispiel eine Card-Component den eigentlichen Inhalt als Slot definieren:

<template>
<div class="card">
<slot/>
</div>
</template>
In der Komponente definiert man der Inhalt innerhalb des Elements:
<card>
<h2>Vue.js</h2>
<p>The Progressive JavaScript Framework</p>
</card>

Es ist auch möglich, mehrere Slots zu definieren. Sie müssen dann aber unterschiedliche Namen tragen.

Die Parent-Komponente definiert den Inhalt der Slots und läuft im Kontext der Komponente ab. Mit einem Slot-Scope ist der Datenaustausch zwischen den beiden Komponenten möglich. Das folgende Beispiel übergibt eine Referenz auf eine Funktion (close) und einen String an den Slot-Scope (slogan):

<template>
<div class="card">
<slot :closeFunction="close"
slogan="The Progressive JavaScript Framework"/>
</div>
</template>

Die übergeordnete Komponente kann nun darauf zugreifen, indem über das Attribut *slot-scope* ein Name für den Scope vergeben wird:

<card>
<template slot-scope="cardScope">
<h2>Vue.js</h2>
<p>{{ cardScope.slogan }}</p>
<button @click="cardScope.closeFunction()">Close</button>
</template>
</card>

Die Komponente kann nun über cardScope die close-Funktion innerhalb der Card-Komponente aufrufen und den Slogan aus ihr empfangen. Scoped Slots ermöglichen elegante generische Komponenten.

Fazit

Besonders für Einsteiger ist Vue.js ein zugängliches Framework. Das steht in keinem Widerspruch dazu, dass Vue.js auch für große und komplexe Anwendungen geeignet ist.

Die API des Frameworks ist stabil und die Weiterentwicklung sehr sorgfältig geplant. Das reduziert den Migrationsaufwand für technische Upgrades auf ein Minimum, was insbesondere für umfangreichere Anwendungen ein großer Mehrwert ist. Hinter dem Framework steht kein Technologiekonzern, aber die Zukunftsaussichten sind gut. Die Community kann das Framework frei von Konzerninteressen unabhängig weiterentwicken.

Im zweiten Teil der Serie geht es um State Management mit Vuex und den Einsatz des Vue-Routers. (bbo)

Norbert Frank
ist seit 15 Jahren in der IT als Entwickler, Berater und Softwarearchitekt tätig. Er ist passionierter Full-Stack-Entwickler und JavaScript-Enthusiast bei der Lucom GmbH.