Komplexe Webanwendungen mit Vue.js, Teil 2

Die beiden zusätzlichen Bibliotheken Vuex und Vue Router ergänzen Vue.js um State Management à la Flux und einen mächtigen Router.

Know-how  –  0 Kommentare
Komplexe Webanwendungen mit Vue.js, Teil 2

Zum Grundprinzip von Vue.js gehört es, dass im Framework nur die zentralen Funktionen gebündelt sind. Dem Prinzip folgend, sind State Management und Routing ausgelagert, da nicht jede Anwendung sie braucht. Für kleinere Anwendungen kann es ausreichend sein, dass die Komponenten untereinander Daten und Ereignisse austauschen. Schnell kommt das Konzept aber an seine Grenzen, weshalb eine größere Antwort gefordert ist.

Komponentenkommunikation

Obwohl es mit der Vue.js-API möglich ist, auf die übergeordnete Komponenten direkt zuzugreifen (über this.$parent), sollte man es vermeiden. Die Kommunikation über Props und Events ist sinnvoller. Props geben Daten in eine Komponente hinein, und per Events kann die Komponente Daten und Ereignisse zurückgeben. Wie in Teil 1 der Serie gezeigt, behandelt Vue.js Events einer Child-Component dann analog zu Standard-DOM-Events und registriert einen Handler.

Diese Art der Kommunikation ermöglicht aber nur den Austausch von Daten entlang der Komponentenhierarchie. Sie ist bei einem großen Komponentenbaum schnell unübersichtlich. Eine häufig anzutreffende Alternative ist der Einsatz eines Event-Bus. Dazu erzeugt man eine Vue-Instanz und nutzt deren Funktionen zur Registrierung von Event-Handlern und Veröffentlichung von Ereignissen. Alle betroffenen Komponenten importieren die Instanz, registrieren ihre Event-Handler und veröffentlichen eigene Ereignisse. In folgendem Beispiel wird eine Vue-Instanz erzeugt und exportiert:

export const EventBus = new Vue()

Eine anderen Komponente importiert die Instanz und registriert eine Callback-Funktion für das Ereignis someEvent:

import { EventBus } from './main'

export default {
created() {
EventBus.$on('someEvent',someValue => {
console.log(`Event 'someEvent' called with value '${someValue}'`)
})
}
}

An beliebiger anderer Stelle der Anwendung kann nun der Event-Bus ein Ereignis veröffentlichen:

EventBus.$emit('someEvent','myCurrentValue')

Vue.js ruft alle Callback-Funktionen, die auf dem Event-Bus registriert sind, auf und übergibt ihnen den Wert als Parameter. Im Beispiel führt das zur Ausgabe:

Event 'someEvent' called with value 'myCurrentValue'.

Solange eine Anwendung nur das Teilen weniger Zustandsinformationen erfordert, funktioniert der Ansatz gut. Bei komplexen Anwendungen kommt er an seine Grenzen. Um den lokalen Zustand diverser Komponenten synchron zu halten, wären je nach Anwendungsfall viele Ereignisse notwendig. Das sorgt für Unübersichtlichkeit in den Kommunikationsbeziehungen und gestaltet die Fehlersuche schwierig. Eine Alternative dafür ist der Einsatz eines zentralen State Management mit Vuex.

State Management mit Vuex

Die Grundidee hinter State Management Patterns ist das zentrale Verwalten von Zustandsinformationen in der Anwendung. Anstatt dass verschiedene Komponenten einen Teil der Daten redundant besitzen, gibt es nur noch einen Ort, auf den alle Teile der Anwendung lesend zugreifen können. Die Veränderung der Daten ist durch ein definiertes Vorgehen klar geregelt.

