Erstellen einer dynamischen JAMstack-App mit GatsbyJS und FaunaDB

❥ Sponsor

In diesem Artikel erklären wir den Unterschied zwischen Single-Page-Anwendungen (SPAs) und statischen Websites und wie wir die Vorteile beider Welten in einer dynamischen JAMstack-App mit GatsbyJS und FaunaDB vereinen können. Wir werden eine Anwendung erstellen, die zur Build-Zeit einige Daten aus FaunaDB bezieht, das HTML für eine schnelle Auslieferung an den Client vorab rendert und dann zusätzliche Daten zur Laufzeit lädt, wenn der Benutzer mit der Seite interagiert. Diese Kombination von Technologien bietet uns die besten Eigenschaften von statisch generierten Websites und SPAs. 

Kurz gesagt…<tief durchatmen>…auto-skalierende verteilte Websites mit geringer Latenz, schnellen Benutzeroberflächen, ohne Neuladen und dynamischen Daten für alle!

Schwere Backends, Single-Page-Anwendungen, statische Websites

Früher, als JavaScript neu war, wurde es hauptsächlich nur für Effekte und verbesserte Interaktionen verwendet. Einige Animationen hier, ein Dropdown dort, und das war's. Die Schwerstarbeit wurde im Backend von Perl, Java oder PHP geleistet. 

Das änderte sich im Laufe der Zeit: Der Client-Code wurde schwerer, und JavaScript übernahm immer mehr den Frontend-Bereich, bis wir schließlich meist leeres HTML auslieferten und die gesamte Benutzeroberfläche im Browser renderten, wobei das Backend uns mit JSON-Daten versorgte.

Dies führte zu einer sauberen Trennung der Zuständigkeiten und ermöglichte uns den Aufbau ganzer Anwendungen mit JavaScript, sogenannte Single-Page-Applications (SPAs). Der wichtigste Vorteil von SPAs war das Ausbleiben von Neuladevorgängen. Sie konnten auf einen Link klicken, um das angezeigte zu ändern, ohne einen vollständigen Neuladevorgang der Seite auszulösen. Dies allein bot eine überlegene Benutzererfahrung. SPAs erhöhten jedoch die Größe des Client-Codes erheblich; ein Client musste nun die Summe mehrerer Latenzen abwarten

  • Serving Latency: Abruf von HTML und JavaScript vom Server, wobei das JavaScript größer als üblich war. 
  • Data Loading Latency: Laden zusätzlicher vom Client angeforderter Daten
  • Frontend Framework Rendering Latency: Sobald die Daten empfangen wurden, muss ein Frontend-Framework wie React, Vue oder Angular immer noch viel Arbeit leisten, um das endgültige HTML zu erstellen. 

Eine königliche Metapher

Das Laden einer SPA können wir mit dem Bau und der Lieferung einer Spielzeugburg vergleichen. Der Client muss das HTML und JavaScript abrufen, dann die Daten abrufen und die Seite immer noch zusammenbauen. Die Bausteine werden geliefert, müssen aber nach der Lieferung noch zusammengesetzt werden.

Wenn es doch nur einen Weg gäbe, die Burg im Voraus zu bauen…

Betreten der JAMstack

JAMstack-Anwendungen bestehen aus JavaScript, APIs und Markup. Mit modernen statischen Website-Generatoren wie Next.js und GatsbyJS können die JavaScript- und Markup-Teile zu einem statischen Paket gebündelt und über ein Content Delivery Network (CDN) bereitgestellt werden, das Dateien an einen Browser ausliefert. Ein CDN verteilt die Bundles und andere Assets geografisch auf mehrere Standorte. Wenn der Browser eines Benutzers das Bundle und die Assets abruft, kann er sie vom nächstgelegenen Standort im Netzwerk erhalten, was die Serving-Latenz reduziert. 

Um unsere Spielzeugburg-Analogie fortzusetzen: JAMstack-Apps unterscheiden sich von SPAs dadurch, dass die Seite (oder Burg) fertig montiert geliefert wird. Wir haben eine geringere Latenz, da wir die Burg als Ganzes erhalten und sie nicht mehr bauen müssen. 

