Tutorial: Serverseitiges Rendering mit React – Teil 3: Redux Store

Keine Kommentare

Nachdem im ersten Teil (Tutorial: Serverseitiges Rendering mit React – Teil 1: Einführung) die grundlegende Konfiguration für unsere serverseitig gerenderte App umgesetzt wurde und im zweiten Teil (Tutorial: Serverseitiges Rendering mit React – Teil 2: React Router und React Helmet) React Router und React Helmet thematisiert wurden, schauen wir uns in diesem Teil an, wie wir unseren Redux Store auf dem Server und dem Client nutzen können.

Der Code der fertigen SSR-App kann hier abgerufen werden:
https://gitlab.codecentric.de/rene.bohrenfeldt/react-serverside-rendering-example

4. Redux Store

Die größte Herausforderung beim serverseitigen Rendering ist das Herstellen des richtigen States, damit alle Komponenten so gerendert werden, wie es im Browser passieren würde.

Wir haben z.B. das Problem, dass Lifecycle-Methoden wie componentDidMount() nicht beim serverseitigem Rendering (SSR) ausgeführt werden. Das führt dazu, dass auch keine Daten geladen werden, die typischerweise innerhalb dieser Lifecycle-Methoden abgerufen werden. Das ist ein Problem, dass wir mit einer ganzen Menge an Boilerplate-Code beheben müssen. Gleichzeitig ist dies auch der schwierigste Teil des serverseitigen Renderings.

Ich zeige die Anpassungen beispielhaft an folgender ToDoList-Komponente:

ToDoList.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { fetchToDos } from '../actions';
 
class ToDoList extends Component {
 componentDidMount() {
   this.props.fetchToDos();
 }
 
 renderToDos() {
   return this.props.todos.map(todo => {
     return <li key={todo.id}>{todo.message}</li>;
   });
 }
 
 render() {
   return (
     <div>
       ToDos:
       <ul>{this.renderToDos()}</ul>
     </div>
   );
 }
}
 
function mapStateToProps(state) {
 return { todos: state.todos };
}
 
export default connect(mapStateToProps, { fetchToDos })(ToDoList);

Diese Komponente lädt die ToDos nachdem sie gemountet wurde. Anschließend werden die ToDos in einer Liste angezeigt. Wir wollen erreichen, dass auch beim SSR diese Liste bereits im statischen HTML gerendert wird.

4.1 Redux Store hinzufügen

Wir statten zunächst die beiden Stellen, an denen wir die Routes rendern mit dem Redux Store aus.

src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { createStore, applyMiddleware } from 'redux';import thunk from 'redux-thunk';import { Provider } from 'react-redux';import Routes from './Routes';
import reducers from './reducers'; 
const store = createStore(reducers, {}, applyMiddleware(thunk)) 
ReactDOM.render(
 <Provider store={store}>    <BrowserRouter>
       <Routes />
     </BrowserRouter>
 </Provider>, document.querySelector('#app')
);
server.js
import express from 'express';
import React from "react";
import { Provider } from 'react-redux';import {StaticRouter} from "react-router-dom";
import {createStore, applyMiddleware} from 'redux';import ReactDOM  from 'react-dom/server';
import Routes from "./src/Routes";
import thunk from 'redux-thunk';import reducers from './src/reducers'; 
const app = express();
 
const renderer = (req, store) => { const content = ReactDOM.renderToString(
  <Provider store={store}>    <StaticRouter location={req.path} context={{}}>
      <Routes>
    </StaticRouter>
  </Provider> );
 
 return `
   <html>
     <head></head>
     <body>
       <div id="app">${content}</div>
       <script src="bundle.js"></script>
     </body>
   </html>
 `;
};
 
app.use(express.static('public'));
app.get('*', (req, res) => {
 const store = createStore(reducers, {}, applyMiddleware(thunk)); 
 res.send(renderer(req, store));});
 
app.listen(3000, () => {
 console.log('Server gestartet auf http://localhost:3000');
});

Neu ist hier die Provider-Komponente für den Client und den Server, die den store als Parameter übergeben bekommt und ihn an alle Unterkomponenten weiterreicht.

Die renderer()-Funktion in der server.js bekommt neben dem Request auch einen Store als Parameter übergeben. Dieser Store wird in der app.get()-Methode erzeugt.