Das hat sich für komplexe Anwendungen bewährt. Es bedeutet zwar initial mehr Aufwand und erfordert das Erlernen neuer Konzepte, aber dafür ist die Anwendung unabhängig von ihrer Größe klar strukturiert und leicht verständlich. Es gibt nur eine zentrale Wahrheit über den Zustand der Applikation. Das vereinfacht die Fehlersuche. Da Zustandsänderungen nur über eine Schnittstelle der State Management Library möglich sind, können Debugging-Tools jede Veränderung aufzeichnen. Es ist genau nachvollziehbar, welche Veränderungen in welcher Reihenfolge erfolgt sind.

Zusätzlich zu den genannten Vorteilen führt der Einsatz einer State Management Library zu schlankeren Komponenten. Typischerweise befasst sich ein erheblicher Teil der Logik mit der Veränderung des Zustands. Den überwiegenden Teil können Entwickler aus den Komponenten in das State Management übernehmen. Die Komponenten sorgen lediglich für das Anstoßen der Veränderungen. Sie selbst bleiben dadurch kurz, gut lesbar und sind von der zentralen Logik entkoppelt. Es gibt also gute Gründe, für größere Anwendungen ein zentrales State Management einzuführen.

Für Vue.js ist die Standard-Implementierung Vuex. Sie ist eine vom Vue-Core-Team entwickelte, separat zu installierende Bibliothek. Sie implementiert das Flux-Pattern und überträgt es auf die Vue-Konzepte. Im Gegensatz zu Redux nutzt Vuex daher nicht das Prinzip von Immutability, da die Änderungsverfolgung von Vue.js nicht darauf basiert. Immutablility besagt, dass das Verändern von Objekten nicht erlaubt ist und Änderungen ein neues Objekt erfordern. Es ist zwar denkbar, andere State-Management-Bibliotheken mit Vue.js einzusetzen, aber Vuex ist die erste Wahl und am besten integriert.

Das Anlegen eines Vue.js-Projekts mit Vuex per CLI erzeugt die Datei store.js:

import Vue from "vue";
import Vuex from "vuex";

Vue.use(Vuex);

export default new Vuex.Store({
state: {},
mutations: {},
actions: {}
});

Vue.use gibt Vuex als Plug-in bekannt. Es folgt die Definition des Store, der im Beispiel nur drei leere Objekte für state, mutations und actions enthält. Um den Store nicht in allen Komponenten einzeln importieren zu müssen, können Entwickler ihn in main.js dem Vue-Konstruktor übergeben. Er steht dann automatisch in den Komponenten unter this.$store zur Verfügung.

Vuex-State

Den Zustand der Anwendung legt man im State-Objekt im Store ab. Es ist möglich, das Objekt beliebig tief zu schachteln. Es gibt nur einen Store und nur ein State-Objekt in einer Anwendung, aber Vuex unterstützt auch eine Modularisierung des State-Objekts. Der folgende Code zeigt ein einfaches Beispiel für ein State-Objekt:

{
languages: [
{ name: "JavaScript", created: 1995 },
{ name: "Rust", created: 2010 },
{ name: "Java", created: 1995 },
{ name: "Python", created: 1991 }
]
}

Der Zugriff innerhalb einer Komponente funktioniert so:

export default {
computed: {
languages() {
return this.$store.state.languages
}
}
}

Er erfolgt innerhalb einer Computed Property, damit Vue.js den Wert bei Veränderungen aktuell hält. Dadurch können alle benötigten Teile des globalen Zustands in die Komponente übertragen und in den Templates wie jedes lokale Attribut verwendet werden.

Wenn die Zustandsinformationen als Basis für weitere Operationen dienen, könnte das lokal innerhalb einer weiteren Computed Property erfolgen. Da jedoch weitere Komponenten die Information ebenfalls benötigen könnten, ist es besser, die Berechnungslogik als Teil des Stores zu realisieren. In Vuex funktioniert das mit Getter-Funktionen.

Im Beispiel könnte eine Liste der Programmiersprachen, sortiert nach Veröffentlichungsjahr, interessant sein:

export default new Vuex.Store({
state: {
// ...
},
getters: {
languagesByYear: state => {
return state.languages.sort(
(lang1, lang2) => lang1.created - lang2.created
);
}
}
});

