Как стать автором
Обновить

React Server-Side Rendering (SSR) — руководство новичка

Время на прочтение24 мин
Количество просмотров95K
Автор оригинала: Uday Hiwarale

В этом уроке мы поговорим о серверном рендеринге (SSR), его преимуществах и подводных камнях. Затем мы создадим мини React проект и express сервер (Node.js), чтобы продемонстрировать, как можно достичь SSR.

Почти каждый второй сайт на данный момент является одностраничным приложением (SPA). Я уверен вы знаете, что такое одностраничное приложение. Такие фреймворки как Angular, React, Vue, Svelte и т.д. находятся на подъеме из-за их способности быстро и эффективно разрабатывать SPA. Они идеально подходят не только для быстрого прототипирования, но и для разработки сложных веб-приложений (если всё сделано правильно).

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

Пользователю приходилось ждать несколько секунд, пока сервер получит запрос, соберет данные, составит HTML и вернет ответ. Так как это было полностраничной загрузкой, браузер должен был ждать все ресурсы, такие как JavaScript, CSS, и другие файлы, чтобы загрузить их снова (если только они не кешируются должным образом). Это было огромным неудобством для пользователя.

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

Таким образом, мы предотвращаем полную перезагрузку страницы и значительно улучшаем время загрузки страницы. Мы программно изменяем URL в браузере с помощью History API, что не приводит к обновлению браузера. Так как обновление страницы никогда не происходит, мы запрашиваем только начальный HTML, который включает в себя JavaScript, CSS и другие средства для всего приложения.

Для любой страницы, такой как example.com/ или example.com/settings (если она доступна напрямую через ввод URL в браузере), наш сервер посылает один и тот же HTML и ресурсы в ответ. Приложение JavaScript читает URL-адрес в браузере, видит пути, например / или /settings, и рендерит компоненты, связанные с данными путями, на стороне клиента. Эти компоненты затем делают запрос к серверу на получение нужных им данных. Поэтому типичный HTML-ответ от сервера для таких SPA выглядит так, как показано ниже.

код
// index.html

<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width,initial-scale=1">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>SPA Application</title>
        <meta name="description" content="My SPA website.">
        <link rel="icon" href="/assets/favicon.ico">
        <link href="styles.css" rel="stylesheet">
    </head>

    <body>
        <div id="app"></div>
        <script src="vendor.js"></script>
        <script src="main.js"></script>
    </body>
</html>

В приведенном выше HTML, <div id="app"></div> является контейнером или корневым элементом SPA. Весь HTML приложения, генерируемый нашим JavaScript, будет вставляться внутрь этого элемента на стороне клиента.

Давайте поговорим о преимуществах и подводных камнях SPA.

= Преимущества

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

  2. Так как все ресурсы приложения, такие как JavaScript и CSS файлы, загружаются только один раз и никогда не запрашиваются снова после загрузки приложения, мы сильно экономим на пропускной способности сервера.

  3. После начальной загрузки приложения мы запрашиваем у сервера только данные размером в несколько килобайт (по запросу). Таким образом, SPA идеально подходят для использования в условиях плохой сети.

  4. Всё приложение может быть кешировано на клиенте (устройстве) с помощью service worker. Таким образом, при следующем обращении пользователя к сайту, браузеру больше не нужно будет загружать HTML и ресурсы. Когда пользовательское устройство не подключено к интернету, вместо отображения сообщения браузера по умолчанию "Не подключено к интернету!", мы можем отобразить пользовательский экран, который даст пользователю оффлайн-доступ.

  5. Пользователи могут сохранить SPA как приложение на устройстве. Если вы заинтересованы в разработке мобильного приложения но не хотите тратить деньги на разработку нативных приложений (Android или iOS), SPA открывают возможность создать приложение похожее на нативное, используя ту же самую кодовую базу веб-сайта.

= Подводные камни

  1. Так как SPA должен обслуживать все JavaScript и CSS файлы приложения вместе (обычно), эти файлы громоздки (несколько мегабайт). Следовательно, начальная загрузка приложения требует значительно больше времени, что означает, что пользователь будет видеть пустой экран в течение довольно долгого времени. При плохой сети это может быть серьезной проблемой. Однако, мы можем исправить это с помощью SSR.

  2. В SPA, так как JavaScript управляет генерацией HTML на стороне клиента, SPA делают тяжелую работу на стороне клиента, которую раньше выполнял сервер. Поэтому SPA нуждаются в устройствах с хорошими возможностями в части процессора и батареи.

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

Из приведенного выше сравнения видно, что преимущества SPA превосходят их подводные камни. Наши устройства с каждым днем становятся все более производительными, а условия работы сети становятся все лучше и лучше. Поэтому нам больше не нужно беспокоиться об этом.

