Frontend

Jak zrobić statycznę stronę w React bez Reacta

Komponentowe podejście wprowadzone przez bibliotekę React bardzo ułatwiło tworzenie złożonych layoutów. Koncepcja rozdzielenia odpowiedzialności, pozwala rozbijać złożone problemy na dużo mniejszych, prostszych do rozwiązania. Jednak React sam w sobie zajmuje sporo miejsca i oferuje więcej możliwości, które nie zawsze są potrzebne. Tworząc stronę ze złożonym layoutem, bez zaawansowanej logiki, nie zawsze może być warto dodawać go do kodu strony. Dzisiaj omówimy temat jak zbudować stronę w React, która będzie działać bez niego w przeglądarce.


Tomasz Wierzchowski. Frontend Architect w IPFDigital, absolwent Politechniki Warszawskiej. Entuzjasta JavaScriptu i Reacta. Brał udział w wielu projektach komercyjnych, specjalizuje się w tworzeniu aplikacji związanych z branżą finansową. Fan rozwiązań serverless i rozsądnego podejścia do nowinek technicznych.


Cała strona będzie budowana za pomocą webpacka, do styli wykorzystamy CSS Modules. Dzięki temu będziemy mogli łatwo skonfigurować SRR, który jest konieczny oraz zapewnimy brak konfliktów w nazwach klas w stylach.

Za pomocą SSR będziemy tworzyć statyczne pliki HTML. Z założenia strona jest statyczna, dlatego nie ma potrzeby tworzyć serwera, który będzie odpowiadał na zapytania. Kod wszystkich stron będzie budowany przez skrypt uruchamiany po zbudowaniu paczki za pomocą webpacka. Tak naprawdę będziemy budować dwie paczki, jedną z Reactem, drugą z czystym JS odpowiedzialnym za wszystkie interakcje na stronie. Paczka z Reactem będzie służyła nam do wygenerowania plików HTML.

Konfiguracja webpacka

Zacznijmy od skonfigurowania webpacka. Potrzebujemy dwóch bardzo podobnych konfiguracji, dlatego napiszemy funkcję zwracającą nam bazową konfigurację. Żeby nie komplikować zbytnio konfiguracji nie będę rozdzielał trybu deweloperskiego i produkcyjnego, zawsze będziemy zwracać produkcyjny kod. Funkcja musi dawać możliwość dodania pluginów i nadpisania pól konfiguracja (target i entry). Można znaleźć bardzo dużo artykułów na temat webpacka, dlatego nie będę wdawał się w szczegóły, tylko wkleję wynikowy kod, z kilkoma komentarzami:

const { resolve } = require('path');

const webpack = require('webpack');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');


module.exports = ({
  plugins,
  ...rest
}) => () => {
  process.env.NODE_ENV = 'production';

  return {
    /**
     * konfiguracja dla plików wyjściowych
     */
    output: {
      filename: '[name].js',
      path: resolve(__dirname, 'dist'),
    },

    mode: 'production',

    context: resolve(__dirname, 'src'),

    resolve: {
      extensions: ['.js', '.jsx'],
      modules: ['node_modules'],
    },

    module: {
      rules: [
        {
          test: /.jsx?$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'babel-loader',
              /**
               * konfiguracja transpilacji kodu za pomoca Babela
               */
              options: {
                presets: [
                  '@babel/preset-env',
                  '@babel/preset-react',
                ],
                plugins: [
                  ['@babel/plugin-proposal-decorators', {
                    legacy: true,
                  }],
                  ['@babel/plugin-proposal-class-properties', {
                    loose: false,
                  }],
                ],
              },
            },
          ],
        },
        {
          test: /.css/,
          exclude: /node_modules/,
          /**
           * konfiguracja dla css-modules i ekstraktowania styli do osobnego pliku
           */
          use: [
            MiniCssExtractPlugin.loader,
            {
              loader: 'css-loader',
              options: {
                modules: true,
                localIdentName: '[hash:base64:5]',
                camelCase: true,
              },
            },
          ],
        },
      ],
    },

    plugins: [
      new MiniCssExtractPlugin({
        filename: '[name].[hash].css',
      }),
      new webpack.DefinePlugin({
        PROJECT_ROOT: JSON.stringify(resolve(__dirname)),
      }),
      ...plugins,
    ],

    ...rest,
  };
};

