Der Fullstack-Lebenszyklus einer React-Komponente

Komponentenbasierte Frameworks wie React, Vue oder Angular erlauben es uns, Komponenten isoliert zu betrachten. Wir müssen uns keine Gedanken darüber machen, wie unser Code gebundled, kompiliert und im Browser des Benutzers ausgeführt wird, da die Komponente selbst vollständig davon entkoppelt ist. Es gibt jedoch Situationen, in denen das Wissen über den gesamten Lebenszyklus einer Komponente von Vorteil ist. In diesem Artikel möchte ich daher durch den gesamten Lebenszyklus einer React-Komponente führen, angefangen mit dem Schreiben des Codes der Komponente, der statischen Analyse dieses Codes, der ersten Verwendung, dem Buildprozess vor der Ausführung, dem Rendering auf dem Server und schließlich der Ausführung auf dem Client.

Schreiben wir die erste Komponente

Lass uns direkt loslegen und unsere erste einfache Reakt-Komponente schreiben:

import React, { useEffect, useState } from "react";

export function CounterButton(props: { initialCount: number }) {
  const [count, setCount] = useState(props.initialCount);

  useEffect(() => {
    console.log("Initial count: " + props.initialCount);
  }, [props.initialCount]);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}

Wie Du bereits weißt, sind React-Komponenten einfache JavaScript/TypeScript-Funktionen, die ein Argument benötigen: props. Dieses Objekt enthält alle Daten und die Konfigurationen, die zum Rendern unserer Komponente benötigt werden. In unserem Fall haben wir die TypeScript-Typdefinition { initialCount: number } hinzugefügt, so dass wir und andere Entwickler wissen, wie diese Komponente zu verwenden ist.

Statische Prüfung mit TypeScript und ESLint

Sobald der Source Code in der IDE steht oder man sogar auf Speichern drückt, schauen bereits viele Werkzeuge bereits auf den Code und versuchen, ihn zu interpretieren: ESLint könnte prüfen, ob wir die useState-Hook korrekt und nicht bedingt oder in einer Schleife verwendet haben, Prettier kann den Code neu formatieren, sodass er gemäß der Richtlinien aussieht, und TypeScript prüft, ob alle eingegebenen Funktionen korrekt verwendet, alle erforderlichen Importe definiert und die richtigen Props an die button-Hostkomponente übergeben werden. All dies passiert, bevor der Code auch nur einmal ausgeführt wird, und trägt dazu bei, die Feedbackschleife von Code schreiben, Ergebnis prüfen, Fehler beheben, Ergebnis prüfen und Stolz sein zu reduzieren.

Verwendung der Komponente

Nun haben wir unsere erste Komponente umgesetzt! Allerdings wird mit diesem Code noch nichts passieren, wenn ihn niemand benutzt.

import React from "react";
import { CounterButton } from "./counter-button";

export function App() {
  return (
    <div>
      <CounterButton initialCount={0} />
      <CounterButton initialCount={999} />
    </div>
  );
}

Natürlich verwenden wir unsere React-Komponente in einer anderen React-Komponente in einer anderen Datei. Wie zuvor, sobald wir diese Datei schreiben/speichern, analysieren viele Werkzeuge diese Datei, überprüfen Konventionen, prüfen, ob wir die richtigen Props an unsere CounterButton-Komponente übergeben haben — alles ohne den Komponentencode selbst auszuführen.

Bundling der Anwendung

Leider unterstützen nicht alle Browser diese modulare Art, Code in getrennte Dateien zu schreiben. Aus diesem Grund brauchen wir Bundler wie Webpack, Parcel oder rollup. Wir sagen diesen Tools einfach, welche Datei der Einstiegspunkt is und sie durchsuchen die Import-Anweisungen und erzeugen eine oder wenige resultierende Dateien, die vom Browser in einer performanteren Art und Weise geladen werden können.

Zusätzlich haben diese Bundler die Option, den Code zu transformieren. In unserem Beispiel verwenden wir TypeScript, um unseren Code mit Informationen zu den Typen zu annotieren. Browser verstehen diese Typen nicht, weshalb unsere Build-Kette (die vom Bundler orchestriert wird) all diese Typ-Anmerkungen entfernt. Darüber hinaus verwendet React eine Spracherweiterung namens JSX (bzw. TSX bei TypeScript), die es uns erlaubt, diese seltsamen HTML-ähnlichen Tags direkt in unseren Code zu schreiben. Auch hier unterstützen die Browser diese nicht, so dass wir sie entfernen müssen, indem wir jeden Tag in einen Aufruf von React.createElement umwandeln. Das Ergebnis dieser Transformationen sieht so aus, wenn wir Parcel verwenden um nur unseren CounterButton zu kompilieren:

const react_1 = __importStar(require("react"));

