Ostatnio, we wstępie do serii na temat Server Side Renderingu w React, opowiedziałem co nieco czym jest aplikacja izomorficzna/uniwersalna. W poście tym przedstawiłem też kilka zalet oraz wad tego podejścia. Dziś natomiast przejdziemy do rzeczy. Zajmiemy się bowiem praktyczną stroną tego zagadnienia - skonfigurujemy uniwersalną aplikację React przy użyciu frameworka ExpressJS po stronie serwera!

W ramach dzisiejszego wpisu przedstawię krok po kroku jak skonfigurować Server Side Rendering w React z użyciem frameworka express.js. Najpierw zajmiemy się konfiguracją dla klienta, a następnie przekształcimy ją tak aby obsługiwała też renderowanie po stronie serwera. Aby nie dostarczać zbyt wiele wiedzy na jeden raz, dziś pokażę tylko podstawowy przypadek. W kolejnych odcinkach serii przedstawię natomiast jak go rozszerzyć o Redux oraz react-router.

Założenia i przykładowy komponent

Tworząc aplikację uniwersalną musimy obsłużyć dwa przypadki: renderowanie aplikacji po stronie serwera i po stronie klienta. Kiedy przeglądarka zgłosi się do serwera po stronę, aplikacja przygotuje wszystkie niezbędne dane, wyrenderuje cały potrzebny kod HTML i prześle do przeglądarki. Następnie zostaną załadowane skrypty JavaScript zawierające kliencką wersję naszej aplikacji, dzięki czemu będzie możliwa dalsza interakcja ze stroną.

Aby osiągnąć powyższe, w naszym projekcie znajdą się dwa punkty startowe aplikacji: dla serwera będzie to plik o nazwie server.js, a dla klienta plik o nazwie client.js. Pliki te utworzymy za chwilę. Najpierw jednak potrzebować będziemy głównego komponentu React. Na początek przyjmijmy, że wygląda on w ten sposób (plik App.js):

import React from 'react';
import PropTypes from 'prop-types';

class App extends React.Component {
  static propTypes = {
    initialText: PropTypes.string.isRequired
  }

  constructor(props) {
    super(props);
    this.state = { text: this.props.initialText };
  }

  onButtonClick(event) {
    event.preventDefault();

    this.setState({ text: 'changed in the browser!' });
  }

  render() {
    return (
      <div>
        <p>{this.state.text}</p>
        <button onClick={this.onButtonClick.bind(this)}>change text!</button>
      </div>
    );
  }
}

export default App;

Jak widzisz, jest to dość prosty komponent, który za pomocą obiektu props przyjmuje tekst początkowy (właściwość initialText). Tekst ten jest następnie przypisywany do początkowego stanu komponentu (w konstruktorze) i wyświetlany w metodzie render. Naszym celem będzie tutaj, aby ta początkowa, tekstowa wartość ustawiana była jeszcze po stronie serwera.

Oprócz tego, nasz przykładowy komponent zawiera też guzik. W metodzie obsługi jego zdarzenia (onClick) zmieniamy ustawioną wcześniej wartość stanu komponentu. Ta interakcja będzie się odbywać tylko po stronie klienta.

Renderowanie po stronie klienta - plik client.js

Skoro mamy już komponent, możemy go teraz wyrenderować. Na razie zróbmy to tylko po stronie klienta. Do tego celu utwórzmy plik client.js (jeśli interesuje Cię struktura katalogów, z jakiej tutaj korzystam, zajrzyj do repozytorium GitHub z omawianym dziś przykładem - link na końcu artykułu). Na razie zawartość tego pliku będzie wyglądać następująco:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';

ReactDOM.render(
  <App initialText="rendered on the client side!" />,
  document.getElementById('app')
);

Jeśli programujesz już od jakiegoś czasu w React to powyższy kod powinien być Ci znany (zakładam, że tak bo ten wpis nie jest raczej przeznaczony dla osób zupełnie początkujących). Za pomocą metody render obiektu ReactDOM wstrzykujemy komponent App do wnętrza kontenera (zwykle jest to element div) oznaczonego identyfikatorem “app”. Dzieje się to dopiero po wyświetleniu w przeglądarce pustej strony (zawierającej jedynie wspomniany kontener) i załadowaniu niezbędnych skryptów. Zwróć też uwagę, jak do komponentu przekazywany jest tekst początkowy.