Тем не менее, каждый сайт хочет занять первое место в результатах поиска. Когда дело доходит до SPA, этого не очень легко достичь. Как мы уже говорили, когда поисковая система (crawler), такая как Google, видит наш веб-сайт, она видит HTML с пустым элементом <div id="app"></div>, т.к. большинство поисковых роботов читают только HTML, возвращаемый сервером, и не запускают приложение так, как это сделал бы наш браузер. Это происходит в отношении любой страницы сайта.

Поэтому существует большая вероятность того, что ваш сайт никогда не окажется на первых нескольких страницах результатов поиска. Так как же мы можем это исправить? Единственный способ исправить это - генерировать HTML на сервере для данной страницы, но только при первой загрузке.

Причина, по которой я выделил "при первой загрузке", заключается в том, что этот подход не похож на традиционный рендеринг HTML на стороне сервера всего приложения. Здесь мы генерируем HTML на сервере только один раз для запрашиваемой страницы, так что поисковые системы видят правильный HTML, в то время как приложение будет вести себя в браузере точно так же.

Этот метод имеет еще одно дополнительное преимущество. Так как сервер будет возвращать правильный HTML для страницы, пользователь больше не будет видеть пустой экран, пока все ресурсы приложения не будут загружены. Нам для этого нужно будет настроить несколько параметров, но зато это будет очень удобно для пользователя.

Если вам интересно разобраться, как браузер визуализирует веб-сайт, прочтите эту статью. Так как сервер уже отрендерил HTML для данной страницы, нам нужно будет загрузить все JavaScript ресурсы после того, как произойдет событие DOMContentLoaded. Поэтому убедитесь, что теги <script> в HTML имеют атрибут defer.

Такой процесс рендеринга HTML на стороне сервера называется server-side rendering или SSR. Теперь, когда у нас есть некоторое понимание того, как должен работать SSR, давайте посмотрим, как мы можем достичь его в React приложении.

Настройка React проекта

Прежде всего, давайте создадим простое React приложение. Вы можете использовать CLI-инструмент, такой как create-react-app, чтобы сгенерировать React-проект или клонировать стандартный React boilerplate c GitHub, но для этого урока давайте создадим кастомный Webpack-проект. Исходный код этого проекта можно найти в этом GitHub репозитории. Вы также можете следовать подходу из моей предыдущей статьи о тестировании React.

В текущем примере проекта мы используем Webpack для транспиляции React и ES6 в JavaScript с помощью Babel. Также мы будем использовать SCSS (Sass) для генерации CSS для приложения и получения его в виде отдельного файла styles.css. Наше JavaScript приложение будет распределено между файлами main.js и vendor.js. Для этого нам понадобятся следующие настройки.

код
// babel.config.js

module.exports = {
    presets: ['@babel/env', '@babel/react'],
    plugins: [
        '@babel/plugin-transform-runtime',
        '@babel/plugin-transform-async-to-generator',
        '@babel/transform-arrow-functions',
        '@babel/proposal-object-rest-spread',
        '@babel/proposal-class-properties'
    ]
};
// package.json

{
  "name": "react-ssr",
  "description": "A React server-side rendering (SSR) sample project.",
  "version": "1.0.0",
  "scripts": {
    "start": "NODE_ENV=development webpack serve",
    "build": "NODE_ENV=production webpack"
  },
  "dependencies": {
    "react": "^17.0.1",
    "react-dom": "^17.0.1"
  },
  "devDependencies": {
    "@babel/core": "^7.12.9",
    "@babel/plugin-proposal-class-properties": "^7.12.1",
    "@babel/plugin-proposal-object-rest-spread": "^7.12.1",
    "@babel/plugin-transform-arrow-functions": "^7.12.1",
    "@babel/plugin-transform-async-to-generator": "^7.12.1",
    "@babel/plugin-transform-runtime": "^7.12.1",
    "@babel/preset-env": "^7.12.7",
    "@babel/preset-react": "^7.12.7",
    "@babel/runtime": "^7.12.5",
    "babel-loader": "^8.2.2",
    "copy-webpack-plugin": "^6.3.2",
    "css-loader": "^5.0.1",
    "html-webpack-plugin": "^4.5.0",
    "mini-css-extract-plugin": "^1.3.2",
    "node-sass": "^5.0.0",
    "sass-loader": "^10.1.0",
    "webpack": "^5.10.0",
    "webpack-cli": "^4.2.0",
    "webpack-dev-server": "^3.11.0"
  }
}
// src\components\app\app.component.jsx

import React from 'react';

// импорт дочерних компонентов
import { Counter } from '../counter';

// экспорт главного компонента приложения
export class App extends React.Component {
    constructor() {
        console.log( 'App.constructor()' );
        super();
    }

    // рендер представления
    render() {
        console.log( 'App.render()' );

        return (
            <div className='ui-app'>
                <Counter name='Monica Geller'/>
            </div>
        );
    }
}
// src\index.html

<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <meta http-equiv='X-UA-Compatible' content='ie=edge'>
    <title>React Boilerplate / Webpack 4 / Babel 7</title>
    <meta name="description" content="React boilerplate with Webpack 4 and Babel 7">
    <link rel='icon' href='/assets/favicon.ico'>