function CounterButton(props) {
  const [count, setCount] = react_1.useState(props.initialCount);
  react_1.useEffect(() => {
    console.log("Initial count: " + props.initialCount);
  }, [props.initialCount]);
  return react_1.default.createElement(
    "button",
    {
      onClick: () => setCount(count + 1),
    },
    "Count: ",
    count
  );
}

exports.CounterButton = CounterButton;

Bitte beachte, dass ich den gesamten Bibliothekscode von React sowie den Setup-Code des Bundlers in der resultierenden Datei weggelassen habe. Im Falle einer Produktionsanwendung würde der Bundler sogar noch einen Schritt weiter gehen. Da die Variablennamen und Leerräume wertvolle Bytes benötigen, die an den Benutzer übertragen werden müssen, optimieren Bundler hier weiter, indem sie einen Minifier oder Uglyfier verwenden, um Leerzeichen zu entfernen und Variablennamen zu verkürzen.

Rendern der Anwendung auf dem Server (SSR)

Nachdem wir unseren Code gebundled haben, ist es an der Zeit, dass er zum ersten Mal ausgeführt wird. In vielen Fällen wollen wir aber nicht, dass unser Code nur auf dem Client läuft. Um schnellere Ladezeiten und bessere SEO/Google-Ergebnisse zu erzielen, generieren wir auf dem Server echtes HTML, das wir dann an den Client senden können. Für diesen Fall müssen wir einen Einstiegspunkt erstellen, der auf dem Server ausgeführt wird: server.tsx

import express from "express";
import fs from "fs";
import React from "react";
import { renderToString } from "react-dom/server";
import { App } from "./App";

const app = express();

