Server Side Rendering в React впечатления

Давно у меня было желание изучить рендеринг react на сервере. Итак точка входа: начитавшись статей «для чего это нужно?» в сознании созрели идеи относительно преимуществ серверного рендеринга (дочитайте до конца, там эти идеи могут измениться):

  1. SEO, т.к. поисковые боты не воспринимают js.
  2. Клиент быстрее получает контент.
  3. Увеличится производительность (вот тут я сразу очень засомневался).

Собственно больше ничего очевидного я не нашел.

С чего же начать… А начну я пожалуй уже с того что ранее сделал:

  1. В моем приложении IOCommander уже есть пару написанных серверов, позаимствую я веб-сервер оттуда, пожалуй. Тем более на днях переписал его на потоковую реализацию (это не было критичным, т.к. панель администратора запускается всего парой человек одновременно).
  2. Везде описаны простенькие примеры SSR, но это не наш случай. Может год назад я бы и решил «войти в тему» с рендеринга простенького компонента, но сейчас я решил взять что-нибудь достаточно громоздкое, чтобы оно могло выявить хотя бы часть плюсов и минусов моей затеи. И я взял панель для mssql отчетов, написанную ранее. Для понимания там в фильтрах порядка 5 тысяч элементов.

Итак, вернемся к серверу, который я вытащил из кода IOCommandera. Собственно сервер Читает сертификаты, если с ними все ок — создает https-сервер на заданном порту, иначе http-сервер. Да, почему-то я не видел ни одного адекватного мануала как же вставить сертификат цепочки. Зато видел пару абсурдных идей типа «скопируйте текст сертификата и сертификата цепочки в 1 файл» и т.д. Буду я их копировать, если они letsencrypt-ом каждые три месяца пересоздаются. Для решения проблемы достаточно вспомнить, что чтение файла в переменную оставляет в ней собственно текст файла. А символ переноса строки в linux испокон веков был \n. Итого мы просто читаем в одну переменную оба сертификата:

cert: fs.readFileSync(sslcrt) + '\n' + fs.readFileSync(sslca)

Далее мы определяем тип запроса, что он GET (на данный момент остальные нас и не интересуют). Дальше нам нужно определить запрошенный url и передать в поток ответа соответствующий файл. Для начала мы отобъем все запросы к корню и index.html и вернёмся к этой части позже. В остальных случаях мы меняем url на путь к папке ./dist/ (папка с упакованным скриптом и прочими файлами для клиента) и далее путь из url. Проверяем файл, если он отсутствует — отдаем ошибку сервера 404 (файл не найден). Далее проверяем тип файла и отдаем соответствующий заголовок в поток ответа сервера. После этого читаем файл в поток и объединяем этот поток с потоком ответа сервера. В случае ошибки потока чтения — отдаем ошибку сервера 500 (внутренняя ошибка сервера). Последний случай может возникнуть, если у вас нет прав на доступ к чтению файла. Под root конечно вы врядли столкнетесь с этим (если, конечно, что-то не заблокировало файл).

Его код (./src/server.js):

import http from 'http';
import https from 'https';
import fs from 'fs';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Html from './html.js';

const sslkey = '/.../letsencrypt-key.pem',
sslcrt = '/.../letsencrypt-cert.pem',
sslca = '/.../letsencrypt-ca.pem',
port = 9000;

var SslOptions;
try {
	SslOptions = {
		key: fs.readFileSync(sslkey),
		cert: fs.readFileSync(sslcrt) + '\n' + fs.readFileSync(sslca)
	};
} catch(e){
	SslOptions = 'error';
}
startWebServer(port);