Die Referenzierung der Getter-Funktionen innerhalb der Komponenten erfolgt über das getters-Attribut:

computed: {
languagesByYear() {
return this.$store.getters.languagesByYear
}
}

Getter-Funktionen können auch Parameter erhalten. Um zum Beispiel dynamisch nur die Sprachen ab einem bestimmten Jahr zurückzugeben, ist folgende Variante denkbar:

getters: {
languagesByYear: state => fromYear => {
let languagesFromYear = fromYear
? state.languages.filter(lang => lang.created >= fromYear)
: state.languages;
return languagesFromYear.sort(
(lang1, lang2) => lang1.created - lang2.created
);
}
}

Anstatt direkt ein Ergebnis zurückzugeben, gibt die Getter-Funktion eine Funktion zurück. Daher muss der Zugriff in der Komponente als Funktionsaufruf mit dem Parameter erfolgen:

this.$store.getters.languagesByYear(1992)

Vuex Actions und Mutations

Jede Zustandsveränderung erfolgt innerhalb einer dedizierten Funktion. Vuex nennt sie Mutations. Eine Mutations-Funktion erhält ein Zustandsobjekt und gibt es verändert zurück. Im Beispiel kann man eine weitere Programmiersprache so hinzufügen:

export default new Vuex.Store({
state: {
// ...
},
mutations: {
addLanguage (state, language) {
state.languages.push(language)
}
}
})

Mutations-Funktionen müssen immer synchron sein. Folgende Zeile löst sie mittels commit aus:

this.$store.commit("addLanguage", { name: "Kotlin", created: 2016 })

Wenn an der Veränderung ein asynchroner Aufruf beteiligt ist, kommen sogenannte Actions zum Einsatz. Anstatt den Zustand direkt zu verändern, können sie beliebige asynchrone Logik enthalten und nach deren Durchlauf eine Mutations-Funktion aufrufen:

export default new Vuex.Store({
state: {
// ...
},
mutations: {
// ...
},
actions: {
addLanguage(context, language) {
Promise.resolve().then(() => {
context.commit("addLanguage", language);
});
}
}
});

Actions können auch mehrere verschiedene Mutationsfunktionen aufrufen. In der Praxis könnte man zunächst ein Flag setzen, um eine Ladeanimation anzuzeigen, bevor der asynchrone Aufruf stattfindet. Kommt ein Ergebnis zurück, blendet die Anwendung die Animation wieder aus und verarbeitet das Ergebnis. Tritt ein Fehler auf, hinterlegt eine Mutations-Funktion den Zustand ebenfalls im Store. Actions enthalten also häufig mehrere Verarbeitungsschritte inklusive Fehlerbehandlung, und Mutationsfunktionen überführen die Änderungen am Zustand in den Store.

Der Aufruf in der Komponente sieht nun so aus:

this.$store.dispatch("addLanguage", { name: "Kotlin", created: 2016 })

Vuex in der Praxis

Vuex bietet für den Einsatz in großen Anwendungen weitere Vereinfachungen. Es gibt mehrere Hilfsfunktionen, die den Zugriff in den Komponenten auf den Store vereinfachen können, anstatt jeweils eine Computed Property anzugeben. Sie ändern jedoch nichts an der grundlegenden Funktionsweise, weshalb der Artikel nur die vollständige Schreibweise aufführt.

Für große Anwendungen ist es weiterhin sinnvoll, den State-Tree in Module zu unterteilen. Jedes Modul kapselt einen Teil des States und enthält eigene Getter, Actions und Mutations. Man kann je Modul entscheiden, ob es einen eigenen Namensraum definiert. Ohne Namensraum übernimmt der globale Store alle Inhalte, als hätte man sie dort definiert.

Zu Beginn eines Projekts sollten Entwickler den Einsatz von Vuex im Detail festlegen. Zum Beispiel ist zu entscheiden, ob der Aufruf von Mutations aus Komponenten heraus erlaubt ist oder grundsätzlich Actions verwendet werden sollen. Weiterhin ist zu entscheiden, wie mit Formularinhalten umzugehen ist.