P.S. Oczywiście, aby nasz komponent działał prawidłowo musimy zainstalować kilka pakietów npm. Ja, tradycyjnie, używam do tego celu yarn:

yarn add react react-dom prop-types

Zwróć uwagę, że powyższe pakiety instaluję bez atrybutu --dev - robię tak, ponieważ będę ich potrzebować nie tylko w “bundlu” (który ładowany będzie po stronie klienta) ale też na serwerze.

Konfiguracja webpacka - na razie tylko dla klienta

Powyższe nie zadziała jeśli nie będziemy mieli pliku index.html, który będzie ładować niezbędne skrypty i który będzie zawierać kontener, do którego będziemy “wrzucać” naszą aplikację. Do “ogarnięcia” tego wszystkiego wykorzystam webpacka.

Instalacja pakietów npm

Oczywiście nie obejdzie się bez instalacji kilku niezbędnych pakietów (wystarczy, że będą to zależności “deweloperskie”, ponieważ nie potrzebujemy ich mieć na serwerze). Na potrzeby przykładu wystarczy nam webpack oraz babel-loader (wraz z wymaganym przez niego pakietem babel-core). Do tego dodam też pakiet html-webpack-plugin, o którym za chwilę. Oto komenda instalująca te pakiety:

yarn add --dev webpack babel-loader babel-core html-webpack-plugin

Do powyższych bibliotek dorzućmy też odpowiednie presety Babela (zestawy transformat, dzięki którym Babel wie jak tłumaczyć różne elementy nowej składni):

yarn add --dev babel-preset-env babel-preset-react babel-preset-stage-2

Na koniec instaluję jeszcze pakiet babel-polyfill - tym razem jako normalna zależność, ponieważ jest to biblioteka potrzebna zarówno na serwerze jak i po stronie klienta:

yarn add babel-polyfill

Nie wiesz po co to? Spieszę z wyjaśnieniami: Babel sam w sobie potrafi jedynie tłumaczyć składnię, natomiast specyfikacje kolejnych wersji języka JavaScript wprowadzają też różne nowe metody natywne czy też globalne obiekty (na przykład obiekt Promise) - babel-polyfill pozwala więc na emulowanie pełnego środowiska ES6+.

Pliki .babelrc oraz index.html

Mając zainstalowane powyższe pakiety, mogę przejść do konfiguracji webpacka. Zanim jednak przedstawię moją przykładową konfigurację, zrobię jeszcze dwie rzeczy. Po pierwsze, w głównym katalogu projektu dodam plik .babelrc, w którym znajdzie się taki zapis:

{
  "presets": [
    "env", "stage-2"
  ]
}

Dzięki temu będę mógł zastosować składnię ES6+ w pliku konfiguracyjnym webpacka. Po drugie, dodam do projektu plik index.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Server Side Rendered React App!!</title>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

Plik ten wykorzystamy, przynajmniej na razie (kiedy przejdziemy do konfiguracji renderowania po stronie serwera, przestanie on być potrzebny), jako punkt startowy naszej strony internetowej. Zwróć uwagę, że zawiera on kontener o identyfikatorze app - to właśnie w jego wnętrzu renderowany jest wynikowy kod Reacta. Do pliku tego wstrzykniemy też skrypty wygenerowane przez webpacka - wszystko to dzięki wtyczce html-webpack-plugin.

Plik konfiguracyjny webpacka

Ok, wygląda na to, że mamy już wszystko, co potrzebne jest aby skonfigurować zadania webpacka odpowiedzialne za kliencką część naszej aplikacji. Oto jak w tej chwili wygląda plik webpack.config.babel.js (słowo babel informuje webpacka, że konfiguracja napisana jest z użyciem nowej składni JS):

import path from 'path';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';

const config = {
  entry: {
    client: [
      'babel-polyfill',
      './src/client.js'
    ]
  },
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
  },

  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        include: [path.resolve(__dirname, 'src')],
        query: {
          presets: [
            'env',
            'stage-2',
            'react'
          ]
        }
      }
    ]
  },

  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => /node_modules/.test(module.resource)
    }),
    new HtmlWebpackPlugin({ template: path.resolve(__dirname, 'src', 'index.html') })
  ],

  devtool: 'cheap-module-source-map'
}