Pozostało nam stworzyć plik webpack.config.js, który będzie odpalał obydwie konfiguracje. Zacznijmy od skonfigurowania paczki, która będzie zawierała logikę naszej strony w czystym JS. Nie potrzebujemy robić wiele więcej niż w bazowej konfiguracji. Tylko wskazać plik wejściowy i dodać plugin generujący manifest.json(będziemy potrzebować informacji o dokładnych nazwach wygenerowanych plików dla szablonu strony). Otrzymujemy taki kod:

const ManifestPlugin = require('webpack-manifest-plugin');


const bundleConfig = getWebpackConfig({
  entry: {
    bundle: './bundle.js',
  },
  plugins: [
    new ManifestPlugin(),
  ],
});

Dla kodu odpowiedzialnego za SSR musimy zmienić domyślne środowisko kompilacji na Node.js i dodać plugin odpowiedzialny za uruchomienie skryptu budującego statyczne pliki HTML:

const RunNodeWebpackPlugin = require('run-node-webpack-plugin');


const ssrConfig = getWebpackConfig({
  entry: {
    ssr: './ssr.js',
  },
  target: 'node',
  plugins: [
    new RunNodeWebpackPlugin({ scriptToRun: 'ssr.js' }),
  ],
});

Pozostało nam zwrócić tablicę z konfiguracjami. Kluczowa jest kolejność, ponieważ najpierw musimy wygenerować czysty JS. A dopiero później utworzyć pliki HTML, w których potrzebujemy informacji o hashach dodanych do pliku z JS i styli.

module.exports = [
  bundleConfig,
  ssrConfig,
];

Przydatna funkcja do importowania wielu plików

Webpack udostępnia funkcję require.context, która pozwala na wygodne importowanie wszystkich spełniających jakieś wyrażenie regularne plików. Funkcja ta zwraca funkcję, którą trzeba wywołać na każdym kluczu, nie importuje sama z siebie domyślnych eksportów i klucze mają postać poprawnych ścieżek. Przez co nie jest zbyt wygodna w użyciu. Więcej informacji na ten temat znajdziecie tutaj.

Stwórzmy dodatkową funkcję, która uprzyjemni nam korzystanie z webpackowych kontekstów. Będzie ona przyjmować rezultat wywołania funkcji require.context i obiekt z opcjami pozwalającymi mapować klucze i wartość. Z pudełka będzie importować domyślny eksport danego pliku:

const defaultKeyMapper = key => key;
const defaultValueMapper = val => val.default;

export default (
  r,
  {
    keysMapper = defaultKeyMapper,
    valuesMapper = defaultValueMapper,
  } = {},
) => r.keys().reduce((memo, key) => ({
  ...memo,
  [keysMapper(key)]: valuesMapper(r(key)),
}), {});

Tworzenie bundla z czystym JSem

Chcemy mieć możliwość łatwej interakcji ze stworzonymi komponentami. Dodatkowo korzystamy z hashowanych nazw klas w stylach. Dlatego nie jesteśmy po prostu stworzyć jednego pliku JS, w którym będziemy pobierać potrzebne elementy i wykonywać na nich potrzebne operacja. “Jak za starych dobry czasów”, można powiedzieć. Tworząc logikę strony, będzie nam zależeć na możliwie komponentowym podejściu, dobrze znamy z Reacta.

Aby to osiągnąć potrzebujemy trzech założeń:

1. Każdy komponent jest czystą funkcją i nie ma swojego stanu. Jest to konieczne, ponieważ w przeglądarce nie będzie Reacta. Dlatego nie będziemy mieli możliwości umieszczenia logiki w samym komponencie.