Scheint ein Anwendungsfall nicht mit Vuex lösbar zu sein, liegt es typischerweise an einer falschen Herangehensweise. Das Flux-Pattern erfolgreich einzusetzen erfordert ein Umdenken in Bezug auf den inneren Aufbau einer Anwendung. Richtig angewendet, belohnt es Entwickler mit einer stabilen, skalierbaren und langlebigen Softwarearchitektur.

Routing

Ähnlich wie Vuex ist der Vue-Router eine separat zu installierende Bibliothek. Das Vue-Core-Team übernimmt auch bei ihr Entwicklung und Wartung. Da Routing aber nicht so tief in die interne Architektur eines Frameworks integriert ist, lassen sich problemlos auch andere Routing-Libraries einsetzen. Es gibt vielfältige Community-Alternativen.

Der Vue-Router unterstützt alles, was man von einer modernen Routing-Library erwartet: dynamische Parameter, verschachtelte Routen, eine vollständige API sowie Ereignisse und Berechtigungsprüfungen in Form von Navigation Guards. Auch asynchrones Laden von Routen (Lazy Loading) und die Zwischenspeicherung von Scroll-Positionen sind damit umsetzbar.

Der Router ist als Vue-Plug-in realisiert. Vue konfiguriert ihn wie üblich über ein Objekt. Der wichtigste Parameter des Konfigurationsobjekts ist das Route-Array. Es bildet Routen auf Komponenten ab und konfiguriert alle weiteren Details jeder Route.

Ein ganz einfaches Beispiel sieht so aus:

import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import HelloWorld from './views/HelloWorld.vue'

Vue.use(Router)

export default new Router({
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/hello/:id',
name: 'hello',
component: HelloWorld
}
]
})

Es sind zwei Routen definiert. Die zweite Route enthält den dynamischen Parameter id. Mittels $route.params.id kann auf dessen Wert in der Komponente zugegriffen werden. Alternativ setzt man das Attribut props innerhalb der Routen-Konfiguration auf true. Dadurch übergibt Vue die Routen-Parameter als normale Properties an die Komponente, sodass die Komponente nicht mehr an den Router gekoppelt ist.

Das HTML-Template verwendet einen Platzhalter, an dessen Stelle die Komponente der aktuellen Route gerendert wird: <router-view/>. Weiterhin kann man Links auf andere Seiten über <router-link to="/home">Home</router-link> definieren oder über die API in eigenen Funktionen navigieren. Eine Seite kann mehrere Platzhalter enthalten, die unterschiedliche Namen benötigen. In der Route sind dann ebenfalls mehrere Komponenten definiert.

Möchte man vor der Navigation Aktionen ausführen, zum Beispiel Berechtigungen überprüfen, Daten laden oder prüfen, ob die Session noch gültig ist, kann man das im beforeEnter-Hook ausführen. Als Parameter erhält man Informationen zur aktuellen Route, zur Zielroute und einen Callback, um die Navigation auszuführen. Dadurch ist es möglich, asynchrone Aktionen auszuführen. Weiterhin können Entwickler die Zielroute verändern, beispielsweise wenn ein Redirect auf eine Login-Seite gewünscht ist. Es ist auch ein geeigneter Ort, um Daten zu laden und anschließend als Properties an die Zielkomponente zu übergeben:

{
path: '/book/:id',
name: 'book',
component: BookComponent,
beforeEnter(routeTo, routeFrom, next) {
Promise.resolve('ISBN' + routeTo.params.id)
.then(isbn => {
routeTo.params.isbn = isbn
next()
}).catch(() => {
next({ name: '404' })
})
},
props: true
}

Die Route erhält eine ID und lädt weitere Daten. Im Beispiel ist eine asynchrone Aktion über Promise.resolve angedeutet. Das Ergebnis wird in den Routen-Parameter isbn geschrieben. Anschließend ruft die Anwendung next auf, um die Navigation auszuführen. Tritt ein Fehler auf, erfolgt die Weiterleitung auf eine Fehlerseite.