export default config;

Objaśnienie konfiguracji

W sekcji entry konfiguruję punkt startowy: jest to przedstawiony wcześniej plik client.js. Zwróć uwagę, że w tym miejscu ładuję też bibliotekę babel-polyfill.

Dalej mamy sekcję output. Tutaj konfiguruję miejsce docelowe na pliki wygenerowane przez webpacka - będzie to katalog build, który znajdzie się w głównym katalogu projektu. Oprócz tego określam jaka ma być nazwa pliku wynikowego: używam tutaj wzorca [name].js, gdzie “name” odpowiada nazwom właściwości obiektu przypisanego do entry. W naszym przypadku mamy jedną właściwość o nazwie client, więc powstanie jeden plik wynikowy o nazwie client.js.

Kolejna sekcja to module. Określamy tutaj jakie “loadery” mają zostać użyte do transformacji poszczególnych typów plików. W naszym przypadku obsługujemy jedynie pliki js/jsx, które transformowane będą za pomocą “loadera” babel-loader.

Na koniec definiuję kilka przydatnych pluginów: dzięki CommonsChunkPlugin, pakiety importowane w wielu miejscach projektu trafią do osobnego pliku wynikowego o nazwie vendor; oprócz tego konfiguruję wspomnianą wcześniej wtyczkę HtmlWebpackPlugin, podstawiając jako szablon utworzony wcześniej plik index.html.

Dzięki powyższej konfiguracji, webpack wygeneruje odpowiednie pliki JavaScript, oraz odpowiednio przetworzy plik index.html (umieści w nim odwołania do wygenerowanych skryptów i wynik również umieści w katalogu build).

Uwaga! Jeśli powyższa konfiguracja jest Ci obca i niezrozumiała, polecam sprawdzić najpierw moją serię wpisów na temat webpacka.

Możemy teraz przetestować naszą konfigurację uruchamiając poniższą komendę (będąc w głównym katalogu projektu):

webpack

To powinno utworzyć katalog build oraz umieścić w nim wszystkie niezbędne pliki. Jeśli chcesz możesz przetestować czy to działa - wystarczy, że z poziomu katalogu build uruchomisz jakiś lokalny serwer, np. na Macu możesz zrobić to tak:

python -m SimpleHTTPServer 8000

Teraz wystarczy, że otworzysz stronę localhost:8000 w przeglądarce i powinieneś zobaczyć efekt naszej dotychczasowej pracy.

Renderowanie po stronie serwera

Przedstawione dotychczas rozwiązanie działa w ten sposób, że do przygotowanego wcześniej pliku index.html wstrzykujemy odpowiednie skrypty JavaScript. Te, po załadowaniu się, dodają “w locie” kod React do kontenera o identyfikatorze “app”. Teraz przyszedł czas na dodanie do naszej aplikacji renderowania po stronie serwera. Naszym celem będzie sprawienie, aby po wysłaniu przez przeglądarkę żądania, kod React był wstrzykiwany do kontenera “app” jeszcze na serwerze. W ten sposób przeglądarka dostanie plik index.html, który jest już odpowiednio wypełniony i nie ma potrzeby czekania na załadowanie się skryptów JS.

Za chwilę opiszę wszystkie niezbędne zmiany w projekcie, najpierw jednak musimy doinstalować kilka pakietów npm. Najpierw jedna zależność “developerska”:

yarn add --dev webpack-node-externals

Oprócz tego potrzebna nam będzie jedna zależność “zwykła”:

yarn add express

Komponent Html

W przedstawionym powyżej kodzie klienckim, do generowania pliku index.html wykorzystana została wtyczka HtmlWebpackPlugin. W przypadku SSR nie jest to jednak rozwiązanie wystarczająco wygodne. Potrzebujemy bowiem mechanizmu, dzięki któremu łatwo wstrzykniemy komponenty React do kontenera. Dlatego też, poprzednie rozwiązanie zastąpię komponentem React o nazwie Html.js. Będzie on “placeholderem” na resztę kodu i będzie służył do wygenerowania odpowiedniego, wynikowego pliku index.html. Spójrz na przykład tego komponentu:

import React from 'react';
import PropTypes from 'prop-types';

class Html extends React.Component {
  static propTypes = {
    children: PropTypes.node.isRequired,
    scripts: PropTypes.array
  }

  render () {
    const { children, scripts } = this.props;

    return (
      <html>
        <head>
          <meta charSet="UTF-8" />
          <title>Server Side Rendered React App!!</title>
        </head>
        <body>
          <div id="app"
               dangerouslySetInnerHTML={{ __html: children }}
          ></div>
          {scripts.map((item, index) => {
            return <script key={index} src={item}></script>;
          })}
        </body>
      </html>
    );
  }
}

export default Html;

Jak widzisz, metoda render zwraca “szkielet” naszej strony - zawiera znacznik html, w którym znajdują się elementy head oraz body. Jednak dzięki temu, że jest to komponent React, mogę do niego przekazać kilka rzeczy za pomocą obiektu props. W naszym przypadku są to dwie właściwości: children oraz scripts.

Pierwsza z nich zawierać będzie całe drzewo komponentów React wyredenderowane do postaci ciągu znaków. Zwróć uwagę w jaki sposób wykorzystuję tę właściwość: przypisuję ją do właściwości __html obiektu, który następnie przypisuję do atrybutu dangerouslySetInnerHTML kontenera “app”. Jest to “reactowy” odpowiednik właściwości innerHTML dostępnego w DOM: dzięki temu ciąg znaków przekazany we właściwości children potraktowany zostanie jako HTML, a nie jako “string”.

Druga z przekazanych w obiekcie props wartości - scripts - zawiera ścieżki do plików JavaScript wygenerowanych przez webpacka. Skrypty te nadal muszą się ładować na naszej stronie, tak aby wszelkie interakcje wykonywane przez użytkownika również obsługiwane były przez React.

Renderowanie po stronie serwera - plik server.js

Mając zdefiniowany komponent Html możemy przejść do najważniejszej części konfiguracji SSR, a więc pliku server.js. Plik ten stanie się punktem startowym całej naszej aplikacji - będzie uruchamiany na serwerze posiadającym obsługę Node.js (jest to główne ograniczenie opisywanego rozwiązania).

Sam Node.js nam jednak nie wystarczy. Potrzebujemy czegoś do obsługi żądań HTTP wysyłanych przez przeglądarkę. Do tego celu wykorzystamy wspomniany wcześniej, tytułowy framework Express.js.

Uwaga! Opisanie wszystkich możliwości tego frameworka wykracza poza ramy tego wpisu, dlatego teraz skupię się tylko na objaśnieniu jego użycia w przypadku konfiguracji SSR. Nie wykluczam jednak, że kiedyś pojawi się na blogu coś więcej na jego temat…

Wracając jednak do tematu - oto przykład zawartości pliku server.js odpowiedzialnej za renderowanie aplikacji React po stronie serwera:

import express from 'express';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';

import Html from './components/Html';
import App from './components/App';

const app = express();

app.use(express.static(path.join(__dirname)));

app.get('*', async (req, res, next) => {
  const scripts = ['vendor.js', 'client.js'];

  const appMarkup = ReactDOMServer.renderToString(
    <App initialText="rendered on the server" />
  );
  const html = ReactDOMServer.renderToStaticMarkup(
    <Html children={appMarkup} scripts={scripts} />
  );

  res.send(`<!doctype html>${html}`);
});

app.listen(3000, () => console.log('Listening on localhost:3000'));

Objaśnienie przykładu

Po pierwsze zwróć uwagę na import ReactDOMServer z pliku server.js należącego do pakietu react-dom. Jest to część tego pakietu dostarczająca narzędzi do renderowania kodu React po stronie serwera. Z narzędzi tych korzystam w dalszej części przykładu.

Dalej wywołuję funkcję express, a wynik jej działania przypisywany jest do zmiennej app. W ten sposób inicjalizuję aplikację Express.js. W kolejnej linii informuję Express.js, z jakiego katalogu mają być serwowane pliki statyczne (takie jak, na przykład, wygenerowane przez webpacka pliki JavaScript).