app.get("/", (req, res) => {
  const reactString = renderToString(<App />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <head></head>
      <body>
        <div id="root">${reactString}</div>
        <script src="client.js"></script>
      </body>
    </html>
  `);
});

app.get("/client.js", (req, res) => {
  fs.createReadStream("./dist/client.js").pipe(res);
});

app.listen(8080);

Diese Datei wird auf dem Server ausgeführt und startet einen Webserver auf Port 8080. Immer wenn wir einen get-Request unter / erhalten, rufen wir die renderToString-Methode von React auf, die alle Funktionen der React-Komponenten aufruft, Props an die Kindkomponenten weitergibt, die Zustandsvariablen mit den Anfangswerten (0 und 999) initialisiert und dann den resultierenden HTML-String erstellt. Es ist sehr wichtig zu beachten, dass keine der Lebenszyklus-Methoden von React während dieses serverseitigen Rendering-Prozesses aufgerufen wird. Wir können zwar useEffect, useLayoutEffect oder componentDidMount in den Komponenten verwenden, sie werden aber bei der Generierung des HTMLs ignoriert. Falls asynchrone Daten für den serverseitigen Rendering-Prozess benötigt werden, müssen diese vor dem Aufruf von renderToString geladen werden, da Side Effects (in useEffect) nicht auf dem Server ausgeführt werden. (componentWillMount wird zwar aufgerufen, wird aber in zukünftigen Versionen nicht mehr unterstützt). Das bedeutet, dass unser console.log aus der useEffect-Hook zu diesem Zeitpunkt noch nicht aufgerufen wird.

Zusätzlich konfigurieren wir unseren Webserver so, dass Anfragen an /client.js die entsprechende Client-JavaScript-Datei erhalten, die wir uns als nächstes anschauen werden.

Interessiert an React? Folge mir auf Twitter, damit du keine Beiträge mehr verpasst.

Wenn wir nun den Browser auf http://localhost:8080 öffnen, erhalten wir das folgende HTML-Dokument zurück:

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <div id="root">
      <div data-reactroot="">
        <button>Count: 0</button>
        <button>Count: 999</button>
      </div>
    </div>
    <script src="client.js"></script>
  </body>
</html>

Übernahme auf dem Client

Wie bereits erwähnt, wurde bis jetzt alles JavaScript auf dem Server ausgeführt. Wir haben den Webserver gestartet und für jede Anfrage rufen wir alle React-Komponenten auf (ohne ihre Lebenszyklus-Methoden und -Effekte auszuführen) und erzeugen die reine HTML-Darstellung.

Der nächste Schritt ist die Erstellung eines Client-Bundles, das im Browser des Benutzers ausgeführt wird. Ziel ist es, das vom Server generierte HTML zu übernehmen. Das machen wir, indem wir React's hydrate Funktion in einer neuen Datei client.tsx verwenden:

import React from "react";
import { hydrate } from "react-dom";
import { App } from "./App";

const appRoot = document.getElementById("root");

hydrate(<App />, appRoot);

Auch hier müssen wir unserem Bundler wieder sagen, dass diese Datei ein neuer Einstiegspunkt ist, wodurch Typen entfernt, alle importieren Dateien gebundled und JSX/TSX entfernt werden. Durch die Verwendung von hydrate weisen wir React an, die Generierung von HTML-Elementen beim ersten Rendern zu überspringen (weil sie bereits im HTML-Dokument vorhanden sind). Wenn der Benutzer nun http://localhost:8080 besucht, wird das HTML in der Node-App serverseitig generiert, vom Browser geladen und dem Benutzer angezeigt. Der Browser parst dieses Dokument, bis er das Skript-Tag erreicht. Dadurch wird der Browser angewiesen, diese Datei von unserem Server anzufordern. Wenn sie geladen und geparst ist, beginnt die Ausführung des Client-seitigen Bundles mit unserem hydrate-Aufruf. Beachte, dass während dessen die Seite bereits mit Inhalten aus der HTML-Datei befüllt ist, sodass der Benutzer nicht auf eine leere Seite starren muss.

Durch den Aufruf von hydrate instruieren wir React, eine virtuelle Repräsentation des DOM zu erstellen, indem alle unsere Komponentenfunktionen aufgerufen werden. Im Falle des initialen Renderns sollte dieser v-DOM immer mit dem vom Server gesendeten DOM übereinstimmen. Zusätzlich registriert React alle definierten Event-Listener (wie unsere Klick-Handler auf den Buttons) auf den bestehenden DOM-Knoten. Nachdem dieser Prozess abgeschlossen ist, werden unsere Effekte aufgerufen, so dass wir Daten abrufen, Datenquellen abonnieren oder jeden anderen Side Effect ausführen können.

Ich möchte einen Punkt in diesem Prozess hervorheben: Der Benutzer kann bereits den Inhalt der Seite sehen, lange bevor React geladen und initialisiert wurde und bereit ist, auf Benutzerinteraktionen zu reagieren. Das bedeutet, dass diese erste Version der Seite so benutzbar wie möglich sein muss. Jede Aktualisierung des Inhalts, die durch einen useEffect-Aufruf ausgelöst wird, führt zu ruckartigen Sprüngen in der Benutzeroberfläche.

Wie wir sehen, muss React eine ganze Menge Arbeit leisten, bis eine erste voll funktionsfähige Webanwendung dargestellt werden kann. Aus diesem Grund ist es so wichtig, den DOM auf dem Server (oder während des Build-Prozesses im Falle von Static Site Generators wie Gatsby.js) zu generieren, wenn es sich um Inhaltsbasierte Sieten wie einen Blog oder einen Nachrichtenartikel handelt.

Zustand aktualisieren

Wie bereits gesagt, ist unsere Anwendung jetzt bereit für Interaktion. Nehmen wir an, der Benutzer klickt nun auf den Button in unserer ersten CounterButton-Komponente. Dies löst unseren Event-Listener in der jeweiligen Button-Komponenten-Instanz aus (ja, auch bei Verwendung von Funktionskomponenten gibt es Instanzen unserer Komponenten), der wiederum den stateSetter aufruft. Dies weist React an, den Rendering-Prozess von dieser Komponenteninstanz aus den ganzen Baum abwärts neu zu starten (oder bis React auf eine manuell optimierte Komponente ohne Prop-Änderungen stößt). Eine neue Version des v-DOM wird erstellt, ein Vergleich zur vorherigen Version wird durchgeführt und Patches für den DOM werden generiert und dann "commited". Nachdem React den DOM so geändert hat, dass die angeklickte Schaltfläche nun eine erhöhte Anzahl zeigt, ruft React alle Effekte auf, bei denen sich mindestens eine Abhängigkeit geändert hat. Wenn einer dieser Effekte einen weiteren setState-Aufruf auslöst, startet der Prozess wieder von vorne.

Der Benutzer sieht nun die App im neuen Zustand: Eine 2 auf dem ersten CounterButton und der Effekt des Buttons logt die nächste Nummer auf die Konsole. Die App wartet nun wieder auf Interaktion.

Aufräumen vor dem "unmount"

In unserem Fall werden die Komponenten nie aus dem Komponentenbaum entfernt, sodass wir nie irgendwelche Aufräumarbeiten durchführen müssen. Stellen wir uns jedoch vor, dass diese Anwendung nur auf einer URL in einer großen Sinle Page Application sichtbar ist. Was passiert jetzt, wenn wir zu einer anderen Route navigieren?

  1. React erstellt zunächst den neuen virtuellen DOM der nächsten Seite (es wurden noch keine realen DOM-Knoten verändert).
  2. React prüft nun, welche Komponenteninstanzen nicht mehr benötigt werden und ruft die Cleanup-Funktionen der useEffect-Aufrufe auf.
  3. Nachdem die Cleanup-Funktion aufgerufen wurde, werden die entsprechenden DOM-Knoten aus dem DOM entfernt.

Eine lange Reise

Das ist es. Die komplette Reise einer React-Komponente. Natürlich könnten wir noch einen Schritt weiter gehen und auch die Schritte des Refactoring einer React-Komponente behandeln, aber das ist ein Thema für einen anderen Artikel.