Statische JAMstack-Apps dynamisch mit Hydration machen

Bei der JAMstack-Methode beginnen wir mit einer dynamischen Anwendung und rendern statische HTML-Seiten vor, die über ein schnelles CDN ausgeliefert werden. Aber was ist, wenn eine vollständig statische Website nicht ausreicht und wir dynamische Inhalte unterstützen müssen, während der Benutzer mit einzelnen Komponenten interagiert, ohne die gesamte Seite neu zu laden? Hier kommt die clientseitige Hydration ins Spiel.

Hydration ist der clientseitige Prozess, bei dem das serverseitig gerenderte HTML (DOM) von unserem Frontend-Framework mit Ereignishandlern und/oder dynamischen Komponenten „bewässert“ wird, um es interaktiver zu gestalten. Das kann knifflig sein, da es von der Abgleichung des ursprünglichen DOM mit einem neuen virtuellen DOM (VDOM) abhängt, der im Speicher gehalten wird, während der Benutzer mit der Seite interagiert. Wenn die DOM- und VDOM-Bäume nicht übereinstimmen, können Fehler auftreten, die dazu führen, dass Elemente in falscher Reihenfolge angezeigt werden oder die Seite neu erstellt werden muss.

Glücklicherweise sind Bibliotheken wie GatsbyJS und NextJS so konzipiert, dass die Wahrscheinlichkeit solcher Hydration-bedingten Fehler minimiert wird, und erledigen alles out-of-the-box mit nur wenigen Codezeilen. Das Ergebnis ist eine dynamische JAMstack-Webanwendung, die gleichzeitig schneller und dynamischer ist als die entsprechende SPA. 

Ein technisches Detail bleibt: Woher kommen die dynamischen Daten?

Verteilte, frontend-freundliche Datenbanken!

JAMstack-Apps verlassen sich typischerweise auf APIs (daher das „A“ in JAM), aber wenn wir benutzerdefinierte Daten laden müssen, brauchen wir eine Datenbank. Und traditionelle Datenbanken sind immer noch ein Performance-Engpass für global verteilte Websites, die ansonsten über ein CDN ausgeliefert werden, da traditionelle Datenbanken nur in einer Region angesiedelt sind. Anstatt eine traditionelle Datenbank zu verwenden, möchten wir, dass unsere Datenbank in einem verteilten Netzwerk liegt, genau wie das CDN, das die Daten von einem Standort so nah wie möglich an den Standort unserer Clients liefert. Diese Art von Datenbank wird als verteilte Datenbank bezeichnet. 

In diesem Beispiel wählen wir FaunaDB, da es auch stark konsistent ist, was bedeutet, dass unsere Daten überall gleich sind, von wo auch immer meine Clients darauf zugreifen, und keine Daten verloren gehen. Weitere Merkmale, die besonders gut mit JAMstack-Anwendungen funktionieren, sind, dass (a) auf die Datenbank als API (GraphQL oder FQL) zugegriffen wird und Sie keine Verbindung öffnen müssen, und (b) die Datenbank über eine Sicherheitsschicht verfügt, die es ermöglicht, sowohl öffentliche als auch private Daten sicher vom Frontend aus abzurufen. Das hat zur Folge, dass wir die geringen Latenzen von JAMstack beibehalten können, ohne ein Backend skalieren zu müssen, und das alles bei null Konfiguration. 

Vergleichen wir den Prozess des Ladens einer hydrierten statischen Website mit dem Bau der Spielzeugburg. Wir haben dank des CDN immer noch geringere Latenzen, aber auch weniger Daten, da der Großteil der Website statisch generiert ist und daher weniger Rendering erfordert. Nur ein kleiner Teil der Burg (oder der dynamische Teil der Seite) muss nach der Auslieferung noch zusammengebaut werden.

Beispiel-App mit GatsbyJS & FaunaDB