2. Logika każdego komponentu jest umieszczona w osobnym pliku interaction.js. Właśnie w tym miejscu będziemy obsługiwać reakcje na interakcję użytkownika.

3. Każdy komponent swoje style przechowuje w pliku styles.css i posiada wyrenderowaną klasę o nazwie self. Jest to potrzebne ze względu na hashowanie klas. Musimy mieć możliwość znalezienia w drzewie DOM odpowiednich elementów.

Każdy plik interaction.js musi domyślnie eksportować funkcję, która będzie wykonana po załadowaniu się dokumentu. Do funkcji będzie przekazany element odpowiadający za komponent, a zwrócony powinien zostać obiekt z handlerami. Każdy klucz zwracanego obiektu powinien być poprawną nazwą eventu (np.: click). Prosty plik interaction.js może wyglądać w ten sposób:

import styles from './styles.css';


export default (el) => {
  let isRed = el.classList.contains(styles.black);

  return {
    click: (ev) => {
      ev.preventDefault();

      if (isRed) {
        el.classList.replace(styles.red, styles.white);
      } else {
        el.classList.replace(styles.white, styles.red);
      }

      isRed= !isRed;
    },
  };
};

Powyższy komponent będzie zmieniał kolor z czerwonego na biały po kliknięciu i vice versa. Plik styles.css powinien wyglądać w ten sposób:

.self {
  display: flex;
}

.white {
  background-color: white;
}

.red {
  background-color: red;
}

A sam kod komponent w ten sposób:

import React from 'react';
import cx from 'classnames';

import styles from './styles.css;


export default ({
  isRed,
  ...attrs
}) => (
  <div
    {...attrs}
    className={cx(styles.self, isRed ? styles.red : styles.white)}
  />
);

Pozostało nam pobrać wszystkie pliki interaction.js stworzone dla wszystkich komponentów, korzystając z funkcji importAll. Dla każdego modułu trzeba też stworzyć logikę odpowiedzialną za obsługę eventów i przekazywanie elementów. Dla każdej interakcji musimy pobrać wszystkie elementy mające odpowiednią klasę i wywołać nasz kod na każdym z nich. Do każdego handlera w zwróconym obiekcie, powinniśmy też dodać listener.

Najpierw pobierzmy wszystkie pliki z interakcjami i zmapujmy klucze, aby odpowiadały nazwie folderu z komponentem:

const interactions = importAll(
  require.context('./components', true, /interaction.js$/),
  {
    keysMapper: key => key.replace('./', '').replace('/interaction.js', ''),
  },
);

Następnie dla każdej interakcji musimy uzyskać nazwę klasy. Pobrać wszystkie elementy, które zawierają dany styl i dla każdego obiektu wywołać naszą funkcję odpowiedzialną za logikę komponentu:

Object.keys(interactions).forEach((key) => {
  const interaction = interactions[key];
  const styles = require(`./components/${key}/styles.css`);

  Array.from(document.getElementsByClassName(styles.self))
    .forEach((node) => {
      const actions = interaction(node);

      Object.keys(actions)
        .forEach(handler => node.addEventListener(handler, actions[handler]));
    });
});

W ten sposób udało się nam zachować komponentowe podejście do tworzenia aplikacji i odseparować logikę każdego komponentu. Dzięki CSS Modules nie musimy martwić się o konfliktujące nazwy w stylach. Tworząc interakcje nie musimy pamiętać o pobieraniu poszczególnych elementów z drzewa DOM, czy zapinaniu każdego handlera z osobna. Na potrzeby naszej statycznej strony pozbyliśmy się ciężkiej biblioteki, dzięki czemu użytkownik powinien szybciej zobaczyć treść. Pozostało tylko stworzyć zawartość.

Renderowanie statycznych plików HTML