4.2 Daten laden

Wir wollen nun sicherstellen, dass alle Komponenten vor dem serverseitigen Rendering ihre Daten laden. Um das zu erreichen, erstellen wir eine Funktion innerhalb der Komponenten-Datei die wir z.B. fetchData() nennen und exportieren diese zusammen mit der eigentlichen Komponente:

ToDoList.js
...
class ToDoList extends Component {
...
}
 
function fetchData(store) {
 return store.dispatch(fetchToDos()); // returns a promise
}
 
export default {
 fetchData,
 component: connect(mapStateToProps, { fetchToDos })(ToDoList);
}

Der Funktion fetchData() werden wir später den Redux Store als Parameter übergeben. Diesen nutzen wir, um die gleiche Action zu dispatchen, die auch innerhalb der componentDidMount()-Methode dispatched wird.

Am Ende exportieren wir ein Objekt mit den Properties fetchData und component anstelle der einzelnen Komponente. Das ist nützlich, wenn wir mit den Routes arbeiten. Aber dazu kommen wir gleich.

4.3 Routes anpassen

Wir wollen dafür sorgen, dass die Methode fetchData() für jede unserer Komponenten aufgerufen wird. Im Prinzip brauchen wir also eine Liste von Komponenten, die für die angefragte Seite gerendert werden sollen. Da hilft uns unsere Routes.js weiter.

Damit wir die Einträge in der Routes.js sehr einfach analysieren und über sie iterieren können, bringen wir sie in ein passendes Format:

Routes.js
import React from 'react';
import App from './App';
import Home from "./components/Home";
import ToDoList from "./components/ToDoList";
 
export default [
  {
    ...App,
    routes: [
      {
        ...Home,
        path: '/',
        exact: true
      },
      {
        ...ToDoList,
        path: '/todos'
      }
    ]
  }
];

Wir sehen hier, dass anstelle der jsx-Syntax nun eine Liste von Objekten für jede Route erzeugt wird. Das ist zwar nicht sehr hübsch, bringt uns aber im Folgenden viele Vorteile. Und wir verlieren hiermit auch keine Funktionalität, denn auch verschachtelte Routes lassen sich definieren, wenn wir sie brauchen.

Weiterhin wird hier der Import von Home und ToDoList dem Route-Objekt per Spread-Operator hinzugefügt (...Home). Da wir bei der ToDoList-Komponente nun ein Objekt anstelle der einzelnen Komponente exportieren, landen hier automatisch die Properties component und fetchData in unserer Route-Konfiguration. Das macht die Liste hier etwas übersichtlicher.

Wir müssen nun auch das Export-Statement in der Home-Komponente und allen anderen Page-Komponenten anpassen, wenn sie in der Routes.js verwendet werden. Jede dieser Komponenten muss ein Objekt exportieren, das mindestens das Property component enthält:

Home.js
...
export default {
  component: Home
}

4.4 Daten aller Komponenten laden

Bevor wir React unser statisches HTML rendern lassen, wollen wir nun die Liste der relevanten Komponenten ermitteln und für jede die fetchData()-Funktion aufrufen. Wir passen also unsere server.js wie folgt an:

server.js
import express from 'express';
import React from "react";
import { Provider } from 'react-redux';
import {StaticRouter} from "react-router-dom";
import {createStore, applyMiddleware} from 'redux';
import ReactDOM  from 'react-dom/server';
import Routes from "./client/Routes";
import { matchRoutes, renderRoutes } from 'react-router-config';import thunk from 'redux-thunk';
import reducers from './src/reducers';
 
const app = express();
 
const renderer = (req, store) => {
const content = ReactDOM.renderToString(
 <Provider store={store}>
   <StaticRouter location={req.path} context={{}}>
     <div>{renderRoutes(Routes)}</div>   </StaticRouter>
 </Provider>
 );
 
 return `
   <html>
     <head></head>
     <body>
       <div id="app">${content}</div>
       <script src="bundle.js"></script>
     </body>
   </html>
 `;
};
 
