Давно у меня было желание изучить рендеринг react на сервере. Итак точка входа: начитавшись статей «для чего это нужно?» в сознании созрели идеи относительно преимуществ серверного рендеринга (дочитайте до конца, там эти идеи могут измениться):
- SEO, т.к. поисковые боты не воспринимают js.
- Клиент быстрее получает контент.
- Увеличится производительность (вот тут я сразу очень засомневался).
Собственно больше ничего очевидного я не нашел.
С чего же начать… А начну я пожалуй уже с того что ранее сделал:
- В моем приложении IOCommander уже есть пару написанных серверов, позаимствую я веб-сервер оттуда, пожалуй. Тем более на днях переписал его на потоковую реализацию (это не было критичным, т.к. панель администратора запускается всего парой человек одновременно).
- Везде описаны простенькие примеры 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": "Name Surname", "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 Кб.
- До тех пор пока не будут загружены скрипты — интерфейс по сути все равно является не рабочим и не имеет смысла.
А теперь выводы относительно начальных идей:
- Пока разбирался с темой вопроса убедился, что, как минимум, поисковые боты гугла умеют работать с js на странице. Если у вас магазин или SEO для вас — все (т.е. вашему проекту требуется максимум рекламы), SSR вам может и пригодится.
- Клиент действительно быстрее увидит контент. Вот только если этот контент жестко завязан на обработку действий клиента — это лишь увеличит время, через которое контент станет рабочим (из-за роста объемов переданных данных).
- В производительности сервера + вырос объем передаваемого трафика в 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. Если же рендерить на горячую, то конечно прирост будет ощутимый при множестве параллельных запросов.