Ostatnim krokiem jest napisanie skryptu odpowiedzialnego za SSR. Załóżmy, że wszystkie strony znajdują się w folderze pages. Potrzebujemy je wszystkie pobrać i wyrenderować do statycznych plików. Aby było to możliwe konieczne jest wykonanie kilku dodatkowych kroków. Najpierw stwórzmy szablon dla wszystkich stron. Do tego celu skorzystałem z silnika doT.js i uzyskałem coś takiego:

<!doctype html>
<html lang="en">
  <head>
    <title>{{= it.title }}</title>
    <meta name="viewport" content="initial-scale=1, maximum-scale=1">
    {{~it.styles :style }}
    <link rel="stylesheet" type="text/css" href="{{= style }}">
    {{~}}
  </head>
  <body>
    {{= it.html }}
    {{~it.scripts :script }}
    <script src="{{= script }}"></script>
    {{~}}
  <body>
</html>

Oczywiście możliwe jest rozbudowanie tego prostego szablonu o obsługę meta tagów, analityki itp. Ale skupmy się tylko na podstawowych funkcjonalnościach. Pobierzmy i skompilujmy nasz szablon:

const templatePath = resolve(PROJECT_ROOT, 'src', 'templates');
const {
  main: template,
} = process({ path: templatePath });

Teraz potrzebujemy pliku manifest.json, w którym mamy informację o finalnych nazwach nazwach plików z naszym JS i stylami. Stała PROJECT_ROOT wskazuje na folder, w którym znajduje się nasza aplikacja i została zdefiniowana w konfiguracji webpacka.

const manifest = readJsonSync(resolve(PROJECT_ROOT, 'dist', 'manifest.json'));
const scripts = [manifest['bundle.js']];
const styles = [manifest['bundle.css']];

Ostatnim krokiem jest pobranie komponentów odpowiedzialnych za poszczególne strony (korzystając z naszej funkcji importAll), wyrenderowanie treści za pomocą funkcji ReactDOMServer.renderToStaticMarkup. Wywołanie funkcji template i zapisanie wyniku jako plik HTML. Całość można zapisać jako:

import { writeFile } from 'fs';
import { resolve } from 'path';

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { ensureFile } from 'fs-extra';



/**
 * opisany wcześniej kod związany z kompilacją szablonu i pobieraniem manifest.json
*/

const pages = importAll(
  require.context('./pages', false, /.jsx$/),
  {
    keysMapper: key => key.replace('jsx', 'html'),
  },
);

Object.keys(pages).forEach((pagePath) => {
  const Page = pages[pagePath];

  const html = ReactDOMServer.renderToStaticMarkup(<Page />);
  const title = Page.title || '';

  const filePath = resolve(PROJECT_ROOT, 'dist', Page.url);
  const fileContent = template({
    html,
    scripts,
    styles,
    title,
  });

  ensureFile(filePath)
    .then(() => (
      writeFile(filePath, fileContent, {
        flag: 'w+',
      })
    ));
});

W ten prosty sposób stworzyliśmy skrypt budujący nam statyczne strony HTML z komponentów napisanych React. Wszystkie strony zdefiniowane w folderze pages są renderowane do osobnych plików, które mogą być serwowane przez dowolny serwer HTTP (apache, nginx, czy choćby AWS S3).

Podsumowanie

Strony produktowe mają często coraz bardziej złożony design. Z jednej strony chcielibyśmy zachować komponentowe podejście, a z drugiej musimy zapewnić możliwie szybkie działanie strony nawet na starszych urządzeniach. Minimalizacja ilości JS zazwyczaj daje wyraźne korzyści (mniejsza paczka szybciej się ściąga i interpretuje). Dzisiaj pokazałem jak można podejść do budowania takich stron i rosnących wymagań, zachowując wygodny proces tworzenia kodu. Komponentowe podejście połączone z CSS Modules pozwala uniknąć wielu przypadkowych błędów z nachodzącymi się nazwami. Brak konieczności pobierania wszystkich elementów i zapinania event handlerów poprawia produktywność.

baner

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://justjoin.it/blog/jak-zrobic-statyczne-strone-w-react-bez-reacta" order_type="social" width="100%" count_of_comments="8" ]