function startWebServer(port){
	try {
		var webserverfunc = function(req, res){
			try {
				if(req.method === 'GET'){
					var pathFile;
					if((req.url === '/') || (req.url === '/index.html')){
						try{ 
							res.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});      
                            var readerHtlmStream = ReactDOMServer.renderToNodeStream(<Html />);
                            readerHtlmStream.on('error', (e) => {
							      res.writeHead(500, {'Content-Type': 'text/plain'});
							      res.end('Internal Server Error');
                            });
                            readerHtlmStream.pipe(res);
						} catch(e){
							res.writeHead(500, {'Content-Type': 'text/plain'});
							res.end('Internal Server Error');
						}
					} else {
						pathFile = './dist'+req.url;
						try {
							fs.stat(pathFile, function (err, stats) {
								try {
									if (err) {
										throw err;
									} else {
										if (stats.isFile()) {
											try{
												var ContentType = req.url.split('.');
												ContentType = ContentType[ContentType.length -1];
												switch(ContentType){
													case '/':
														res.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});
														break;
													case 'html':
														res.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});
														break;
													case 'js':
														res.writeHead(200, {'Content-Type': 'text/javascript; charset=UTF-8'});
														break;
													case 'css':
														res.writeHead(200, {'Content-Type': 'text/css; charset=UTF-8'});
														break;
													case 'ico':
														res.writeHead(200, {'Content-Type': 'image/x-icon'});
														break;
													default:
														res.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});
														break;
												}
											} catch(e){
												res.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});
											}
											let ReadStream = fs.createReadStream(pathFile);
											ReadStream.on('error', (e) => {
												res.writeHead(500, {'Content-Type': 'text/plain'});
												res.end('Internal Server Error');
											});
											ReadStream.pipe(res);
										} else {
											res.writeHead(404, {'Content-Type': 'text/plain'});
											res.end('Not Found');
										}
									}
								} catch(e){
									res.writeHead(404, {'Content-Type': 'text/plain'});
									res.end('Not Found');
								}
							});								
						} catch (e){
							res.writeHead(500, {'Content-Type': 'text/plain'});
							res.end('Internal Server Error');
						}
					}
				}
			}catch(e){
				console.log("Критическая ошибка работы web-сервера:" +e);
			}
		};
		if(SslOptions !== 'error'){ 
			var server = https.createServer(SslOptions, webserverfunc).listen(port, '0.0.0.0');
			console.log('https-webserver-server listening on *:' + port);
		} else {
			var server = http.createServer(webserverfunc).listen(port, '0.0.0.0');
			console.log('http-webserver-server listening on *:' + port);
		}
		server.timeout = 120000;
	} catch (e){
		console.log("Не могу запустить web-сервер!");
	}
}

Вернемся к корню и index.html. В данном случае нам необходимо отрендерить страничку и передать её браузеру.  SSR в React реализован в пакете react-dom/server. В связи с тем что мы не будем использовать кэширование (собственно у нас весь сервер на потоках) воспользуемся функцией ReactDOMServer.renderToNodeStream, которая преобразовывает React.Component в HTML строку.

Тут начинаются первые проблемки (проблемами назвать сложно, но придется менять код нашей странички). Чтобы выдать браузеру полный HTML, для начала нужно его создать. Это мы сделаем, создав в файле ./src/html.js компонент React с текстом html и нашим компонентом:

import React from 'react';
import MsSqlReportPanel from './MsSqlReportPanel.js';

"use strict"

module.exports = class Html extends React.Component{
  
	constructor(props, context){
		super(props, context);
	}
	
  	render() {			
		return (
			<html>
				<head>
					<link rel="stylesheet" href="./mssql-report.css" />
					<link rel="stylesheet" href="./daypicker.css" />
					<title>MSSQL отчеты</title>
					<meta charset="utf-8" />
				</head>
				<body>
					<div id="MsSqlReport">
						<MsSqlReportPanel />
					</div>
					<script type="text/javascript" src="mssql-report.js"></script>
				</body>
			</html>
		);
	}
};

Собственно здесь ничего сложного, однако нам нужен сам компонент ./src/MsSqlReportPanel.js который мы ранее подключали к DOM в ./src/index.js при помощи ReactDOM.render():

ReactDOM.render(
	<MsSqlReportPanel />,
	document.getElementById('MsSqlReport')
);