Lassen Sie uns eine Beispielanwendung erstellen, die Daten zur Build-Zeit aus FaunaDB lädt und sie in statisches HTML rendert, und dann zur Laufzeit zusätzliche dynamische Daten im Client-Browser lädt. Für dieses Beispiel verwenden wir GatsbyJS, ein JAMstack-Framework auf React-Basis, das statisches HTML vorab rendert. Da wir GatsbyJS verwenden, können wir unsere Website komplett in React coden, die statischen Seiten generieren und ausliefern und dann zur Laufzeit dynamisch weitere Daten laden. Wir werden FaunaDB als unsere vollständig verwaltete serverlose Datenbanklösung verwenden. Wir werden eine Anwendung erstellen, in der wir Produkte und Bewertungen auflisten können. 

Werfen wir einen Blick auf einen Überblick darüber, was wir tun müssen, um unsere Beispiel-App zum Laufen zu bringen, und gehen dann jeden Schritt im Detail durch.

  1. Neue Datenbank einrichten
  2. GraphQL-Schema zur Datenbank hinzufügen
  3. Datenbank mit Mock-up-Daten befüllen
  4. Neues GatsbyJS-Projekt erstellen
  5. NPM-Pakete installieren
  6. Server-Schlüssel für die Datenbank erstellen
  7. GatsbyJS-Konfigurationsdateien mit Server- und neuem Nur-Lese-Schlüssel aktualisieren
  8. Vorrab gerenderte Produktdaten zur Build-Zeit laden
  9. Bewertungen zur Laufzeit laden

1. Neue Datenbank einrichten

Erstellen Sie vor Beginn ein Konto auf dashboard.fauna.com. Sobald Sie ein Konto haben, richten wir eine neue Datenbank ein. Sie soll Produkte und deren Bewertungen enthalten, damit wir die Produkte zur Build-Zeit und die Bewertungen im Browser laden können. 

2. GraphQL-Schema zur Datenbank hinzufügen

Als Nächstes verwenden wir den Server-Schlüssel, um ein GraphQL-Schema in unsere Datenbank hochzuladen. Dazu erstellen wir eine neue Datei namens schema.gql, die den folgenden Inhalt hat:

type Product {
  title: String!
  description: String
  reviews: [Review] @relation
}

type Review {
  username: String!
  text: String!
  product: Product!
}

type Query {
  allProducts: [Product]
}

Sie können Ihre schema.gql-Datei über die FaunaDB-Konsole hochladen, indem Sie links in der Seitenleiste auf „GraphQL“ klicken und dann auf die Schaltfläche „Schema importieren“.

Nachdem Sie FaunaDB ein GraphQL-Schema bereitgestellt haben, erstellt es automatisch die erforderlichen Collections für die Entitäten in unserem Schema (Produkte und Bewertungen). Außerdem werden die Indizes erstellt, die für eine sinnvolle und effiziente Interaktion mit diesen Collections erforderlich sind. Sie sollten nun mit einer GraphQL-Playground-Oberfläche konfrontiert werden, auf der Sie testen können.

3. Datenbank mit Mock-up-Daten befüllen

Um unsere Datenbank mit Produkten und Bewertungen zu befüllen, können wir die Shell unter dashboard.fauna.com verwenden: 

Um einige Daten zu erstellen, verwenden wir die Fauna Query Language (FQL), danach fahren wir mit GraphQL fort, um unsere Beispielanwendung zu erstellen. Fügen Sie die folgende FQL-Abfrage in die Shell ein, um drei Produktdokumente zu erstellen:

Map(
  [
    { title: "Screwdriver", description: "Drives screws." },
    { title: "Hair dryer", description: "Dries your hair." },
    { title: "Rocket", description: "Flies you to the moon and back." }
  ],
  Lambda("product",
    Create(Collection("Product"), { data: Var("product") })
  )
);

Anschließend können wir eine Abfrage schreiben, die die gerade erstellten Produkte abruft und für jedes Produktdokument ein Bewertungsdokument erstellt.

Map(
  Paginate(Match(Index("allProducts"))),
  Lambda("ref", Create(Collection("Review"), {
    data: {
      username: "Tina",
      text: "Good product!",
      product: Var("ref")
    }
  }))
);