</head>
<body>
    <div id="app"></div>
</body>
</html>
// src\index.js

import React from 'react';
import ReactDOM from 'react-dom';

// импорт App
import { App } from './components/app';

// обработка компонента App и помещение его в HTML элемент '#app'
ReactDOM.render( <App/>, document.getElementById( 'app' ) );
// webpack.config.js

const path = require( 'path' );
const HTMLWebpackPlugin = require( 'html-webpack-plugin' );
const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
const CopyWebpackPlugin = require( 'copy-webpack-plugin' );

/*-------------------------------------------------*/

module.exports = {

    // режим webpack оптимизации
    mode: ( 'development' === process.env.NODE_ENV ? 'development' : 'production' ),

    // начальные файлы
    entry: [
        './src/index.js', // react
    ],

    // выходные файлы и чанки
    output: {
        path: path.resolve( __dirname, 'dist' ),
        filename: 'build/[name].js',
    },

    // module/loaders configuration
    module: {
        rules: [
            {
                test: /\.jsx?$/,
                exclude: /node_modules/,
                use: [ 'babel-loader' ]
            },
            {
                test: /\.scss$/,
                use: [ MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader' ]
            }
        ]
    },

    // webpack плагины
    plugins: [

        // выделение css во внешний файл таблицы стилей
        new MiniCssExtractPlugin( {
            filename: 'build/styles.css'
        } ),

        // подготовка HTML файла с ресурсами
        new HTMLWebpackPlugin( {
            filename: 'index.html',
            template: path.resolve( __dirname, 'src/index.html' ),
            minify: false,
        } ),

        // копирование статических файлов из `src` в `dist`
        new CopyWebpackPlugin( {
            patterns: [
                {
                    from: path.resolve( __dirname, 'src/assets' ),
                    to: path.resolve( __dirname, 'dist/assets' )
                }
            ]
        } ),
    ],

    // настройка распознавания файлов
    resolve: {
        
        // расширения файлов
        extensions: [ '.js', '.jsx', '.scss' ],
    },

    // webpack оптимизации
    optimization: {
        splitChunks: {
            cacheGroups: {
                default: false,
                vendors: false,
                
                vendor: {
                    chunks: 'all', // both : consider sync + async chunks for evaluation 
                    name: 'vendor', // имя чанк-файла
                    test: /node_modules/, // test regular expression
                }
            }
        }
    },

    // настройки сервера разработки
    devServer: {
        port: 8088,
        historyApiFallback: true,
    },

    // генерировать source map
    devtool: 'source-map'

};

В вышеприведенных настройках src/index.js является точкой входа транспиляции, а src/components/app - компонентом входа в приложение. Компонент App по умолчанию рендерит компонент Counter. Как только мы запустим сервер разработки с помощью команды $ npm run start, мы должны увидеть следующий экран в браузере.

Когда мы нажимаем на кнопку INCREMENT, счетчик увеличивается на 1 до тех пор, пока не достигнет 3, и затем кнопка становится неактивной. Если мы откроем инструменты разработчика (Chrome DevTools), мы сможем увидеть сгенерированный HTML. Как мы знаем, наш index.html имел только пустой элемент <div id="app"></div>. HTML, который мы видим выше, генерируется на стороне клиента компонентом App.

Если мы посмотрим на ответ от сервера разработки, мы всё ещё увидим пустой элемент <div id="app"></div> (как показано ниже).

Так как это не годится для поискового робота, нам необходимо заполнить элемент <div id="app"></div> соответствующим HTML на самом сервере. На стороне клиента, используя функцию ReactDOM.render(), мы смогли загрузить компонент App внутрь этого элемента. Мы должны следовать аналогичному подходу на сервере, но это не так просто, как кажется. Продолжаем.

Настройка сервера для SSR

В настоящее время мы используем сервер разработки Webpack. Это не то, что мы собираемся использовать в продакшене. Скорее всего, мы будем использовать NGINX, Apache, или Node.js сервер. Но если мы хотим отрендерить React-приложение на стороне сервера, то мы должны использовать только сервер Node.js (из-за JavaScript). Как вариант, NGINX или Apache могут стоять в качестве обратного прокси-сервера.

Выполним сборку приложения, используя команду $ npm run build, которая создаст файлы сборки в каталоге dist. Вот файлы, которые мы должны обслуживать на нашем HTTP сервере.

dist/
├── assets
|  └── favicon.ico
├── build
|  ├── main.js
|  ├── main.js.map
|  ├── styles.css
|  ├── styles.css.map
|  ├── vendor.js
|  └── vendor.js.map
└── index.html

Для нашего проекта-примера я собираюсь использовать Express.js для создания HTTP-сервера. Вы можете выбрать любой другой фреймворк по своему усмотрению, так как SSR от этого не зависит. Давайте создадим файл server/express.js и напишем логику обслуживания файла index.html для всех маршрутов, кроме JS, CSS и других файлов ресурсов нашего веб-приложения.

server/
├── express.js
└── index.js

Мы запустим server/index.js, используя node, который импортирует файл server/express.js. Причина, по которой мы используем здесь index.js в том, что нам нужно настроить поведение express.js позже для выполнения SSR.

код
// server/express.js

const express = require( 'express' );
const fs = require( 'fs' );
const path = require( 'path' );
// создание express приложения
const app = express();
// обслуживание статических ресурсов
app.get( /\.(js|css|map|ico)$/, express.static( path.resolve( __dirname, '../dist' ) ) );
// в ответ на любые другие запросы отправляем 'index.html'
app.use( '*', ( req, res ) => {
// читаем файл `index.html`
let indexHTML = fs.readFileSync( path.resolve( __dirname, '../dist/index.html' ), {
    encoding: 'utf8',
} );

// устанавливаем заголовок и статус
res.contentType( 'text/html' );
res.status( 200 );

return res.send( indexHTML );

} );
// запускаем сервер на порту 9000
app.listen( '9000', () => {
    console.log( 'Express server started at <http://localhost:9000>' );
} );
// server/index.js

require( './express.js' );

Теперь используем команду $ node server/index.js или просто добавим "start:ssr": "node server" в "scripts" файла package.json и выполнив команду $ npm run start:ssr, мы запустим наш HTTP-сервер, который обслуживает файлы из директории dist.

Наш HTTP-сервер работает на 9000 порту. Из приведенного выше скриншота, кажется, что все работает просто отлично, за исключением того, что в данный момент мы не выполняем SSR, поэтому элемент <div id="app"></div> все еще пуст. Сейчас мы находимся в той точке, когда мы должны начать думать о рендеринге на стороне сервера.

В случае с браузером, файл src/index.js является точкой входа приложения. Он импортирует компонент App и делает его рендеринг помещая результат в элемент #app, как показано ниже.

// импорт компонента App
import { App } from './components/app';

// обработка компонента App и помещение результата в HTML элемент '#app'
ReactDOM.render( <App/>, document.getElementById( 'app' ) );

Мы должны сделать то же самое на сервере. При обслуживании index.html, мы должны заполнить <div id="app"></div> HTML компонентом App. Итак, вопрос в том, как получить HTML из App компонента.

Пакет react-dom/server предоставляет нам функцию renderToString, которая принимает элемент (экземпляр компонента) точно так же, как и ReactDOM.render(), но возвращает HTML-строку вместо того, чтобы заполнять DOM-элемент. Итак, давайте изменим файл server/express.js и заполним элемент #div возвращаемым элементом перед возвращением ответа браузеру.

код
// server/express.js

const express = require( 'express' );
const fs = require( 'fs' );
const path = require( 'path' );
const React = require( 'react' );
const ReactDOMServer = require( 'react-dom/server' );
// create express application
const app = express();
// импорт компонента App
const { App } = require( '../src/components/app' ); 
// обслуживание статических ресурсов
app.get( /\.(js|css|map|ico)$/, express.static( path.resolve( __dirname, '../dist' ) ) );
// в ответ на любые другие запросы отправляем 'index.html'
app.use( '*', ( req, res ) => {
// читаем файл `index.html`
let indexHTML = fs.readFileSync( path.resolve( __dirname, '../dist/index.html' ), {
    encoding: 'utf8',
} );

// получаем HTML строку из компонента 'App'
let appHTML = ReactDOMServer.renderToString( <App />; );

// заполняем элемент '#app' содержимым из 'appHTML'
indexHTML = indexHTML.replace( '<div id="app"></div>', `<div id="app">${ appHTML }</div>` ); );

// устанавливаем заголовок и статус
res.contentType( 'text/html' );
res.status( 200 );

return res.send( indexHTML );

} );
// запускаем сервер на порту 9000
app.listen( '9000', () => {
    console.log( 'Express server started at <http://localhost:9000>' );
} );
// server/index.js