app.use(express.static('public'));
app.get('*', (req, res) => {
 const store = createStore(reducers, {}, applyMiddleware(thunk));
 
 const promises = matchRoutes(Routes, req.path).map(({ route }) => {  return route.fetchData ? route.fetchData(store) : null; });  Promise.all(promises).then(() => {  res.send(renderer(req, store)); }); 
});
 
app.listen(3000, () => {
 console.log('Server gestartet auf http://localhost:3000');
});

Hier ist eine Menge passiert innerhalb der renderer() und app.get()-Methode:

  • Da wir die Routes angepasst haben, können wir innerhalb des <StaticRouter> nicht mehr die <Routes>-Komponente nutzen, da wir jetzt nicht mehr eine React-Komponente importieren, sondern ein Array von Objekten. Deshalb nutzen wir die Methode renderRoutes() vom Modul react-router-config.
    <StaticRouter location={req.path} context={{}}>
         <div>{renderRoutes(Routes)}</div></StaticRouter>
  • Das gleiche muss übrigens auch innerhalb unserer /src/index.js passieren:
    <BrowserRouter>
     <div>{renderRoutes(Routes)}</div></BrowserRouter>
  • Innerhalb von app.get() nutzen wir die Methode matchRoutes() von react-router-config, um die Routes zu erhalten, die zum aktuellen Request passen.
    matchRoutes(Routes, req.path)
  • Das Ergebnis ist eine Liste von Objekten, die jeweils als Property die Route enthalten, in welcher wiederum, unsere fetchData()-Methode vorhanden ist:
    matchRoutes(Routes, req.path);
    
    // ... produces this result:
    [
      {
        route: { path: ‘/todos’, component: [Object], fetchData: [Function: fetchData] }
        match: {path: ‘/todos’, url: ‘/users’, isExact: true, params: {} }
      },
    ...
    ]
    
  • Wenn eine Route die Methode fetchData() enthält, führen wir diese anschließend innerhalb der map()-Methode aus und speichern das Ergebnis im Array promises. Die fetchData()-Methode gibt jeweils ein Promise zurück.
    const promises = matchRoutes(Routes, req.path).map(({ route }) => {
      return route.fetchData ? route.fetchData(store) : null;
    });
  • Das Ergebnis ist eine Liste von Promises, welche wir im folgenden alle resolven, bevor wir das statische HTML erzeugen:
    Promise.all(promises).then(() => {
      res.send(renderer(req, store));
    });

Damit haben wir alle fetchData()-Methoden ausgeführt und dabei auch unseren Store mit Inhalt gefüllt. Die Ergebnisse der fetchData()-Aufrufe sind also im Store enthalten und diesen nutzen wir nun beim statischen Rendern. Damit haben unsere Komponenten alle Daten, die sie brauchen, um die korrekten Inhalte zu rendern.

4.5 State vom Server zum Client transferieren

Wir haben es fast geschafft, unsere Redux-App serverseitig mit dynamischen Daten zu rendern. Das Einzige, was jetzt noch stört, ist die Fehlermeldung im Browser, die uns sagt, dass der DOM unserer statisch gerenderten Seite nicht mit dem berechneten DOM der dynamischen Seite übereinstimmt:

Warning: Did not expect server HTML to contain a <li> in <ul>.

React erwartet, dass sich der DOM beim initialen Rendern im Browser nicht vom serverseitig gerendertem DOM unterscheidet. Erst durch die Nutzerinteraktion dürfen Änderungen passieren.

Der Grund für die Fehlermeldung ist, dass der State im Browser initial leer ist, wenn die bundle.js geladen wird. Wir haben zwar zuvor auf dem Server unseren State mit Inhalt gefüllt, jedoch wird dieser bisher nur serverseitig für das statische HTML genutzt. Sobald die bundle.js im Browser geladen wird, ist der State wieder leer. Die Folge ist, dass React die Komponenten zunächst mit leeren Inhalt rendert bevor dann die Lifecycle-Methoden greifen und durch die Actions die Daten nachgeladen werden. Um das zu verhindern und den State auch im Client von Anfang an mit den richtigen Daten zu befüllen, müssen wir den State vom Server auf den Client übertragen:

server.js
...
import serialize from 'serialize-javascript'; 
...
 