Beide Dokumententypen werden über GraphQL geladen. Es gibt jedoch einen signifikanten Unterschied zwischen Produkten und Bewertungen. Die ersteren ändern sich nicht viel und sind relativ statisch, während die letzteren benutzergesteuert sind. GatsbyJS ermöglicht uns das Laden von Daten auf zwei Arten:

  • Daten, die zur Build-Zeit geladen werden und zur Generierung der statischen Website verwendet werden. 
  • Daten, die live zur Zeitpunkt der Anfrage geladen werden, wenn ein Client Ihre Website besucht und damit interagiert. 

In diesem Beispiel haben wir uns entschieden, die Produkte zur Build-Zeit laden zu lassen und die Bewertungen bedarfsgesteuert im Browser zu laden. Daher erhalten wir statische HTML-Produktseiten, die von einem CDN ausgeliefert werden und die der Benutzer sofort sieht. Dann, während unser Benutzer mit der Produktseite interagiert, laden wir die Daten für die Bewertungen.

4. Neues GatsbyJS-Projekt erstellen

Der folgende Befehl erstellt ein GatsbyJS-Projekt basierend auf der Starter-Vorlage.

$ npx gatsby-cli new hello-world-gatsby-faunadb
$ cd hello-world-gatsby-faunadb

5. NPM-Pakete installieren

Um unser neues Projekt mit Gatsby und Apollo zu erstellen, benötigen wir einige zusätzliche Pakete. Wir können die Pakete mit dem folgenden Befehl installieren: 

 $ npm i gatsby-source-graphql apollo-boost react-apollo

Wir werden gatsby-source-graphql als Mittel verwenden, um GraphQL-APIs in den Build-Prozess zu integrieren. Mit dieser Bibliothek können Sie einen GraphQL-Aufruf tätigen, dessen Ergebnisse automatisch als Eigenschaften für Ihre React-Komponente bereitgestellt werden. Auf diese Weise können Sie dynamische Daten verwenden, um Ihre Anwendung statisch zu generieren. Das apollo-boost-Paket ist eine einfach konfigurierbare GraphQL-Bibliothek, die zum Abrufen von Daten auf dem Client verwendet wird. Schließlich wird die Verbindung zwischen Apollo und React von der react-apollo-Bibliothek übernommen.

6. Server-Schlüssel für die Datenbank erstellen

Wir erstellen einen Server-Schlüssel, der von Gatsby zum Vorabrendern der Seite verwendet wird. Denken Sie daran, das Geheimnis zu kopieren, da wir es später verwenden werden. Schützen Sie Server-Schlüssel sorgfältig, da sie zum Erstellen, Löschen oder Verwalten der Datenbank verwendet werden können, der sie zugeordnet sind. Um den Schlüssel zu erstellen, können wir zum Fauna-Dashboard gehen und den Schlüssel im Reiter „Sicherheit“ erstellen. 

7. GatsbyJS-Konfigurationsdateien mit Server- und neuen Nur-Lese-Schlüsseln aktualisieren

Um die GraphQL-Unterstützung zu unserem Build-Prozess hinzuzufügen, müssen wir den folgenden Code in unsere graphql-config.js einfügen, im Abschnitt `plugins`, wo wir den FaunaDB-Server-Schlüssel einfügen, den wir gerade generiert haben. 

{
  resolve: "gatsby-source-graphql",
  options: {
    typeName: "Fauna",
    fieldName: "fauna",
    url: "https://graphql.fauna.com/graphql",
    headers: {
      Authorization: "Bearer <SERVER KEY>",
    },
  },
}

Damit der GraphQL-Zugriff im Browser funktioniert, müssen wir einen Schlüssel erstellen, der nur die Berechtigung hat, Daten aus den Collections zu lesen. FaunaDB verfügt über eine umfassende Sicherheitsschicht, in der Sie dies definieren können. Am einfachsten ist es, die FaunaDB-Konsole unter dashboard.fauna.com zu öffnen und eine neue Rolle für Ihre Datenbank zu erstellen, indem Sie links in der Seitenleiste auf „Sicherheit“ klicken, dann auf „Rollen verwalten“ und dann auf „Neue benutzerdefinierte Rolle“.