const path = require( 'path' );

// игнорируем импорты `.scss`
require( 'ignore-styles' );

// транспилируем на лету импорты
require( '@babel/register')( {
    configFile: path.resolve( __dirname, '../babel.config.js' ),
} );

// импортируем express-сервер
require( './express.js' );

В вышеуказанной программе express.js мы импортируем компонент App и получаем на базе него HTML-строку, которая может быть отрендерена в браузере. Затем мы заполняем элемент #app этой строкой. Однако, теперь мы импортируем React компонент, а также используем JSX выражения. Это невалидный JavaScript, поэтому нам нужно транспилировать express.js и его импорты с помощью Babel. Для этого нам нужны следующие пакеты.

$ npm i -D @babel/register ignore-styles

Пакет @babel/register изменяет поведение стандартного механизма импорта Node. После импорта этого пакета в JavaScript файл (под Node), последующий импорт .js или .jsx файлов с помощью Node функции require() будет транспилирован в JavaScript на лету с помощью Babel.

По умолчанию, для транспиляции используется config file Babel-я (из проекта), но мы можем предоставить другой конфигурационный файл, используя опцию configFile. @babel/register поставляется с конфигурацией по умолчанию, такой как исключение импорта файлов папки node_modules в процессе транспиляции, подробнее об этом см. здесь.