Но у нас весь скрипт странички находится в ./src/index.js, т.о. нам придется вынести весь код в  ./src/MsSqlReportPanel.js, за исключением самого ReactDOM.render(), и сделать экспорт модуля:

module.exports = MsSqlReportPanel;

Код, в принципе, не изменился, за исключением того что бы убрали из зависимостей react-dom и часть кода. Далее нам необходимо отредактировать ./src/index.js (это входящий аргумент для webpack). В итоге он стал иметь такой вид:

"use strict"

import React from 'react';
import ReactDOM from 'react-dom';
import MsSqlReportPanel from './MsSqlReportPanel.js';

ReactDOM.render(
	<MsSqlReportPanel />,
	document.getElementById('MsSqlReport')
);

Запустили упаковку webpack, все работает. Вернемся к SSR. У нас уже есть модуль MsSqlReportPanel в ./src/MsSqlReportPanel.js. Да, чтобы понять что происходит с путями. Импорт модулей происходит относительно папки скрипта, а вот ссылки на файлы относительно папки из которой был запущен shell.

Пробуем npm src/server.js и получаем ошибку. Дело в том что у нас неподдерживаемый синтаксис JSX, который нужно предварительно превратить в js посредством babel. Для того чтобы делать это в режиме потока существует babel-node. Т.о. вместо npm src/server.js возпользуемся командой babel-node src/server.js. Для удобства добавим её в package.json:

{
  "name": "ssr",
  "version": "1.0.0",
  "description": "test",
  "main": "server.js",
  "scripts": {
    "startserver": "babel-node src/server.js",
    "start": "webpack-dev-server --open --mode development",
    "build": "webpack --optimize-minimize --mode production"
  },
  "author": "Siarhei Dudko",
  "license": "MIT",
  "devDependencies": {
    "babel-cli": "^6.26.0",
    "babel-core": "^6.26.3",
    "babel-loader": "^7.1.4",
    "babel-preset-env": "^1.7.0",
    "babel-preset-react": "^6.24.1",
    "html-loader": "^0.5.5",
    "html-webpack-plugin": "^3.2.0",
    "htmllint-loader": "^2.1.4",
    "prop-types": "^15.6.1",
    "uglifyjs-webpack-plugin": "^1.2.5",
    "webpack-cli": "^3.0.3",
    "webpack-dev-server": "^3.1.4",
    "webpack-merge": "^4.1.2"
  },
  "dependencies": {
    "fs": "0.0.1-security",
    "http": "0.0.0",
    "https": "^1.0.0",
    "moment": "^2.22.2",
    "react": "^16.4.0",
    "react-day-picker": "^7.1.9",
    "react-dom": "^16.4.0",
    "redux": "^4.0.0",
    "webpack": "^4.12.0"
  }
}

Для babel-node все еще нужны пресеты, но я их создал заранее еще при упаковке webpack-ом. Это файл .babelrc:

{
  "presets": ["env", "react"]
}

Теперь запуск сервера производится командой npm run startserver (вариант не для продакшен). Пробуем запустить и опять ловим ошибку. В моем скрипте есть несколько отсылок к глобальной window.console.log, которая не доступна в режиме server side rendering. Благо логика на них не сильно завязано и их можно выпилить, или заменить, или сделать в начале скрипта что-то вроде:

if(typeof(window) === 'undefined'){
    var window = {};
}

Тут уж у каждого свой путь, мне было достаточно вместо window.console.log() использовать console.log() поддерживаемую Nodejs.

Пробую запустить еще раз и опять ошибка. У меня в скрипте есть небольшой лайфхак. Сам React проверяет ширину окна браузера и, в зависимости от значения, назначает компоненту класс css. Глобальная innerWidth недоступна в режиме SSR (не важно что она берется по ссылке из window, window.innerWidth также будет недоступна); Тут я воспользовался предложенным выше вариантом:

if(typeof(innerWidth) === 'undefined'){
  var innerWidth = 0;
}

Опять запускаю npm run startserver и все работает, но как проверить? Все просто, убираю из компонента Html:

<script type="text/javascript" src="mssql-report.js"></script>

