Без NextJS или других фреймворков.

React — это библиотека, обычно используемая для разработки одностраничных приложений (SPA), которые в значительной степени зависят от JavaScript.

На самом деле, когда запрос делается из браузера, React возвращает пустую HTML-страницу, а контент фактически загружается с помощью JavaScript после начальной загрузки страницы.

Хотя этот подход работает, следует учитывать несколько существенных недостатков.

  • Плохо для поисковой оптимизации (SEO), поскольку исходный HTML, возвращаемый React, пуст, что затрудняет ранжирование в поисковых системах.
  • Время ожидания начального рендеринга страницы может быть больше из-за выборки больших пакетов JavaScript.

Чтобы противостоять этим проблемам, мы можем прибегнуть к рендерингу на стороне сервера (SSR).

Проверьте код на Github, если вы хотите сразу перейти к коду https://github.com/rajgaur98/react-ssr/tree/main.

Рендеринг на стороне сервера

Рендеринг на стороне сервера (SSR) — это метод, который отображает веб-страницу на сервере и отправляет ее обратно клиенту.

SSR возвращает браузеру полностью отрендеренную HTML-страницу, что может помочь смягчить проблемы, упомянутые ранее, такие как плохое SEO и более длительное время ожидания начальной отрисовки страницы.

Такие фреймворки, как NextJS, уже поддерживают SSR «из коробки», но его также можно реализовать с нуля. В этом процессе участвуют три шага:

  1. Генерация HTML на сервере
  2. Рендеринг HTML, полученного с сервера, на клиенте
  3. Добавление JavaScript в статический HTML на клиенте (также известное как гидратация)

Это станет более ясно, когда мы начнем писать код.

Настраивать

Мы будем использовать ExpressJS для настройки нашего веб-сервера и, конечно же, React и ReactDOM для нашего интерфейса.

Кроме того, нам также понадобятся Babel и Webpack для транспиляции и объединения кода нашего модуля в скрипты, понятные браузерам.

Чтобы установить необходимые зависимости, выполните следующую команду:

yarn add express react react-dom

Чтобы установить зависимости разработки, выполните следующую команду:

yarn add -D @babel/core @babel/preset-env @babel/preset-react @webpack-cli/generators babel-loader webpack webpack-cli

Создание внешнего интерфейса

Мы будем хранить все наши интерфейсные файлы в папке client внутри нашей корневой папки. Эта папка будет содержать типичное приложение React и ничего сложного.

Давайте создадим файл index.jsx, который будет служить корнем нашего приложения React.

import React from "react";
import { hydrateRoot } from "react-dom/client";
import { App } from "./App.jsx";

hydrateRoot(document, <App />);

Обратите внимание, что мы используем функцию hydrateRoot вместо функции createRoot. Это потому, что у нас уже есть HTML-документ из серверной части, и теперь нам нужно только прикрепить к нему JavaScript.

Прежде чем мы двинемся дальше, давайте больше разберемся в гидратации.

Когда мы визуализируем наши веб-страницы на сервере, сервер выводит простой статический HTML-документ.

Хотя это подходит для показа начального рендеринга пользователю, сервер не прикрепляет прослушиватели событий или JavaScript к коду. Он возвращает только статическую страницу.

Ответственность за присоединение JavaScript к этому HTML-коду, отображаемому сервером, лежит на клиентской части. Поэтому процесс прикрепления JavaScript к этой статической, сухой странице называется Hydration.

Итак, как видно из последней строки кода, мы используем функцию hydrateRoot для присоединения JavaScript к отображаемому сервером document с помощью компонента App.

Теперь давайте создадим компонент приложения.

import React from "react";

export const App = () => {
  return (
    <html>
      <head>
      </head>
      <body>
        <div id="root">App</div>
      </body>
    </html>
  );
};

Важно отметить, что весь HTML-код включен в компонент приложения, так как он будет отображаться на сервере, и мы хотим, чтобы сервер возвращал полный HTML-код.

Причина этого в том, что сервер может использовать структуру HTML для вставки в HTML необходимых импортов JavaScript и CSS в виде скриптов и тегов ссылок.

А пока давайте сосредоточимся на рендеринге этого простого приложения на стороне сервера, так как позже мы добавим в приложение больше сложностей.

Управление активами

Включение ресурсов, таких как CSS, в рендеринг на стороне сервера можно выполнить либо путем добавления тега link в head HTML, либо с помощью подключаемых модулей Webpack.

Например, вы можете использовать css-loader и MiniCssExtractPlugin, чтобы извлечь CSS из файлов .jsx и внедрить их как тег link. Эти плагины позволяют использовать импорт CSS в файлах .jsx следующим образом:

import React from "react"

import "../styles/main.css"

Однако в этом случае мы использовали ручной подход для внедрения листов в HTML, как показано ниже:

<head>
  <link rel="stylesheet" href="styles/Root.css"></link>
</head>

Для изображений мы использовали статические пути ExpressJS вместо их импорта в .jsx файлов.

<img src="optimus.png" alt="Profile Picture" />

Если вы предпочитаете импортировать изображения в .jsx, вы можете использовать следующее решение Webpack:

module: {
 rules: [
  {
    test: /\.(png|svg|jpg|jpeg|gif)$/i,
    type: 'asset/resource',
  },
 ],
},

Создание сервера

Мы будем хранить файлы нашего сервера в папке src в нашем корневом каталоге. Сервер прост, состоит из двух файлов: index.js и render.js.

index.js содержит стандартный экспресс-код с одним маршрутом.

const express = require("express");
const path = require("path");
const app = express();
const { render } = require("./render");

app.use(express.static(path.resolve(__dirname, "../build")));
app.use(express.static(path.resolve(__dirname, "../assets")));

app.get("/", (req, res) => {
  render(res);
});

app.listen(3000, () => {
  console.log("listening on port 3000");
});

Давайте еще посмотрим на файл render.js, он интересный.

import React from "react";
import { renderToPipeableStream } from "react-dom/server";
import { App } from "../client/App.jsx";

export const render = (response) => {
  const stream = renderToPipeableStream(<App />, {
    bootstrapScripts: ["client.bundle.js"],
    onShellReady() {
      response.setHeader("content-type", "text/html");
      stream.pipe(response);
    },
  });
};

Обратите внимание, что мы можем использовать операторы ESM import внутри файла render.js, потому что мы перенесем его позже с babel.

Импорт React вверху необходим, иначе веб-страница не будет отображаться и будет выдана ошибка.

Затем мы импортируем renderToPipeableStream, который будет использоваться для рендеринга нашего компонента App в HTML.

Функция renderToPipeableStream принимает два параметра: компонент App и опции.

Параметр bootstrapScripts представляет собой массив, содержащий пути к сценариям, которые будут обрабатывать HTML.

Сценарий client.bundle.js — это выходной пакет нашей точки входа client/index.jsx. Если вы помните, функция hydrateRoot находится внутри client/index.jsx.

Функция onShellReady запускается после того, как исходный HTML-код готов. Мы можем начать потоковую передачу HTML, используя stream.pipe(response), чтобы постепенно отправлять его в браузер.

Это простое приложение SSR завершено и готово к работе! Однако запуск всего кода как есть приведет к ошибкам. Сначала нам нужно связать клиентский и серверный код.

Объединение модулей

Нам понадобятся файлы конфигурации babel.config.json и webpack.config.js в корневой папке, чтобы транспилировать и связать наш код.

// babel.config.json

{
  "presets": ["@babel/preset-env", "@babel/preset-react"]
}

Babel будет обрабатывать импорт модуля ECMAScript (ESM), JSX и другой нестандартный код JavaScript, используя предустановки, указанные в файле выше.

// webpack.config.js

const path = require("path");

const clientConfig = {
  target: "web",
  entry: "./client/index.jsx",
  output: {
    filename: "client.bundle.js",
    path: path.resolve(__dirname, "build"),
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/i,
        loader: "babel-loader",
      },
    ],
  },
};

const serverConfig = {
  target: "node",
  entry: "./src/index.js",
  output: {
    filename: "server.bundle.js",
    path: path.resolve(__dirname, "build"),
  },
  module: {
    rules: [
      {
        test: /\.(js|jsx)$/i,
        loader: "babel-loader",
      },
    ],
  },
};

module.exports = [clientConfig, serverConfig];

Для конфигурации клиента мы устанавливаем свойство target на web, точку entry на client/index.jsx и output на client.bundle.js.

Для конфигурации сервера мы устанавливаем свойство target на node, точку entry на src/index.js и output на server.bundle.js.

Мы также добавим скрипты в package.json для сборки и запуска нашего кода.

// package.json

{
  // ...
  "scripts": {
    "start": "yarn build:dev && node build/server.bundle.js",
    "build": "webpack --mode=production --node-env=production",
    "build:dev": "webpack --mode=development",
    "build:prod": "webpack --mode=production --node-env=production"
  }
}

Теперь, когда мы запускаем yarn start, клиентский и серверный код должны быть объединены в папку build, и наш сервер должен работать! Если мы сделаем запрос к http://localhost:3000, мы должны увидеть наше простое приложение в браузере.

Изучив вкладку сети, мы должны увидеть сгенерированный сервером HTML-код из localhost:3000. На вкладке элементов мы должны увидеть тег <script src=”client.bundle.js” async=””></script>, вставленный перед тегом </body>.

Поддержка саспенса

Давайте начнем добавлять контент в наше приложение. Мы добавим боковую панель, страницу блога и компонент «Похожие блоги». Чтобы организовать наш код, мы реорганизуем App.jsx в отдельные компоненты.

Мы также реорганизуем нашу HTML-разметку в компонент с именем Html. Это означает, что компонент App теперь должен выглядеть следующим образом:

import React, { lazy, Suspense } from "react";
import { Html } from "./Html.jsx";
import { Loading } from "./components/Loading.jsx";

const Sidebar = lazy(() =>
  import("./components/Sidebar.jsx" /* webpackPrefetch: true */)
);
const Main = lazy(() =>
  import("./components/Main.jsx" /* webpackPrefetch: true */)
);
const Extra = lazy(() =>
  import("./components/Extra.jsx" /* webpackPrefetch: true */)
);

export const App = () => {
  return (
    <Html>
      <Suspense fallback={<Loading />}>
        <Sidebar></Sidebar>
        <Suspense fallback={<Loading />}>
          <Main></Main>
          <Suspense fallback={<Loading />}>
            <Extra></Extra>
          </Suspense>
        </Suspense>
      </Suspense>
    </Html>
  );
};

Наше приложение теперь имеет некоторые интересные функции. Разберем их:

  • Sidebar, Main и Extra являются стандартными компонентами React, поэтому мы пока не будем вдаваться в подробности их кода.
  • lazy: используется для ленивой загрузки компонентов. Это означает, что они импортируются только тогда, когда их необходимо визуализировать. Это приводит к разделению кода в Webpack, создавая отдельные файлы пакетов для каждого компонента в папке build. Эти файлы загружаются после загрузки пакета client.bundle.js в браузере.
  • Suspense: это компонент React, который обрабатывает одновременную выборку данных и рендеринг. Он ожидает загрузки компонентов lazy по запросу и показывает индикатор загрузки (используя реквизит fallback), пока они не будут готовы.

В нашем случае это играет важную роль в потоковой передаче HTML с сервера. HTML-код отправляется постепенно в виде потока, при этом компонент Suspense ожидает загрузки компонентов по мере необходимости.

Например, сначала отправляется HTML до корня div, затем компонент Sidebar, затем компонент Main и, наконец, компонент Extra.

Это улучшает взаимодействие с пользователем, поскольку пользователю не нужно ждать, пока все компоненты загрузятся одновременно. Компоненты отображаются постепенно по мере их появления.

Чтобы увидеть это в действии, вы можете искусственно задержать загрузку компонента в компоненте App.

const Sidebar = lazy(
  () =>
    new Promise((resolve) => {
      setTimeout(
        () =>
          resolve(
            import("./components/Sidebar.jsx" /* webpackPrefetch: true */)
          ),
        1000
      );
    })
);
const Main = lazy(
  () =>
    new Promise((resolve) => {
      setTimeout(
        () =>
          resolve(import("./components/Main.jsx" /* webpackPrefetch: true */)),
        2000
      );
    })
);
const Extra = lazy(
  () =>
    new Promise((resolve) => {
      setTimeout(
        () =>
          resolve(import("./components/Extra.jsx" /* webpackPrefetch: true */)),
        3000
      );
    })
);

Управление потоком HTML

Вы можете дополнительно управлять потоковой передачей HTML с помощью параметров в методе renderToPipableStream.

Вы можете использовать onAllReady вместо опции onShellReady, если хотите передать весь HTML-код сразу после загрузки всей страницы, а не при первоначальном рендеринге.

const stream = renderToPipeableStream(<App />, {
  bootstrapScripts: ["client.bundle.js"],
  onAllReady() {
    response.setHeader("content-type", "text/html");
    stream.pipe(response);
  },
});

Рендеринг на стороне сервера также можно остановить, чтобы остальная часть рендеринга могла выполняться на стороне клиента. Вы можете добиться этого, используя следующее:

const stream = renderToPipeableStream(<App />, {
  bootstrapScripts: ["client.bundle.js"],
  onShellReady() {
    response.setHeader("content-type", "text/html");
    stream.pipe(response);
  },
});

setTimeout(() => {
  stream.abort()
}, 10000)

Заключение

Вот и все, ребята. Я надеюсь, что эта статья помогла вам понять сложности рендеринга компонентов React на стороне сервера. Спасибо за чтение!

Полезные ссылки

Найдите больше подобного контента на SliceOfDev.com