Как мы пилили серверный рендеринг и что из этого вышло

Всем привет! На протяжении года мы переходим на React и задумались о том, как бы сделать так, чтобы наши пользователи не ждали клиентской шаблонизации, а видели страницу как можно быстрее. С этой целью решили делать серверный рендеринг (SSR — Server Side Rendering) и оптимизировать SEO, ведь не все поисковые движки умеют исполнять JS, а те, которые умеют, тратят время на исполнение, а время краулинга каждого сайта ограничено.
okdw-_ptimir1qc1vqmd9fcypyk.png

Напомню, что серверный рендеринг — это выполнение JavaScript-кода на стороне сервера, чтобы отдать клиенту уже готовый HTML. Это влияет на воспринимаемую пользователем производительность, особенно на слабых машинах и при медленном интернете. Нет необходимости дожидаться пока скачается, распарсится и выполнится JS. Браузеру остается только отрисовать HTML сразу, не дожидаясь JSa, пользователь уже может читать контент. Таким образом сокращается фаза пассивного ожидания. Браузеру после рендера останется пройтись по готовому DOM, проверить, что он совпадает с тем, что отрендерилось
на клиенте, и добавить слушателей событий (event listeners). Такой процесс называется гидрацией. Если в процессе гидрации произойдёт расхождение контента от сервера и сгенерированного браузером, получим предупреждение в консоли и лишний ререндер на клиенте. Такого быть не должно, надо следить за тем, чтобы результат работы серверного и клиентского рендеринга совпадали. Если они расходятся, то к этому следует отнестись как багу, так как это сводит на нет преимущества серверного рендеринга. В случае если какой-то элемент должен расходиться, надо добавить ему suppressHydrationWarning={true}.

Помимо этого есть один нюанс: на сервере нет window. Код, который обращается к нему, должен выполняться в lifecycle методах, не вызываемых на стороне сервера. То есть, нельзя использовать window в UNSAFE_componentWillMount () или, в случае с хуками, uselayouteffect.

По сути, процесс серверного рендеринга сводится к тому, чтобы получить initialState с бэкенда, прогнать его через renderToString(), забрать на выходе готовый initialState и HTML, и отдать на клиент.

В hh.ru походы из клиентского JS разрешены только в api gateway на питоне. Это нужно для безопасности и балансировки нагрузки. Питон уже ходит в нужные бэкенды за данными, подготавливает их и отдает браузеру. Node.js используем только для серверного рендеринга. Соответственно, после подготовки данных питону необходим дополнительный поход в node, ожидание результата и передача ответа клиенту.

Для начала нужно было выбрать сервер для работы с HTTP. Остановились на koa. Понравился современный синтаксис с await. Модульность — это легкие middleware, которые при необходимости ставятся отдельно или легко пишутся самостоятельно. Сам по себе сервер легкий и быстрый. Да и написан koa той же командой разработчиков, что пишут express, подкупает их опыт.

После того как научились раскатывать наш сервис, написали простейший код на KOA, который умел отдавать 200, и залили это на прод. Выглядело это так:

const Koa = require('koa');

const app = new Koa();

const SERVER_PORT = 9400;

app.use(async (ctx) => {
    ctx.body = 'Hello World';
});

app.listen(SERVER_PORT);

В hh.ru все сервисы живут в docker контейнерах. Перед первым релизом необходимо написать ansible плейкбуки, с помощью которых сервис раскатывается в продакшен окружении и на тестовых стендах. У каждого разработчика и тестировщика свое тестовое окружение, максимально похожее на прод. На написание плейбуков мы потратили больше всего времени и сил. Так получилось из-за того, что делали это два фронтендера, и это первый сервис на ноде в hh.ru. Нам пришлось разбираться с тем, как переключать сервис в режим разработки, делать это параллельно с сервисом, для которого происходит рендеринг. Поставлять файлы в контейнер. Запускать голый сервер, чтобы докер контейнер стартовал, не дожидаясь сборки. Собирать и пересобирать сервер вместе с использующим его сервисом. Определить, сколько нам нужно оперативки.

В режиме разработки предусмотрели возможность автоматической пересборки и последующего рестарта сервиса при изменении файлов, входящих в итоговый билд. Ноде нужно перезапуститься, чтобы подгрузить исполняемый код. За изменениями и сборкой следит webpack. Webpack нужен для конвертации ESM в common CommonJS. Для рестарта взяли nodemon, который смотрит за собранными файлами.

Далее научили сервер маршрутизации. Для корректной балансировки необходимо знать, какие инстансы сервера живы. Чтобы это проверить, эксплуатационный heart beat раз в несколько секунд ходит на /status и ожидает получить 200 в ответ. В случае если сервер не отвечает более заданного в конфиге количества раз, он удаляется из балансировки. Это оказалось простой задачей, пара строк и готов роутинг:

export default async function(ctx, next) {
    if (routeMap[ctx.request.path]) {
        routeMap[ctx.request.path](ctx);
    } else {
        ctx.throw(NOT_FOUND, getStatusText(NOT_FOUND));
    }
    next();
}

И отвечаем 200 на нужном урле:

export default (ctx) => {
    ctx.status = 200;
    ctx.body = '200';
};

После этого сделали примитивный сервер, который отдавал state в