W następnej linii (wywołanie app.get) rozpoczyna się najciekawsza część przykładu, ale o tym za chwilę. Najpierw spójrz na sam koniec - wywołanie metody listen obiektu app powoduje, że kod zawarty w pliku server.js nie kończy działania tylko zaczyna nasłuchiwać, w tym przypadku na porcie 3000.

Funkcja obsługująca żądanie

Wróćmy teraz do funkcji obsługującej żądania wysyłane przez przeglądarkę. Jako pierwszy parametr wywołania funkcji get podajemy adres jaki chcemy obsługiwać. Podając gwiazdkę informujemy Express.js, że funkcja przekazana jako drugi parametr metody get ma obsługiwać wszystkie żądania GET.

Sama funkcja obsługująca żądanie przyjmuje, w naszym przypadku, dwa parametry: req (obiekt żądania), res (obiekt odpowiedzi). Ten drugi parametr wykorzystamy na końcu funkcji, najpierw jednak dzieje się jeszcze kilka rzeczy.

Po pierwsze definiuję tablicę zawierającą ścieżki do skryptów wygenerowanych wcześniej przez webpacka. Za chwilę tablicę tę przekażę do komponentu Html, opisanego powyżej.

Po drugie wywołuję metodę renderToString obiektu ReactDOMServer. Przekazuję do niej komponent App wypełniając przy okazji atrybut initialText, który jak wiemy, jest wymagany. W ten sposób renderuję całą aplikację React w jej stanie początkowym (initialText będzie ustawione!) do kodu HTML, który przedstawiony jest w postaci ciągu znaków.

Po trzecie wywołuję metodę renderToStaticMarkup obiektu ReactDOMServer. Metoda ta robi praktycznie to samo co renderToString z tą tylko różnicą, że pomija wszelkie wykorzystywane przez React (po stronie klienta) atrybuty HTML. Jako parametr tej metody przekazuję komponent Html i wypełniam odpowiednio jego wymagane atrybuty: children - tutaj przekazuję ciąg znaków wygenerowany na podstawie komponentu App; scripts - przekazuję też tablicę zawierającą skrypty JavaScript do pobrania po stronie klienta.

Na koniec wywoływana jest metoda send obiektu res. Skutkuje to wysłaniem do przeglądarki wygenerowanego w poprzednich krokach kodu HTML.

Zmiany w konfiguracji webpacka

Plik server.js, jako że importuje komponenty React oraz korzysta ze składni ES6+, również powinien być przepuszczony przez webpacka. Poniżej przedstawiam zmieniony plik webpack.config.babel.js:

import path from 'path';
import webpack from 'webpack';
import nodeExternals from 'webpack-node-externals';

const common = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: 'babel-loader',
        include: [path.resolve(__dirname, 'src')],
        query: {
          presets: [
            'env',
            'stage-2',
            'react'
          ]
        }
      }
    ]
  }
}

const clientConfig = {
  ...common,

  name: 'client',
  target: 'web',

  entry: {
    client: [
      'babel-polyfill',
      './src/client.js'
    ]
  },
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: '[name].js'
  },

  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: 'vendor',
      minChunks: module => /node_modules/.test(module.resource)
    }),
  ],

  devtool: 'cheap-module-source-map',

  node: {
    fs: 'empty',
    net: 'empty',
    tls: 'empty',
  }
}

const serverConfig = {
  ...common,

  name: 'server',
  target: 'node',
  externals: [nodeExternals()],

  entry: {
    server: ['babel-polyfill', path.resolve(__dirname, 'src', 'server.js')]
  },
  output: {
    path: path.resolve(__dirname, 'build'),
    filename: 'server.js'
  },

  devtool: 'cheap-module-source-map',

  node: {
    console: false,
    global: false,
    process: false,
    Buffer: false,
    __filename: false,
    __dirname: false,
  }
}

export default [clientConfig, serverConfig];

Objaśnienie przykładu

Żeby nie przedłużać, nie będę szczegółowo omawiać wszystkich zmian. Skupię się tylko na tym co najważniejsze…