Так как наши компоненты также импортируют .scss файл, который Babel не может обработать, нам нужно игнорировать эти импорты. Для этого нам нужно require пакет ignore-styles. По умолчанию, он игнорирует многие не JavaScript файлы, такие как .css, .scss, .sass , и т.д. См. здесь полный список игнорируемых файлов.

Если вы используете CSS Modules или любой другой метод упаковки CSS, где импорт CSS-файлов не может быть просто проигнорирован, то вам необходимо изменить конфигурацию транспиляции. Этот ответ на StackOverflow может вам помочь.

Как только мы require эти пакеты в server/index.js, мы можем require файл express.js, который будет транспилирован "на лету", включая файлы которые он импортирует. Поэтому, когда мы снова запустим SSR сервер, используя команду $ npm run start:ssr, мы должны ожидать от сервера предзаполненного HTML ответа.

Ура! Мы сделали это. Как видно из HTML-ответа, полученного от сервера, <div id="app"></div> больше не пуст. Это именно то, чего мы хотели добиться. Теперь, когда поисковик посетит наш сайт, он больше не увидит пустую страницу.

Итак, мы разобрались с точки зрения бэкэнда. Тем не менее, нам нужно изменить некоторые вещи на фронтэнде. Сейчас то, что посылает сервер, не совсем полезно для него. Наше React приложение не заботится о том, является ли элемент #app заполненным или пустым. Оно отрендерит App компонент в этот элемент, так же как и раньше.

Следует избегать этого. Нам на самом деле не нужно снова рендерить компонент App, который может быть огромным (с множеством вложений) в реальном приложении. Мы можем повторно использовать HTML, отрендеренный на стороне сервера и попросить React просто использовать его для App компонента. В этом случае React будет только прикреплять слушателей событий к существующим DOM-элементам (только при необходимости, например, кнопка "Increment" в нашем случае).

Процесс задействования HTML, отрендеренного на сервере для компонента, называется гидратацией (hydration). Функция ReactDOM.render() не выполняет гидратацию, а заменяет все дерево HTML, отрисовывая компонент с нуля. Для гидратации нужно использовать функцию hydrate() из ReactDOM.

// импорт компонента App
import { App } from './components/app';

// обработка компонента App и вставка его в HTML элемент #app
ReactDOM.hydrate( <App/>, document.getElementById( 'app' ) );

Эта функция работает точно так же, как и функция render(), но использует HTML отрендеренный на стороне сервера вместо рендеринга компонента здесь на клиенте. Однако, она ожидает, что HTML отрендеренный на сервере и HTML после рендера App компонента, будут в точности одинаковыми. Если будет несоответствие, то это может привести к неожиданным результатам. Подробнее об этом читайте в документации здесь.

⚠️ В режиме разработки нам может быть нужна функция ReactDOM.render(), так как наш сервер разработки не выполняет рендеринг на стороне сервера. В идеале вы должны иметь index.dev.js и index.prod.js с вызовами render() и hydrate() соответственно и использовать переменную окружения NODE_ENV внутри webpack.config.js для установки одного из этих файлов в качестве точки входа.

Обработка роутов

Маршрутизация - это способ отображения разных страниц для разных URL путей. Традиционно это делается на сервере, но почти все SPA используют какой-либо механизм маршрутизации на стороне клиента. Когда URL путь в браузере меняется, наше приложение удаляет старый компонент и монтирует новый, что похоже на смену страницы, и это быстро.

Маршрутизатор по умолчанию React веб-приложений это react-router-dom. Я объяснял работу React Router v4 в этой статье, но следуйте этой документации для получения информации об API новой версии.

В нашем примере приложения будет два маршрута. Маршрут по умолчанию / выводит компонент Counter, а маршрут /post выводит компонент Post. Мы добавим навигацию внутри компонента App для изменения маршрута на стороне клиента. Итак, давайте начнем с установки пакета react-router-dom.

$ npm install -D react-router-dom

Из данного пакета мы получаем компонент BrowserRouter, которым должны обернуть самый верхний компонент, внутри которого будут отображаться различные компоненты с помощью компонентов Switch и Route. Мы собираемся обернуть App компонент в BrowserRouter в файле src/index.dev.js, а также в src/index.prod.js.

код
// src\\components\\app\\app.component.jsx