const renderer = (req, store) => {
...
return `
 <html>
   <head></head>
   <body>
     <div id="app">${content}</div>
     <script>     window.INITIAL_STATE = ${serialize(store.getState())}     </script>     <script src="bundle.js"></script>
   </body>
 </html>
`;
}
src/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { Provider } from 'react-redux';
import { renderRoutes } from 'react-router-config';
import Routes from './Routes';
import reducers from './reducers';
 
const store = createStore(reducers, window.INITIAL_STATE, applyMiddleware(thunk)); 
ReactDOM.hydrate(
 <Provider store={store}>
   <BrowserRouter>
     <div>{renderRoutes(Routes)}</div>
   </BrowserRouter>
 </Provider>,
 document.querySelector('#app')
);

In unserer renderer()-Funktion in der server.js speichern wir den Zustand des Stores in einer globalen Variablen INITIAL_STATE innerhalb unseres Template-Strings. Durch die Methode store.getState() lässt sich dieser Zustand einfach abrufen.

Um XSS-Attacken zu verhindern, escapen wir alle schädlichen Zeichen unter Verwendung des npm-Moduls serialize-javascript.

Innerhalb der index.js müssen wir diese globale Variable window.INITIAL_STATE beim erstellen des Stores nur noch verwenden und voilá, unser State im Client wird mit den Werten vom Server initialisiert und die Fehlermeldung verschwindet.

Puh, das war ein langer Weg, um den Redux State in unserer Applikation sowohl auf den Server als auch im Browser zu nutzen. Der Code sieht zugegebenermaßen nicht sehr schön aus, jedoch gibt es zurzeit keine guten Alternativen dazu.

Zusammenfassung

Wir haben gesehen, dass das serverseitige Rendering keine simple Angelegenheit ist. Jedoch ist SSR für eine SEO-kritische Webseite nach wie vor sehr zu empfehlen, da die Crawler nicht zuverlässig alle Inhalte einer Seite indizieren. Es erfordert einiges an zusätzlichem Code, um eine Seite mit allen dynamischen Inhalten auf dem Server zu rendern.

In diesem Artikel bin ich nicht auf die Themen Authentifizierung innerhalb der React-App eingegangen, genauso wenig wie auf das 404-Handling auf dem Server, wenn bestimmte Routes nicht verfügbar sind. Außerdem kann in der server.js beim Ausführen der fetchData()-Methoden ein Fehler auftreten, der dann zum Abbruch des Promise.all() führen würde. Diese Probleme sind zwar ebenfalls lösbar, erfordern jedoch ebenfalls einiges an Aufwand.

Man muss in seinem eigenen Projekt überlegen, ob der Aufwand des SSR im Verhältnis zum Nutzen steht oder ob man nicht doch lieber eine andere Möglichkeit nutzt, um SEO-relevante Seiten bereit zu stellen. Man ist dabei auch schnell bei der Diskussion, wann eine Single Page Applikation (SPA) überhaupt eine sinnvolle Alternative zur statischen Webseite ist. Wenn man weder den Aufwand für das SSR betreiben möchte noch auf die SPA verzichten will, bleiben nach wie vor Alternativen wie prerender.io, um gegen Bezahlung ein performantes SSR-System zu bekommen. Weiterhin gibt es mittlerweile Frameworks wie z.B. Next.js, die das SSR vereinfachen. Jedoch muss man sich hier auch wieder an das Framework anpassen und verliert ggf. an einigen Stellen die Flexibilität gegenüber der „handgemachten“ Lösung, die ich in dieser Serie vorgestellt habe.

Ich freue mich jederzeit über Kommentare und Hinweise zu anderen Möglichkeiten, SPAs serverseitig zu rendern. Also hinterlasse mir gern deinen Tipp oder deine Kritik im Kommentarfeld weiter unten.

René Bohrenfeldt

René Bohrenfeldt ist seit mehr als 10 Jahren in der Software-Entwicklung tätig und fokussiert sich dabei hauptsächlich auf Frontend-Technologien. Bei der codecentric AG setzt er als IT-Consultant sowohl sein Entwicklungs-Know-how als auch seine Kommunikationsstärke als PO und Moderator ein.
Darüber hinaus engagiert sich der studierte Betriebswirt unternehmerisch und ist Mitgründer einer Busvermietung in Berlin.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.