Die Zielkomponente BookComponent erhält nun die ISBN als Property und ist vollständig von der Herkunft der Information entkoppelt. Die Logik im beforeEnter-Callback könnte die Information auch aus einem Vuex-Store laden.

Möchte man die Logik zum Laden der Daten nicht von der Komponente trennen, aber sie trotzdem vor der eigentlichen Navigation ausführen, ist ein Nutzen der Navigation-Hooks innerhalb der Zielkomponente denkbar:

export default {
name: 'BookComponent',
data() {
return {
isbn: null
}
},
beforeRouteEnter(routeTo, routeFrom, next) {
Promise.resolve("ISBN" + routeTo.params.id)
.then(isbn => {
next(viewModell => {
viewModell.isbn = isbn
})
})
.catch(() => {
next({ name: "404" })
});
}
}

Bevor man zur Komponente navigiert, startet die Callback-Methode beforeRouteEnter. Deshalb kann man noch nicht auf deren Kontext zugegreifen. Um die geladenen Daten an die Komponente zu übergeben, erhält die next-Funktion eine Callback-Methode. Ihr übergibt der Vue-Router den Kontext, sobald er erzeugt ist (im Beispiel mit viewModell bezeichnet).

Folgendes ist dabei allerdings zu beachten: der Lifecycle-Hook beforeRouteEnter wird nur ausgeführt, wenn die Komponente noch nicht geladen war. Für Fälle, bei denen man von einer Instanz der Komponente zur nächsten navigiert, ist die Verwendung von beforeRouteUpdate alternativ oder zusätzlich möglich.

Lazy Loading mit dem Vue-Router

Eine große Anwendung besteht aus vielen Routen und Komponenten. Müsste man sie alle auf einmal laden, wäre der erste Aufbau der Anwendung sehr langsam. Um das zu verhindern, lädt die Anwendung Inhalte erst, wenn ein Nutzer dorthin navigiert – auch bekannt als Lazy Loading. Der Vue-Router unterstützt das ebenfalls. Die eigentliche Arbeit überlässt man der Code-Splitting-Funktion von webpack. Damit das funktioniert, müssen lediglich die Routen asynchron geladen werden.

Anstatt beim Aufbau des Routen-Arrays die Komponenten direkt als JavaScript-Module zu laden, definiert man eine Funktion, die ein Promise-Objekt zurückliefert.

Der Import ohne Code-Splitting funktioniert so

import Home from './views/Home.vue'
import HelloWorld from './views/HelloWorld.vue'

während der Import mit Code-Splitting wie folgt aussieht:

const Home = () => import('./views/Home.vue')
const HelloWorld = () => import('./views/HelloWorld.vue')

Der Code ruft das import-Statement als Funktion auf und gibt ein Promise-Objekt zurück. Die weitere Definition des Routen-Arrays kann unverändert bleiben.

Fazit

Die beiden Bibliotheken Vuex und Vue Router ergänzen Vue.js um wichtige Funktionen. Da sie vom gleichen Team wie Vue.js selbst stammen, haben sie die gleiche Qualität und werden immer parallel zum Framework entwickelt.

Über die offiziellen Ergänzungen hinaus hat Vue.js ein umfangreiches Ökosystem von Plug-ins und UI-Bibliotheken sowie eine sehr aktive Community. Eine gute Anlaufstelle ist diese moderierte Liste von Erweiterungen und ebenso das GitHub-Repository awesome-vue.

Vue.js, Vuex und der Vue-Router sind sehr gut dokumentiert und es ist viel Aufwand in einen didaktisch guten Aufbau geflossen. Über den Vue-Guide hinaus seien insbesondere der Style Guide und das Cookbook empfohlen. Eine hervorragende Quelle für weitergehende Tipps und Konzepte zur Umsetzung komplexer Anwendungen ist auch das Github-Projekt vue-enterprise-boilerplate.

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.