import React from 'react';
import { NavLink as Link, Switch, Route } from 'react-router-dom';
// импорт дочерних компонентов
import { Counter } from '../counter';
import { Post } from '../post';
// экспорт главного компонента приложения
export class App extends React.Component {
    constructor() {
        console.log( 'App.constructor()' );
        super();
    }
// рендер представления
render() {
    console.log( 'App.render()' );

    return (
        &lt;div className='ui-app'&gt;
            {/* navigation */}
            &lt;div className='ui-app__navigation'&gt;
                &lt;Link
                    className='ui-app__navigation__link'
                    activeClassName='ui-app__navigation__link--active'
                    to='/'
                    exact={ true }
                &gt;Counter&lt;/Link&gt;

                &lt;Link
                    className='ui-app__navigation__link'
                    activeClassName='ui-app__navigation__link--active'
                    to='/post'
                    exact={ true }
                &gt;Post&lt;/Link&gt;
            &lt;/div&gt;

            &lt;Switch&gt;
                &lt;Route
                    path='/'
                    exact={ true }
                    render={ () =&gt; &lt;Counter name='Monica Geller'/&gt; }
                /&gt;

                &lt;Route
                    path='/post'
                    exact={ true }
                    component={ Post }
                /&gt;
            &lt;/Switch&gt;
            
        &lt;/div&gt;
    );
}

}
//src\\index.dev.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';

// импорт компонента App
import { App } from './components/app';

// обработка компонента App и помещение результата в HTML элемент '#app'
ReactDOM.render( <BrowserRouter><App/></BrowserRouter>, document.getElementById( 'app' ) );
//src\\index.prod.js

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';

// import App components
import { App } from './components/app';

// compile App component in `#app` HTML element
ReactDOM.hydrate( <BrowserRouter><App/></BrowserRouter>, document.getElementById( 'app' ) );

Теперь, когда мы снова запустим сервер разработки, мы сможем увидеть навигацию и виджет Counter (компонент), отображаемый по умолчанию, так как он соответствует URL пути по умолчанию. При нажатии на навигационную ссылку POST, URL изменяется на /post и монтируется виджет Post (компонент).

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

На сервере нам нужно вручную указать маршрут, чтобы компонент App мог выбрать правильный компонент для отображения. Для этого нам нужно использовать компонент StaticRouter, который принимает пропс location, который должен быть URL путём. В качестве значения пропса location мы можем использовать значение req.originalUrl, полученное в обработчике маршрута Express.

код
// server\\express.js

const express = require( 'express' );
const fs = require( 'fs' );
const path = require( 'path' );
const React = require( 'react' );
const ReactDOMServer = require( 'react-dom/server' );
const { StaticRouter } = require( 'react-router-dom' );
// создание express приложения
const app = express();
// импорт компонента App
const { App } = require( '../src/components/app' ); 
// обслуживание статических ресурсов
app.get( /\.(js|css|map|ico)$/, express.static( path.resolve( __dirname, '../dist' ) ) );
// в ответ на любые другие запросы отправляем 'index.html'
app.use( '*', ( req, res ) => {
// читаем файл `index.html`
let indexHTML = fs.readFileSync( path.resolve( __dirname, '../dist/index.html' ), {
    encoding: 'utf8',
} );

// получаем HTML строку из компонента 'App'
let appHTML = ReactDOMServer.renderToString(
    &lt;StaticRouter location={ req.originalUrl }&gt;
        &lt;App /&gt;
    &lt;/StaticRouter&gt;
);

// заполняем элемент '#app' содержимым из 'appHTML'
indexHTML = indexHTML.replace( '&lt;div id=&quot;app&quot;&gt;&lt;/div&gt;', `&lt;div id=&quot;app&quot;&gt;${ appHTML }&lt;/div&gt;` );

// устанавливаем заголовок и статус
res.contentType( 'text/html' );
res.status( 200 );

return res.send( indexHTML );

} );
// запускаем сервер на порту 9000
app.listen( '9000', () => {
    console.log( 'Express server started at <http://localhost:9000>' );
} );

Теперь, когда браузер делает запрос к эндпоинтам http://localhost:9000 и http://localhost:9000/post, мы извлекаем путь из URL и предоставляем его в StaticRouter, который затем управляет тем, какой компонент отрисовать внутри компонента App. Следовательно, мы должны увидеть правильный HTML ответ от этих эндпоинтов.

При этом, когда мы нажимаем на навигационные ссылки в браузере, наше React приложение не посылает новый запрос на сервер для получения HTML. Маршрутизация в браузере по-прежнему происходит на стороне клиента.

Обработка получения данных

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

В настоящее время наш компонент Post имеет жестко закодированный title и description, но мы хотели бы получить эти данные из API эндпоинта. Я собираюсь использовать jsonplaceholder.com для получения образца JSON ответа. Нам нужно будет реализовать логику извлечения внутри компонента Post. Используем axios для выполнения AJAX-запроса на получение данных.

$ npm install -S axios

В компоненте Post мы создадим статический метод fetchData, который содержит логику получения данных для компонента. Есть причина, по которой мы делаем этот метод static, но подробнее об этом позже. Когда компонент монтируется, мы вызываем метод fetchData, используя метод жизненного цикла componentDidMount. Как только мы получим данные, мы обновим состояние компонента и он отобразит title и description.

код
// src\\components\\post\\post.component.jsx