Nennen Sie die neue benutzerdefinierte Rolle „ClientRead“ und stellen Sie sicher, dass Sie alle Collections und Indizes hinzufügen (dies sind die Collections, die durch den Import des GraphQL-Schemas erstellt wurden). Wählen Sie dann für jeden von ihnen „Lesen“. Ihr Bildschirm sollte wie folgt aussehen:

Sie haben wahrscheinlich den Reiter „Mitgliedschaft“ auf dieser Seite bemerkt. Obwohl wir ihn in diesem Tutorial nicht verwenden, ist er interessant genug, um ihn zu erklären, da er eine alternative Möglichkeit bietet, Sicherheitstoken zu erhalten. Im Reiter „Mitgliedschaft“ können Sie festlegen, dass Entitäten einer Collection (nehmen wir an, wir haben eine „Benutzer“-Collection) in FaunaDb Mitglieder einer bestimmten Rolle sind. Das bedeutet, dass die Rollenprivilegien gelten, wenn Sie eine dieser Entitäten in der Collection impersonieren. Sie impersonieren eine Datenbankentität (z. B. einen Benutzer), indem Sie Anmeldedaten mit der Entität verknüpfen und die Funktion Login verwenden, die ein Token zurückgibt. Auf diese Weise können Sie auch eine passwortbasierte Authentifizierung in FaunaDb implementieren. Wir werden sie in diesem Tutorial nicht verwenden, aber wenn Sie daran interessiert sind, lesen Sie das FaunaDB Authentifizierungs-Tutorial.

Lassen Sie uns die Mitgliedschaft vorerst ignorieren. Sobald Sie die Rolle erstellt haben, können wir einen neuen Schlüssel mit der neuen Rolle erstellen. Wie zuvor klicken Sie auf „Sicherheit“, dann auf „Neuer Schlüssel“, aber diesmal wählen Sie „ClientRead“ aus dem Dropdown-Menü „Rolle“.

Fügen wir nun diesen Nur-Lese-Schlüssel in die Konfigurationsdatei `gatsby-browser.js` ein, um die GraphQL-API vom Browser aus aufrufen zu können.

import React from "react"
import ApolloClient from "apollo-boost"
import { ApolloProvider } from "react-apollo"

const client = new ApolloClient({
  uri: "https://graphql.fauna.com/graphql",
  request: operation => {
    operation.setContext({
      headers: {
        Authorization: "Bearer <CLIENT_KEY>",
      },
    })
  },
})

export const wrapRootElement = ({ element }) => (
  <ApolloProvider client={client}>{element}</ApolloProvider>
)

GatsbyJS rendert seine Router-Komponente als Wurzelelement. Wenn wir den ApolloClient überall in der Anwendung auf dem Client verwenden möchten, müssen wir dieses Wurzelelement mit der ApolloProvider-Komponente umschließen.

8. Vorab gerenderte Produktdaten zur Build-Zeit laden

Nachdem nun alles eingerichtet ist, können wir endlich den eigentlichen Code zum Laden unserer Daten schreiben. Beginnen wir mit den Produkten, die wir zur Build-Zeit laden werden.

Dafür müssen wir die Datei `src/pages/index.js` wie folgt ändern:

import React from "react"
import { graphql } from "gatsby"
Import Layout from "../components/Layout"

const IndexPage = ({ data }) => (
  <Layout>
    <ul>
      {data.fauna.allProducts.data.map(product => (
        <li>{product.title} - {product.description}</li>
      ))}
    </ul>
  </Layout>
)

export const query = graphql`
{
  fauna {
    allProducts {
      data { _id title description }
    }
  }
}
`

export default IndexPage

Die exportierte Abfrage wird automatisch von GatsbyJS erkannt und ausgeführt, bevor die `IndexPage`-Komponente gerendert wird. Das Ergebnis dieser Abfrage wird als `data`-Prop an die `IndexPage`-Komponente übergeben. Wenn wir nun das Skript `develop` ausführen, sehen wir die vorab gerenderten Dokumente auf dem Entwicklungsserver unter https://:8000/.

 $ npm run develop