Po pierwsze, wydzieliłem dwie konfiguracje: jedna dla klienta, o nazwie clientConfig; druga dla serwera, o nazwie serverConfig. Oprócz tego wydzieliłem część wspólną common, w której znalazła się konfiguracja “loaderów”. Na koniec zwracam obie konfiguracje w tablicy - webpack umie to “łyknąć”.

Jeśli chodzi o konfigurację dla klienta to nie zmieniło się w niej prawie nic - zniknęła tylko obsługa wtyczki HtmlWebpackPlugin, która nie jest już potrzebna. Ponadto doszła właściwość node, która pozwala określić niezbędne “polyfille” oraz “mocki” dla pewnych specyficznych dla Node.js rzeczy - więcej tutaj. Zwróć też uwagę na ustawienia name oraz target - określające przeznaczenie plików wynikowych.

To co doszło, to konfiguracja dla serwera (serverConfig). Tutaj wykorzystujemy te same “loadery” co w przypadku ustawień klienckich. Ustawienie target tym razem wskazuje na Node.js, doszła też właściwość externals, która określa wymagane zależności, które jednak nie powinny znaleźć się w wynikowym pliku. Tutaj skorzystałem z gotowca - biblioteki webpack-node-externals. Najważniejsze są oczywiście właściwości entry oraz output. Punktem wejściowym jest oczywiście plik server.js (tutaj również wstrzykujemy też babel-polyfill). Plik wynikowy trafia natomiast do katalogu build, a jego nazwa to po prostu server.js.

Próbne uruchomienie

I to w zasadzie wszystko co trzeba zmienić w konfiguracji webpacka. Możemy teraz przetestować działanie naszych ustawień. Dla pewności usuń katalog build, a następnie wywołaj polecenie:

webpack && node ./build/server.js

Spowoduje to ponowne wygenerowanie przez webpack plików JavaScript, a następnie uruchomienie serwera Node.js, który nasłuchuje na porcie 3000. Otwórz w przeglądarce adres http://localhost:3000 i zobacz co się stanie (efekt nie jest do końca taki jak chcieliśmy - musimy dokonać jeszcze kilku małych zmian, o których poniżej).

Wspólny stan początkowy

Jeśli wykonałeś opisane powyżej próbne uruchomienie (i dobrze się przyjrzysz) zauważysz, że na ekranie przez ułamek sekundy wyświetla się tekst “rendered on the server” co świadczy, że wyświetlony został kod wygenerowany na serwerze (sprawdź plik server.js, atrybut initialText komponentu App). Potem jednak tekst ten zmienia się na “rendered on the client side!” (zajrzyj do pliku client.js, ten sam atrybut).

Świadczy to o tym, że po wyrenderowaniu kodu HTML otrzymanego z serwera, a następnie załadowaniu skryptów po stronie klienta wykonuje się kod zawarty w pliku client.js, który nadpisuje początkowy stan komponentu App. Zresztą jeśli zajrzysz do konsoli narzędzi developerskich Twojej przeglądarki, znajdziesz tam błąd mówiący, że teksty z serwera i z klienta nie pasują.

Aby temu zaradzić, musimy rozszerzyć nasz przykład o kod, który będzie przekazywał stan początkowy zdefiniowany po stronie serwera do klienta, tak aby komponenty renderowane po stronie klienta, również inicjowane były tymi samymi wartościami.

Zapisywanie stanu początkowego - plik Html.js

Modyfikację mojego przykładu rozpocznę od zmian w pliku Html.js. Oto, jak ten plik wygląda po dodaniu do niego kilku dodatkowych linijek kodu:

import React from 'react';
import PropTypes from 'prop-types';

class Html extends React.Component {
  static propTypes = {
    children: PropTypes.node.isRequired,
    initialState: PropTypes.object,
    scripts: PropTypes.array
  }

  render () {
    const { children, initialState, scripts } = this.props;

    return (
      <html>
        <head>
          <meta charSet="UTF-8" />
          <title>Server Side Rendered React App!!</title>
        </head>
        <body>
          <div id="app"
               dangerouslySetInnerHTML={{ __html: children }}
          ></div>
          {initialState && (
            <script
              dangerouslySetInnerHTML={{
                __html: `window.APP_STATE=${JSON.stringify(initialState)}`
              }}
            ></script>
          )}
          {scripts.map((item, index) => {
            return <script key={index} src={item}></script>;
          })}
        </body>
      </html>
    );
  }
}