import React from 'react';
import axios from 'axios';
export class Post extends React.Component {
    constructor() {
        console.log( 'Post.constructor()' );
    super();

    // состояние компонента
    this.state = {
        isLoading: true,
        title: '',
        description: '',
    };
}

// получение данных
static fetchData() {
    console.log( 'Post.fetchData()' );

   return axios.get( '&lt;https://jsonplaceholder.typicode.com/posts/3&gt;' ).then( response =&gt; {
        return {
            title: response.data.title,
            body: response.data.body,
        };
    } );
}

// затем когда компонент монтируется, получаем данные
componentDidMount() {
    console.log( 'Post.componentDidMount()' );

    Post.fetchData().then( data =&gt; {
        this.setState( {
            isLoading: false,
            title: data.title,
            description: data.body,
        } );
    } );
}

render() {
    console.log( 'Post.render()' );

    return (
        &lt;div className='ui-post'&gt;
            &lt;p className='ui-post__title'&gt;Post Widget&lt;/p&gt;

            {
                this.state.isLoading ? 'loading...' : (
                    &lt;div className='ui-post__body'&gt;
                        &lt;p className='ui-post__body__title'&gt;{ this.state.title }&lt;/p&gt;
                        &lt;p className='ui-post__body__description'&gt;{ this.state.description }&lt;/p&gt;
                    &lt;/div&gt;
                )
            }
        &lt;/div&gt;
    );
}

}

Однако на сервере это не сработает. Метод renderToString() не запускает никаких методов жизненного цикла, в том числе componentDidMount(). Только методы самого React-компонента выполняются - это constructor и render. Если из этих методов будут выполняться вызовы любых других методов, то тогда эти другие методы также будут выполнены.

Если компонент имеет метод жизненного цикла componentWillMount, то он также будет выполнен на сервере, так как внутри этого хука может быть сделан вызов setState(). Между тем, этот метод стал устаревшим в React v17, и вы должны избегать вызова setState() на сервере.

Однако, если вы планируете вызывать метод fetchData из constructor или render, то он не будет работать из-за асинхронного характера API запроса. Нам нужно будет вручную получить данные для компонента внутри самого express.js (сервер) перед возвращением HTML-ответа, а затем каким-то образом передать ответ компоненту Post.

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

Во-первых, нам нужно идентифицировать компонент, который рендерит StaticRouter и получить данные только для этого компонента. Затем, используя пропс context, мы можем передать эти данные компоненту, который был отрендерен роутером. Также нам необходимо избегать получения данных на стороне клиента, если данные уже были предоставлены сервером. Итак, давайте начнем.

код
// server\\express.js

const express = require( 'express' );
const fs = require( 'fs' );
const path = require( 'path' );
const React = require( 'react' );
const ReactDOMServer = require( 'react-dom/server' );
const { StaticRouter, matchPath } = require( 'react-router-dom' );
// создание express приложения
const app = express();
// импорт компонента App
const { App } = require( '../src/components/app' );
// импорт роутов
const routes = require( './routes' );
// обслуживание статических ресурсов
app.get( /\.(js|css|map|ico)$/, express.static( path.resolve( __dirname, '../dist' ) ) );
// в ответ на любые другие запросы отправляем 'index.html'
app.use( '*', async ( req, res ) => {
// получаем совпадающий роут
const matchRoute = routes.find( route =&gt; matchPath( req.originalUrl, route ) );

// получаем данные совпавшего компонента
let componentData = null;
if( typeof matchRoute.component.fetchData === 'function' ) {
    componentData = await matchRoute.component.fetchData();
}

// читаем файл `index.html`
let indexHTML = fs.readFileSync( path.resolve( __dirname, '../dist/index.html' ), {
    encoding: 'utf8',
} );

// получаем HTML строку путем преобразования компонента 'App'
let appHTML = ReactDOMServer.renderToString(
    &lt;StaticRouter location={ req.originalUrl } context={ componentData }&gt;
        &lt;App /&gt;
    &lt;/StaticRouter&gt;
);

// заполняем элемент '#app' содержимым из 'appHTML'
indexHTML = indexHTML.replace( '&lt;div id=&quot;app&quot;&gt;&lt;/div&gt;', `&lt;div id=&quot;app&quot;&gt;${ appHTML }&lt;/div&gt;` );

// задаём значение для глобальной переменной 'initial_state'
indexHTML = indexHTML.replace(
    'var initial_state = null;',
    `var initial_state = ${ JSON.stringify( componentData ) };`
);

// задаём заголовок и статус
res.contentType( 'text/html' );
res.status( 200 );

return res.send( indexHTML );

} );
// запускаем сервер на порту 9000
app.listen( '9000', () => {
    console.log( 'Express server started at <http://localhost:9000>' );
} );
// server\\routes.js

const { Counter } = require( '../src/components/counter' );
const { Post } = require( '../src/components/post' );

module.exports = [
    {
        path: '/',
        exact: true,
        component: Counter,
    },
    {
        path: '/post',
        exact: true,
        component: Post,
    }
];
// src\\components\\post\\post.component.jsx