9. Bewertungen zur Laufzeit laden

Um die Bewertungen eines Produkts auf dem Client zu laden, müssen wir einige Änderungen an `src/pages/index.js` vornehmen: 

import { gql } from "apollo-boost"
import { useQuery } from "@apollo/react-hooks"
import { graphql } from "gatsby"
import React, { useState } from "react"
import Layout from "../components/layout"

// Query for fetching at build-time
export const query = graphql

`
{ 
  fauna { 
    allProducts { 
      data { 
        _id title description
        }
      } 
    }
  }
  `

  // Query for fetching on the client
  const GET_REVIEWS = gql
  `
  query GetReviews($productId: ID!) {
    findProductByID(id: $productId) {
      reviews { 
        data { 
          _id username text
        }
      }
    }
  }
`
const IndexPage = props => {
  const [productId, setProductId] = useState(null)
  const { loading, data } = useQuery(GET_REVIEWS, {
    variables: {
      productId
    },
    skip: !productId,
  })
}

export default IndexPage

Gehen wir Schritt für Schritt vor.

Zuerst müssen wir Teile der Pakete `apollo-boost` und `apollo-react` importieren, damit wir den zuvor in `gatsby-browser.js` eingerichteten GraphQL-Client verwenden können.

Dann müssen wir unsere GET_REVIEWS-Abfrage implementieren. Sie versucht, ein Produkt anhand seiner ID zu finden und lädt dann die zugehörigen Bewertungen dieses Produkts. Die Abfrage erwartet eine Variable, die `productId` ist.

In der Komponentenfunktion verwenden wir zwei Hooks: useState und useQuery.

Der `useState`-Hook verwaltet die `productId`, für die wir Bewertungen laden möchten. Wenn ein Benutzer auf eine Schaltfläche klickt, wird der Zustand auf die entsprechende `productId` gesetzt.

Der useQuery-Hook wendet dann diesen productId an, um Bewertungen für dieses Produkt aus FaunaDB zu laden. Der Parameter `skip` des Hooks verhindert die Ausführung der Abfrage, wenn die Seite zum ersten Mal gerendert wird, da `productId` `null` sein wird.

Wenn wir nun den Entwicklungsserver erneut starten und auf die Schaltflächen klicken, sollte unsere Anwendung die Abfrage wie erwartet mit verschiedenen `productIds` ausführen.

$ npm run develop

Fazit

Eine Kombination aus serverseitiger Datenabfrage und clientseitiger Hydration macht JAMstack-Anwendungen sehr leistungsfähig. Diese Methoden ermöglichen eine flexible Interaktion mit unseren Daten, sodass wir unterschiedlichen Geschäftsanforderungen gerecht werden können. 

Es ist im Allgemeinen eine gute Idee, so viele Daten wie möglich zur Build-Zeit zu laden, um die Seitenleistung zu verbessern. Wenn die Daten jedoch nicht von allen Clients benötigt werden oder zu groß sind, um sie auf einmal an den Client zu senden, können wir die Dinge aufteilen und zum bedarfsgesteuerten Laden auf dem Client wechseln. Dies ist der Fall bei benutzerspezifischen Daten, Paginierung oder beliebigen Daten, die sich relativ häufig ändern und zum Zeitpunkt, an dem sie den Benutzer erreichen, möglicherweise veraltet sind.

In diesem Artikel haben wir einen Ansatz implementiert, bei dem ein Teil der Daten zur Build-Zeit geladen wird und der Rest der Daten dann im Frontend geladen wird, während der Benutzer mit der Seite interagiert. 

Natürlich haben wir noch keinen Login oder Formulare zur Erstellung neuer Bewertungen implementiert. Wie würden wir das angehen? Das ist Stoff für ein weiteres Tutorial, in dem wir FaunaDBs attributbasierte Zugriffskontrolle verwenden können, um festzulegen, was ein Client-Schlüssel vom Frontend aus lesen und schreiben kann. 

Der Code für dieses Tutorial ist in diesem Repository zu finden.