export default Html;

Po pierwsze: poprzez obiekt props przekazywany jest dodatkowa wartość - obiekt initialState. Po drugie: do body dodano skrypt, w którym ustawiana jest wartość window.APP_STATE, do której przypisywana jest wartość właściwości initialState (rzecz jasna, skonwertowana do formatu JSON).

Oczywiście skrypt ten wywołany zostanie dopiero po wyrenderowaniu się strony w przeglądarce, nie ma więc obaw, że obiekt window nie będzie dostępny. Podejrzewam, że już domyślasz się jak to będzie działać…

Definiowanie stanu początkowego - plik server.js

Skoro zmienił się komponent Html to zmiany te musimy uwzględnić w pliku server.js:

import express from 'express';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';

import Html from './components/Html';
import App from './components/App';

const app = express();

app.use(express.static(path.join(__dirname)));

app.get('*', async (req, res) => {
  const scripts = ['vendor.js', 'client.js'];

  const initialState = { initialText: "rendered on the server" };

  const appMarkup = ReactDOMServer.renderToString(<App {...initialState} />);
  const html = ReactDOMServer.renderToStaticMarkup(
    <Html children={appMarkup}
          scripts={scripts}
          initialState={initialState} />
  );

  res.send(`<!doctype html>${html}`);
});

app.listen(3000, () => console.log('Listening on localhost:3000'));

Zmiany nie są duże. Po pierwsze utworzyłem obiekt stanu początkowego initialState, który zawiera właściwość initialText.

Po drugie zawartość tego obiektu przekazuję jako atrybuty komponentu App - zapis:

<App {...initialState} />

…jest tożsamy z zapisem:

<App initialText={initialState.initialText} />

Ostatnia zmiana to przekazanie obiektu initialState poprzez atrybut do komponentu Html. W ten sposób wynikowy kod HTML będzie zawierał skrypt, który przypisze ten obiekt do globalnej zmiennej APP_STATE.

Użycie stanu początkowego po stronie klienta - plik client.js

Na koniec pozostała nam modyfikacja w pliku client.js:

import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';

ReactDOM.hydrate(
  <App {...window.APP_STATE} />,
  document.getElementById('app')
);

Jak widzisz, tym razem do komponentu App przekazuję właściwości obiektu APP_STATE zapisanego w obiekcie window (również na pewno istnieje, ponieważ powyższy kod wywołany zostanie dopiero w przeglądarce.

Uwaga! Zwróć uwagę na jeszcze jedną zmianę - tym razem zamiast ReactDOM.render wywołuję ReactDOM.hydrate. Od wersji 16.0 Reacta, jest to jedyny prawidłowy sposób na “podłączanie” drzewa komponentów React, które wcześniej zostały wyrenderowane po stronie serwera! Od wersji 17.0 Reacta, użycie do tego metody render będzie skutkowało błędem (teraz dostajemy tylko ostrzeżenie).

I to tyle - możesz teraz ponownie przetestować działanie tego kodu (usunąć katalog build, a następnie wywołać komendę webpack && node ./build/server.js). Tym razem powinien się wyświetlić tekst początkowy zdefiniowany na serwerze. Oczywiście interakcje po stronie klienta również powinny działać prawidłowo (naciśnij guzik aby to przetestować).

Podsumowanie

Uff… Wyszło całkiem sporo tekstu - ciekawe czy ktoś to przeczyta w całości. Mam nadzieję, że pomimo objętości wpisu wyjaśniłem wszystko w miarę zrozumiale i nie będziesz mieć problemów z zastosowaniem tych przykładów w praktyce. W razie czego zapraszam do pytań w komentarzach lub na priv!

W kolejnej części tej serii planuję opisać jak do powyższego przykładu dodać obsługę Reduxa oraz react-routera!

Uwaga! Tak jak wspomniałem na początku tekstu, cały przykład dostępny jest na GitHubie - wystarczy, że sklonujesz to repozytorium i wykonasz polecenia opisane w pliku README.md.


P.S. Ten wpis jest częścią serii wpisów na temat Server Side Renderingu w React! Poniżej lista wszystkich wpisów tej serii: