Zum Inhalt springen

Tutorial: NUXT.JS Application zur Darstellung aller Star Wars Filme – Teil #4

NUXT.JS

Content Area

Categories

Tutorial: NUXT.JS Application zur Darstellung aller Star Wars Filme – Teil #4

Rückblick

Im den letzten Kapiteln haben wir NUXT.JS eingerichtet und uns angesehen, wie wir eigene Komponenten für die Navigation und den Footer einrichten und diese in die Template-Struktur einbinden.

Im letzten Kapitel haben wir uns ausschließlich um die Kosmetik gekümmert, indem wir SCSS aktiviert und einige Anpassungen an das „Star Wars Look & Feel“ vorgenommen haben.

Den letzten Stand könnt ihr euch wie immer unter dem oben angegebenen GitHub-Repository herunter laden.

Neuen Branch mit Git einrichten

Wie immer sollten wir einen neuen Branch mit Git einrichten – da ich das aber nun bereits zweimal wiederholt habe, schau im Zweifel einmal in die zurückliegenden Kapitel

Axios und Lodash installieren

Axios haben wir bereits bei der Einrichtung von NUXT.JS aktiviert, deshalb brauchen wir uns darum nicht mehr zu kümmern.

Natürlich könnten wir auch selbst Funktionen schreiben, um unsere via REST-API erhaltenen Ergebnisse zu filtern und sortieren. Als Programmierer bin ich aber per Definition faul, möchte für die Leser außerdem den Einstiegslevel möglichst gering halten und nutze deshalb lieber eines der beliebtesten NPM-Pakete zu diesem Zweck: Lodash.

Lodash können wir mit folgendem Konsolenbefehl installieren:

$ npm i --save lodash

Axios Plugin anlegen

Wenn wir uns die SWAPI etwas genauer anschauen, sehen wir dass alle Queries eine gemeinsame Base-URL (https://swapi.co/api/) und unterschiedliche Endpoints (films, people, planets, species, starships, vehicles) haben.

Deshalb können wir die Base-URL auch in unser Plugin hardcoden und lediglich bei der Query unterscheiden wir ggf.

import axios from 'axios'

export default axios.create({
  baseURL: 'https://swapi.co/api/'
})
Datei ./plugins/axios.js

Das Plugin speichern wir im Ordner ./plugins und müssen es nun nur noch dort per Import einbinden, wo wir es verwenden wollen. Grundsätzlich könnten wir in dieser Datei auch einen größeren Teil der Programmlogik ablegen, für unsere Übung soll das aber reichen.

Javascript Promises vs. Async/Await

Die herkömmliche Methode Rest-APIs einzubinden, ist die native Javascript-Methode mit Promises.

Ich bevorzuge allerdings die „modernere“ Methode via Async/Await, die – wie auch Promises – „non-blocking“ (das Script kann im Hintergrund weiter ausgeführt werden) ist, aber verschiedene Vorteile bietet:

  • Subjektive Meinung: Besser/einfacher lesbarer Code
  • Kürzerer Code
  • Bessere Möglichkeiten beim Error-Reporting

Das sollten für uns an dieser Stelle erst einmal die wichtigsten Vorteile sein.

Wer gerne mehr darüber erfahren möchte, sollte sich den Artikel „From JavaScript Promises to Async/Await: why bother?“ auf Pusher durchlesen.

Wir werden uns dennoch beiden Möglichkeiten widmen und schauen uns deshalb erst einmal die Möglichkeit mittels herkömmlicher Promises an.

Daten der SWAPI REST-API mit Promises einbinden und darstellen

Hinweis: Was wir im Folgenden machen, ist nicht wirklich elegant – nämlich die Komponente zum Abruf und Darstellung der Daten direkt in eine Seite zu integrieren. Der Einfachheit halber machen wir es aber erst einmal genau so und überlegen uns später, wie wir stattdessen sinnvoll eine Komponente anlegen.

<template>
  <section class="container">
    <div>
      <h1 class="title">
        Star Wars Films
      </h1>
      <h2 class="subtitle text-primary mb-5">
        – A Nuxt.js project –
      </h2>
    </div>
    <b-card-group deck>
      <b-card
        v-for="item in films"
        :key="item.episode_id"
        class="mb-5"
      >
        <b-list-group>
          <b-list-group-item>
            <b-card-title class="text-primary">
              {{ item.title }}
            </b-card-title>
          </b-list-group-item>
          <b-list-group-item>
            <div class="opening-crawl text-primary">
              {{ item.opening_crawl }}
            </div>
          </b-list-group-item>
          <b-list-group-item>
            <strong>Episode:</strong> {{ item.episode_id }}
          </b-list-group-item>
          <b-list-group-item>
            <strong>Director:</strong> {{ item.director }}
          </b-list-group-item>
          <b-list-group-item>
            <strong>Producer:</strong> {{ item.producer }}
          </b-list-group-item>
          <b-list-group-item>
            <strong>Release Date:</strong> {{ item.release_date }}
          </b-list-group-item>
        </b-list-group>
      </b-card>
    </b-card-group>
  </section>
</template>

<script>
import axios from '~/plugins/axios'

export default {
  components: {},
  asyncData() {
    return axios.get('films/').then(response => ({
      films: response.data.results
    }))
  }
</script>

<style></style>
Datei ./pages/index.vue

Import von Axios und asynchrones Abrufen der Daten von SWAPI

Im Script-Bereich müssen wir zunächst unser Plugin importieren. Dadurch dass wir den export default als „axios“ importieren, können wir auch über diesen Namen auf die Funktion zugreifen, die uns alle Funktionen von Axios bereit stellt. Siehe dazu auch die Doku von Axios.

Dazu nutzen wir in Zeile 51 die Promise-Funktion von NUXT.JS und übergeben der Axios „get„-Funktion in Zeile 52 unseren SWAPI REST-Endpoint „films“.

Mit .then warten wir die Antwort ab und übergeben das empfangene JSON-Object dem Daten-Parameter „films„. Grundsätzlich sollten wir an dieser Stelle auch Fehler abfangen, kneifen uns das aber der Einfachheit halber und integrieren das später bei Async/Await.

Hinweis: Was wir empfangen, steht uns lediglich als response.data zur Verfügung. Wir greifen direkt auf ein Unterobjekt, nämlich results zu. Sieh dir dazu einmal das empfangene JSON an – die Filme selbst stecken alle im Unterobjekt „results“.

Integration der REST-Daten in die „Star Wars Films“-Anwendung

Die Idee: Ich möchte die „Card Deck Groups“ aus BootstrapVue nutzen und für jeden Film eine eigene Card generieren.

In jeder Card sollen

  • der Titel des Films
  • der „Opening Crawl“, also die Laufschrift am Anfang jedes Films
  • die Nummer der Episode
  • der Film-Director
  • der/die Produzent/en des Films
  • das Erscheinungsdatum des Films

angezeigt werden.

Grundsätzlich könnten wir weitere Informationen einbinden bzw. verknüpfen, denn im JSON-Objekt schlummern weitere Informationen.

So könnten wir zum Beispiel später auch im Kontext die im Film auftauchenden Charaktere, Planeten uvm. filtern und anzeigen.

Mal schauen 😉

In Code übersetzt bedeutet das: Wir müssen wie in einer for-Schleife durch alle Einträge loopen und für jeden Eintrag dann die entsprechenden Infos abrufen.

v-for Schleifen

In den Zeilen 12-16 machen wir genau das.

Das Element <b-card> erhält den v-for Parameter, der zwei Argumente erwartet:
Zum einen den Namen für den Iterator, den wir verwenden möchten, in unserem Fall nennen wir es „item“ . Zum Zweiten welches Objekt/Array wir durchlaufen möchten, in unserem Fall haben wir über Axios das Objekt als „films“ deklariert. Damit steht uns jedes einzelne Element innerhalb der Schleife als „item“ zur Verfügung.

Außerdem erwartet NUXT.JS einen eindeutigen Key in v-for – wir nutzen dafür einen numerischen Key aus jedem einzelnen Unterobjekt bzw. Film, nämlich die episode_id .

Innerhalb des Templates können wir die Texte nun durch Template-Literals wie zum Beispiel {{ item.title }} darstellen, die du wie Platzhalter verstehen kannst.

Das funktioniert soweit, die Console schmeißt auch keine Fehler, allerdings sieht man noch nicht viel wegen der Farben, die wir vergeben haben.

Deshalb müssen wir noch ein wenig an den S/CSS-Styles arbeiten.

Styling

Ich ersetze einfach den kompletten <style>-Bereich in der default.vue wie folgt ohne näher darauf einzugehen:

@import '~assets/scss/bootstrap-variables.scss';
@import '~bootstrap/scss/bootstrap.scss';
@import '~bootstrap-vue/src/index.scss';

body {
  padding-top: 100px;
  padding-bottom: 100px;
  background-color: $black;
  color: $light;
}

/* Navigation Styling */
.navbar-brand {

  #header-logo {
    max-height: 2rem;
    margin-right: 1.5rem;
  }

  h1 {
    font-family: "Star Wars", Arial, Helvetica, sans-serif;
    font-size: 1.25rem;
  }
}

/* Containers Styling */
.container {
  min-height: calc(100vh - 3rem - (1.25rem + 3.25rem + 1rem) -200px;
  /* Berechnung: Viewport-Höhe - <Footer> - <Navigation> - <body>-Padding */
  text-align: center;
}

/* Headline Styling */
.title {
  font-family: "Star Wars", Arial, Helvetica, sans-serif;
  font-weight: normal;
  font-size: 3rem;
  color: $dark;
  text-shadow:  0px 0px 3px transparentize($primary, 0.5),
                -1px 1px 3px transparentize($primary, 0.5),
                1px -1px 3px transparentize($primary, 0.5),
                -1px -1px 3px transparentize($primary, 0.5);
}

.subtitle {
  font-weight: 300;
  font-size: 2.5rem;
}

/* Card Styling */
.card {
  /* Default width for mobile-first */
  min-width: 100%;
  background-color: $gray-900;

  .card-title {
    font-family: "Star Wars", Arial, Helvetica, sans-serif;
  }

  .opening-crawl {
    transform-origin: 50% 100%;
    transform: perspective(250px) rotateX(20deg);
  }
}

@media (min-width: 768px) {
  /* Width for large Smartphones and higher */
  .card {
    min-width: calc(50% - 30px);
  }
}

.list-group {
  border: 1px solid transparentize($white, 0.5);
  box-shadow: 8px 8px 5px transparentize($black, 0.5);
}

.list-group-item {
  background-color: $black;
  border: none !important;
  border-bottom: 2px solid transparentize($white, 0.5) !important;

  &:last-child {
    border: none !important;
  }
}

/* Link Styling */
.nuxt-link-exact-active {
  color: $primary !important;
}

/* Transition Styling */
.page-enter-active,
.page-leave-active {
  transition: opacity 0.5s;
}

.page-enter,
.page-leave-to {
  opacity: 0;
}
Datei: ./layouts/default.vue

Damit sollte das Ergebnis nun so aussehen:

Star Wars Films – Importierte Daten von SWAPI
Star Wars Films – Importierte Daten von SWAPI

Großartig! Die Daten werden erfolgreich empfangen und dargestellt, das Styling passt auch zum Thema und der Console sollten wir auch keine Fehler erhalten.

Ich habe nur drei kleine Probleme mit der Lösung:

  1. Die Filme werde nicht nach der Episode sortiert – sondern einfach so, wie sie im JSON notiert sind. Das ist unschön.
  2. Die Lösung mit Async/Await ist eleganter.
  3. Wir haben keine Reaktion auf mögliche Fehler (Daten können aus welchem Grund auch immer nicht empfangen werden) eingebaut.

Sortieren der Daten mit Lodash

Lodash haben wir bereits installiert, wiederum müssen wir das Modul in den Dateien, in denen wir es nutzen wollen, importieren.

Zur Definition von Funktionen stellt Vue.js/NUXT.JS verschiedene Möglichkeiten wie methods, computed, created, watchers und mehr zur Verfügung. Was davon wozu genau taugt und wann/wo greift, schauen wir uns vielleicht später einmal an. Wenn du magst, solltest du schon einmal einen Blick auf einen recht guten Artikel bei Sitepoint zu den Unterschieden und Verwendungen lesen.

Wichtig ist für uns an dieser Stelle erst einmal nur computed. das für uns aus folgenden Gründen entscheidende ist:

  • computed greift nur beim ersten Aufruf der Seite
  • wir haben die Möglichkeit vor dem ersten Rendern eine Funktion zu übergeben, die die empfangenen Daten wunschgemäß sortiert, filtert oder, oder, oder..
  • solang die Daten nicht irgendwie modifiziert werden, werden sie im Cache abgelegt
  • das erspart uns teilweisem Neurendern der Anwendung aufwändige Rechenoperationen

Dazu ersetzen wir den <script>-Bereich in index.vue wie folgt:

<script>
import _ from 'lodash'
import axios from '~/plugins/axios'

export default {
  components: {},
  computed: {
    filmsOrderedByID: function() {
      const filmsOrdered = _.orderBy(this.films, 'episode_id')
      return filmsOrdered
    }
  },
  asyncData() {
    return axios.get('films/').then(response => ({
      films: response.data.results
    }))
  }
}
</script>
Datei: ./pages/index.vue

In Zeile 2 importieren wir zunächst Lodash als „_„, in den Zeilen 7-12 fügen wir eine neue computed-Property namens „filmsOrderedByID“ ein.

Darin steckt eine Funktion, die für den Wert „filmsOrderedByID“ nun den Rückgabewert der Funktion beinhaltet.

Für die Sortierung ist der String _.orderBy(this.films, 'episode_id') verantwortlich, der die orderBy-Funktion von Lodash nutzt. Diese erwartet (minimal) zwei Parameter:

  • was sortiert werden soll: this.films
  • den Wert nach dem sortiert werden soll: episode_id

Damit stehen uns die sortierten Daten in einem neuen Objekt namens filmsOrderedByID zur Verfügung, deshalb müssen wir auch unseren v-for Loop anpassen, da wir auf das neue Objekt zugreifen wollen:

<b-card
  v-for="item in filmsOrderedByID"
  :key="item.episode_id"
  class="mb-5"
>
Datei ./pages/index.vue

Und damit klappt nun endlich auch die Sortierung. Natürlich kannst Du auch nach jedem weiteren Feld sortieren und das sogar auf- oder absteigend. Wirf dazu einen Blick in die orderBy-Funktion von Lodash.

Optimierung: Async/Await statt Javascript Promises

Unsere Anwendung funktioniert zwar einwandfrei, dennoch will ich dir die elegantere Lösung mit Async/Await nicht vorenthalten.

Wir ersetzen den asyncData() { .. } Bereich durch folgenden Code:

// Axios with Async/Await
async asyncData({ error }) {
  const films = await axios
    .get('filmsx/')
    .then(response => ({
      // Handle Success
      films: response.data.results
    }))
    .catch(e => {
      // Handle Errors, generate 404 Status with Message
      error({
        statusCode: 404,
        message: 'Endpoint could not be resolved'
      })
    })
  return films
}
Datei: ./pages/index.vue

Wenn du jetzt speicherst und die Seite neu aufrufst, siehst du die Fehlermeldung und in der Console den Statuscode, die wir im Errorhandling mit eingebaut haben.

Meiner ganz persönlichen Meinung nach ist dieser Code erheblich besser lesbar als das vorherige Promise.

Und warum erhalte ich die Fehlermeldung? Schau in Zeile 4: dort steht nun „filmsx“ – entferne einfach das X, speichere und lade die Seite neu. Sie wird dann wie erwartet dargestellt.

Ich wollte den Fehler und die Meldung nur provozieren 😉