import React from 'react';
import axios from 'axios';

export class Post extends React.Component {
    constructor( props ) {
        console.log( 'Post.constructor()' );

        super();

        // component state
        if( props.staticContext ) {
            this.state = {
                isLoading: false,
                title: props.staticContext.title,
                description: props.staticContext.body,
            };
        } else if( window.initial_state ) {
            this.state = {
                isLoading: false,
                title: window.initial_state.title,
                description: window.initial_state.body,
            };
        } else {
            this.state = {
                isLoading: true,
                title: '',
                description: '',
            };
        }
    }

    // получение данных
    static fetchData() {
        console.log( 'Post.fetchData()' );

       return axios.get( '<https://jsonplaceholder.typicode.com/posts/3>' ).then( response => {
            return {
                title: response.data.title,
                body: response.data.body,
            };
        } );
    }

    // когда компонент монтируется, получаем данные
    componentDidMount() {
        if( this.state.isLoading ) {
            console.log( 'Post.componentDidMount()' );

            Post.fetchData().then( data => {
                this.setState( {
                    isLoading: false,
                    title: data.title,
                    description: data.body,
                } );
            } );
        }
    }

    render() {
        console.log( 'Post.render()' );

        return (
            <div className='ui-post'>
                <p className='ui-post__title'>Post Widget</p>

                {
                    this.state.isLoading ? 'loading...' : (
                        <div className='ui-post__body'>
                            <p className='ui-post__body__title'>{ this.state.title }</p>
                            <p className='ui-post__body__description'>{ this.state.description }</p>
                        </div>
                    )
                }
            </div>
        );
    }
}
// src\\index.html

<html lang='en'>
<head>
    <meta charset='UTF-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1.0'>
    <meta http-equiv='X-UA-Compatible' content='ie=edge'>
    <title>React Boilerplate / Webpack 4 / Babel 7</title>
    <meta name="description" content="React boilerplate with Webpack 4 and Babel 7">
    <link rel='icon' href='/assets/favicon.ico'>

    <!-- initial state -->
    <script>
        var initial_state = null;
    </script>
</head>
<body style="background-color: #eee;">
    <div id="app"></div>
</body>
</html>

server/routes.js экспортирует массив объектов, и каждый объект содержит путь маршрута и компонент, который должен будет отображён внутри компонента App на основе пути маршрута. Импортируем этот массив внутри server/express.js и выясняем, какой компонент должен быть отображен с помощью функции matchPath(), предоставляемой react-router-dom.

Как только мы получим соответствующий объект маршрута, мы сможем получить доступ к компоненту, который будет отрисовываться. Так как метод fetchData является статическим, мы можем сделать вызов component.fetchData() для получения данных, если такой метод существует. Переменная componentData содержит ответ API, иначе null.

Используя пропс context компонента StaticRouter, мы передаём значение componentData в компонент который рендерим. Значение, передаваемое в пропс context, передается вниз как пропс staticContext компонента который рендерим. Так как этот пропс был передан от StaticRouter, то он будет существовать только на сервере.

Как мы уже обсуждали, метод жизненного цикла componentDidMount не выполняется на сервере, так что нам не нужно беспокоиться о случайном получении данных. Однако, он будет выполнен в браузере при вызове ReactDOM.hydrate().

Причина, по которой componentDidMount вызывается при гидратации в том, что если компонент решает изменить сгенерированный на сервере HTML, то он должен вызвать setState() в componentDidMount. Этот процесс называется двухпроходный рендеринг (two-pass rendering). Подробнее об этом можно прочитать здесь.

Чтобы не получать данные, когда они уже получены на сервере, нужно как-то передать их компоненту Post. Единственный способ сделать это - установить глобальную переменную в index.html и обновить эту переменную с помощью componentData. Именно это мы и сделали выше.

Теперь, когда мы посещаем маршрут http://localhost:9000/post в браузере, сервер сначала получает данные компонента, вызывая метод fetchData() у компонента (если этот метод существует), затем он передает ответ компоненту, используя пропс context от StaticRouter, а затем устанавливает глобальную переменную initial_data, чтобы компонент мог получить доступ к полученным данным на стороне клиента.

То, что мы увидели здесь, это вершина айсберга. Рендеринг на стороне сервера может быть намного сложнее. Поэтому большинство предпочитают рендерить весь сайт внутри headless браузера на сервере когда посетителем является поисковый робот. Вы можете использовать для этого любой NPM пакет, например useragent для UA sniffing.

Здесь - интересная статья по рендерингу JavaScript-приложений на сервере с помощью headless браузера Chrome. Puppeteer - хороший плагин для создания headless браузера Chromium на сервере для выполнения SSR

Примеры и полный пример проекта, который мы собрали в этой статье, вы можете найти в GitHub-репозитории.

Теги:
Хабы:
+8
Комментарии3

Публикации

Изменить настройки темы

Истории

Работа

Ближайшие события