Проверяю — ничего не изменилось. Все верно, серверный рендеринг работает не на горячую. Перезапускаю сервер, проверяю еще раз — все хорошо страничка отрисовалась без скрипта. Возвращаю нормальный вид странички и еще раз перезапускаю сервер. Изначально, в режиме рендеринга на клиенте по данным https://developers.google.com/speed/pagespeed/insights/ был результат средний 31/53 (мобильный/ПК). Суть стояла не в оптимизации, а просто изучить вопрос, понять плюсы и минусы. Проверяю еще раз и получаю среднее значение 68/43. Т.е. для ПК скорость даже упала, хотя для мобильных девайсов выросла в более чем 2 раза (плюсы в раздаче).

Думаю «стоит ли овчинка выделки». В моем случае однозначно не стоит.

  • Портал на который я делал интерфейс для mssql отчетов вообще под мобильный не заточен, а по ПК мы видим явное торможение.
  • Также меня не интересует в принципе SEO, т.к. портал закрытый корпоративный.
  • Также увеличивается расход ресурсов сервера из-за рендеринга. Учитывая количество логики, достаточно сильно. (Обход предложу ниже)
  • Также увеличивается трафик. (Возможно тут мне не хватает опыта) Логика такова, что при рендеринге мы отдаем клиенту «тяжелую» страницу с данными. Вытащить эти данные из страницы не представляется возможным (точнее сказать логика, выковыривающая их из компонентов будет неоправданно затратной с т.з. ресурсов и времени написания кода), после этого мы выгружаем все тот же объем данных (скрипты, css, картинки — которые отсутствуют в html и объект для redux — который уже присутствует в html, в моем случае размером в 500+Кб, а также DOM элементы сформированные React). Т.е. вместо странички размером 632 байта я отдаю клиенту страницу размером 773,12 Кб.
  • До тех пор пока не будут загружены скрипты — интерфейс по сути все равно является не рабочим и не имеет смысла.

А теперь выводы относительно начальных идей:

  1. Пока разбирался с темой вопроса убедился, что, как минимум, поисковые боты гугла умеют работать с js на странице. Если у вас магазин или SEO для вас — все (т.е. вашему проекту требуется максимум рекламы), SSR вам может и пригодится.
  2. Клиент действительно быстрее увидит контент. Вот только если этот контент жестко завязан на обработку действий клиента — это лишь увеличит время, через которое контент станет рабочим (из-за роста объемов переданных данных).
  3. В производительности сервера + вырос объем передаваемого трафика в 2 раза. Никакого реального прироста производительности тут и быть не может, учитывая мощности современных браузеров. Для того чтобы не рендерить по второму кругу уже выгруженную страничку, можно выключить рендер компонентов при первой загрузке данных в redux — это требует дополнительной логики в каждом компоненте. Прирост производительности в теории возможен, если у клиента слабый браузер. Т.к. он получит страничку и увидит её раньше, чем ReactDOM отрендерит её на клиенте. С другой стороны ничего не мешает вам пользоваться SSR при больших нагрузках. Вот только рендерить не в поток ответа, а в html-файл типо:
    var writerHtlmStream = fs.createWriteStream('./dist/index.html');
    var readerHtlmStream = ReactDOMServer.renderToNodeStream(<Html />);
    writerHtlmStream.on('error', (e) => {
    	  readerHtlmStream.destroy(e);
    });
    readerHtlmStream.on('error', (e) => {
    	  writerHtlmStream.end('Server Error');
    });
    readerHtlmStream.pipe(writerHtlmStream);

    А далее раздавать уже html-файл. Конечно это будет невозможно с динамически обновляющимся контентом (при частом обновлении). Ну и нужно будет выбрать периодичность пересоздания этих самых html-файлов исходя из доступных ресурсов и требованию к актуальности данных (впрочем, они все равно подгружаются самим скриптом). Будет ли от этого сервер производительней — нет, если сравнивать с client side rendering. Если же рендерить на горячую, то конечно прирост будет ощутимый при множестве параллельных запросов.