В этом уроке мы поговорим о серверном рендеринге (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.
= Преимущества
SPA дают опыт взаимодействия похожий на нативные приложения. Так как весь HTML приложения генерируется на стороне клиента, загрузка страницы происходит очень быстро.
Так как все ресурсы приложения, такие как JavaScript и CSS файлы, загружаются только один раз и никогда не запрашиваются снова после загрузки приложения, мы сильно экономим на пропускной способности сервера.
После начальной загрузки приложения мы запрашиваем у сервера только данные размером в несколько килобайт (по запросу). Таким образом, SPA идеально подходят для использования в условиях плохой сети.
Всё приложение может быть кешировано на клиенте (устройстве) с помощью service worker. Таким образом, при следующем обращении пользователя к сайту, браузеру больше не нужно будет загружать HTML и ресурсы. Когда пользовательское устройство не подключено к интернету, вместо отображения сообщения браузера по умолчанию "Не подключено к интернету!", мы можем отобразить пользовательский экран, который даст пользователю оффлайн-доступ.
Пользователи могут сохранить SPA как приложение на устройстве. Если вы заинтересованы в разработке мобильного приложения но не хотите тратить деньги на разработку нативных приложений (Android или iOS), SPA открывают возможность создать приложение похожее на нативное, используя ту же самую кодовую базу веб-сайта.
= Подводные камни
Так как SPA должен обслуживать все JavaScript и CSS файлы приложения вместе (обычно), эти файлы громоздки (несколько мегабайт). Следовательно, начальная загрузка приложения требует значительно больше времени, что означает, что пользователь будет видеть пустой экран в течение довольно долгого времени. При плохой сети это может быть серьезной проблемой. Однако, мы можем исправить это с помощью SSR.
В SPA, так как JavaScript управляет генерацией HTML на стороне клиента, SPA делают тяжелую работу на стороне клиента, которую раньше выполнял сервер. Поэтому SPA нуждаются в устройствах с хорошими возможностями в части процессора и батареи.
Так как исходный 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
| L-- favicon.ico
+-- build
| +-- main.js
| +-- main.js.map
| +-- styles.css
| +-- styles.css.map
| +-- vendor.js
| L-- vendor.js.map
L-- index.html
Для нашего проекта-примера я собираюсь использовать Express.js для создания HTTP-сервера. Вы можете выбрать любой другой фреймворк по своему усмотрению, так как SSR от этого не зависит. Давайте создадим файл server/express.js
и напишем логику обслуживания файла index.html
для всех маршрутов, кроме JS, CSS и других файлов ресурсов нашего веб-приложения.
server/
+-- express.js
L-- 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 (
<div className='ui-app'>
{/* navigation */}
<div className='ui-app__navigation'>
<Link
className='ui-app__navigation__link'
activeClassName='ui-app__navigation__link--active'
to='/'
exact={ true }
>Counter</Link>
<Link
className='ui-app__navigation__link'
activeClassName='ui-app__navigation__link--active'
to='/post'
exact={ true }
>Post</Link>
</div>
<Switch>
<Route
path='/'
exact={ true }
render={ () => <Counter name='Monica Geller'/> }
/>
<Route
path='/post'
exact={ true }
component={ Post }
/>
</Switch>
</div>
);
}
}
//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(
<StaticRouter location={ req.originalUrl }>
<App />
</StaticRouter>
);
// заполняем элемент '#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>' );
} );
Теперь, когда браузер делает запрос к эндпоинтам 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( '<https://jsonplaceholder.typicode.com/posts/3>' ).then( response => {
return {
title: response.data.title,
body: response.data.body,
};
} );
}
// затем когда компонент монтируется, получаем данные
componentDidMount() {
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>
);
}
}
Однако на сервере это не сработает. Метод 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 => 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(
<StaticRouter location={ req.originalUrl } context={ componentData }>
<App />
</StaticRouter>
);
// заполняем элемент '#app' содержимым из 'appHTML'
indexHTML = indexHTML.replace( '<div id="app"></div>', `<div id="app">${ appHTML }</div>` );
// задаём значение для глобальной переменной '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-репозитории.
boolive
Запустить реальное клиентское приложение на сервере нереально сложно — куча граблей. Через webpack что только не подключают с особым извратом ) Самый простой случай с модульным css — игнорировать на сервере его import уже нельзя, в коде нужен объект с названиями css классов. Проще для сервера делать отдельную сборку по тому же конфигу webpack и её запускать. Статик и мета информация должна оказаться в
<head>
, и нужно определить, что именно вставить в соответствии с отрендеренной страницей. Рендер реакта не умеет это делать. Приложение в браузере спокойно может работать с глобальным контекстом (historyApi, sessionStorage, window...), оно уже изолировано браузером. На сервере же обработка запросов от множества клиентов, и если эта обработка асинхронная, то обязательно нужна изоляции рендеров от разных клиентов. Замыкания функцией express-роутера окажется недостаточным из-за природы клиентского приложения. SSR существенно влияет на архитектуру клиентского приложения, например в nextjs свои роутеры реализовали. А хотелось бы спокойно лепить фронт и при первой необходимости подключить SSR. В этом случаи headless браузера выглядит заманчивым. Рекомендую ещё посмотреть решение на воркерах и сбора статики через @loadable/component — там минимум вмешательства в клиентское приложение https://react-skeleton.ru/docs/develop/ssr/index.md.