Каждый день сайтом SuperJob пользуется более миллиона людей. Как построить архитектуру фронта, чтобы приложение загружалось быстро и при этом выполняло требования поисковой оптимизации? Как мы можем это измерить?

Значительно повысить производительность можно при помощи серверного рендеринга, но какая будет цена у такой оптимизации? Какой инструмент выбрать — готовую библиотеку или собственное решение? Какие ограничения в дальнейшем могут быть вызваны выбором того или иного подхода?

На все эти вопросы ответил frontend-разработчик Виталий Старов на конференции FrontendConf 2021. Он рассказал о серверном рендеринге на примере приложения SuperJob. Читайте под катом, как SuperJob пришли к своей реализации серверного рендеринга, узнав по пути много интересного. Узнаете, когда хорош SSR и как он работает, из чего он устроен, чем может быть полезен и кому.  

Все мы знаем, что производительность важна, но почему это так? Почему мы должны вкладывать в это усилия разработки, ресурсы железа и все остальное? Тому есть несколько причин.

Во-первых, мы, как frontend-разработчики, хотим, чтобы наши пользователи были счастливы: чтобы они могли решать свои задачи быстро, эффективно и получать удовольствие от использования наших приложений.

Бизнесу, в свою очередь, нужна конверсия, а для этого нужно больше пользователей. Обычно они приходят из поиска, и в SEO Ranking поисковых систем производительность наших приложений играет очень важную роль. Для них мы оптимизируем наши веб-приложения, и поисковики дают им высокие оценки. Мы попадаем наверх в поиске и к нам приходят новые пользователи. Больше пользователей — больше конверсия бизнесу, и все счастливы.

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

Как измерять производительность?

Метрики производительности сайтов, которые предлагает Google, изложены в его концепции Сore Web Vitals:

С CLS мы сегодня разбираться не будем, но остановимся на LCP и FID.

LCP — Largest Contentful Paint

LCP — это метрика, которая показывает, как скоро на странице становится доступным самый крупный элемент интерфейса. Google говорит, что если мы с LCP уложились в 2,5 секунды, то мы молодцы. От 2,5 до 4 — еще терпимо, а больше 4% — всё очень плохо. Для примера — timeline загрузки сайта SuperJob. FCP — это момент, когда пользователь вообще хоть что-то увидел, а за время LCP большой баннер наполнился содержимым:

Причин, почему LCP бывает большим, может быть несколько. Например, банально может долго отвечать сервер, то есть у него высокий TtFB (Time to First Byte). Или JS и CSS могут блокировать рендеринг и замедлять отображение страницы. Также, если у вас на странице много ресурсов и они должным образом не сжаты, то всё это будет долго загружаться и тоже повлияет на общее время отрисовки.

И наконец, причиной может быть низкая производительность рендеринга на клиенте. Пользователь сначала будет ждать загрузки кода приложения, а потом — пока этот код спарсится браузером и выполнится. Всё это время пользователю придется смотреть на пустой экран. В самых плохих случаях, на слабых устройствах с медленным интернетом, это может занимать десятки секунд —- согласитесь, так себе UX?

Чтобы эту проблему решить «в лоб», мы можем просто взять и выполнить часть работы браузера на своем сервере и отдать на клиент подготовленную и частично отрендеренную страницу. Тогда всё произойдет гораздо быстрее. Это и называют серверным рендерингом.

Как работает SSR в SuperJob

Посмотрим, как работает SSR на примере web-приложения SuperJob. Чтобы был понятен контекст, пара слов об их стеке. 

  • Изоморф с BFF-сервером на Node.js, то есть клиентский код выполняется и в браузере, и на сервере. Этот сервер проксирует запросы к АПИ и выполняет рендеринг — так называемый Backend for Frontend. На нем крутится Node.js, а запросы обрабатываются в Express.js.

  • Само приложение достаточно объемное — 500 тысяч строк кода, не считая тестов. На клиенте — React / Redux, а общение с бэкендом идет по спецификации JSON API.  Собирается всё Webpack’ом.

Очень схематично общая архитектура приложения выглядит так:

Получаем данные для рендеринга

Когда пользователь открывает какую-то страницу, его запрос отдается роутеру, который преобразует url в объекты, а на бэкенде запускается подготовка к рендерингу страниц. 

Для подготовки к рендерингу, во-первых, проверяются куки. Возможно, там есть информация, которая поможет определить роль пользователя, если он был залогинен раньше. Во-вторых, анализируется география по IP и согласно полученной информации готовятся редиректы. Например, если человек, из Самары, то  система его перенаправит на samara.superjob.ru. И третье — так как на клиенте Redux, то нужно заранее подготовить объекта стора и заполнить его дефолтными значениями.

Для роутинга используется готовое решение — Universal Router. Он очень простой, у него всего одна внешняя зависимость. Объект, который вернул роутер, содержит в себе компонент страницы и экшены. Они нужны, чтобы в API получить все данные для наполнения страницы:

После обмена данными с бэкендом есть не только компонент страницы, но и данные для ее наполнения. Можно переходить к рендерингу.

Рендерим страницу

Для этого, в первую очередь передаем в ReactDOM.renderToString() компонент страницы. С каждой полученной строки собираются critical-path-стили для отрисовки приложения на клиенте при первой загрузке. То, что получилось, оборачивается в общий для всех layout — шапку и футер приложения. На страницу добавляется объект с данными, который наполнен тем, что пришло с бэкенда. 

И всё, что получилось, передается в ReactDOM.renderToStaticNodeStream() — так страница уходит пользователю.

Производительность на сервере

Если у вас на сервере рендеринга Node.js, то нельзя допускать долгих синхронных операций в основном потоке, чтобы не блокировать выполнение. Например, React.renderToString — именно такая синхронная операция. Чтобы избежать блокировки, разработчики React предлагают нам асинхронную версию этого метода — React.renderToStaticNodeStream.

Но и с ним надо быть очень осторожным. Дело в том, что если вы передаете через него не очень большую страницу, то оверхед на создание потока Node.js отнимет у вас всю пользу от асинхронности. Тут всё очень индивидуально для приложения и ресурсов сервера — для каждого случая нужно проводить нагрузочное тестирование и смотреть, выигрываете вы от асинхронности или, наоборот, она замедляет вас.

Сложно говорить о конкретных цифрах, но, например, Александр Моргунов из Яндекса в докладе на похожую тему: «Server side rendering in React» рассказывал, что после выполнения серии нагрузочных тестов они пришли к эмпирической границе в 100 kB. Если страница с данными получилась меньше 100 KB, то лучше отрендерить ее в строку — и так и отдавать на клиент. Но если страница больше 100 KB, то вы смело можете ее рендерить с помощью React.renderToStaticNodeStream.

Помимо этого, нужно обязательно контролировать, хватает ли ресурсов вашего сервера в пиковые нагрузки, то есть успевает ли он рендерить страницы достаточно быстро и обрабатывать все запросы от пользователей. Тут у каждого свой инструмент — в SuperJob используется Графана.

Возвращаемся к алгоритму рендеринга. Итак, страница готова и ушла на клиентское устройство, где остается сделать десериализацию стора. А также гидрацию — это почти что render() в React, но работа выполняется с уже подготовленным HTML документом. То есть React не перестраивает DOM заново, а просто проходится по узлам и развешивает обработчики событий, чтобы элементы интерфейса стали интерактивными.

А что со стилями? 

Сначала в SuperJob хотели использовать в приложении CSS-in-JS, потому что он дает много возможностей при разработке. Но все знают, что он не такой производительный, как обычный CSS, и тому есть две причины.

Во-первых, CSS-in-JS тащит за собой рантайм, в котором он сам выполняется. Этот дополнительный JS-рантайм не нужен для обычного CSS и без него можно обойтись. Во-вторых, рантайм динамически обновляет стили прямо на странице, что приводит к избыточному выполнению процедур repaint и reflow.

Прежде, чем мы поговорим о конкретных реализациях CSS-in-JS, обратите внимание на требования, которые React накладывает на результаты рендеринга.

Требования к результатам рендеринга

Основное ограничение заключается в том, что HTML-документ, отрендеренный на сервере, должен точно совпадать с документом, который вы потом получите на клиенте — потому что React в процессе гидрации полагается на то, что DOM не нужно выстраивать заново. Если документы окажутся разными, то React полностью перестроит DOM — и вся польза от предварительного рендеринга на сервере пропадет. Но как вообще может получиться так, что у вас на сервере — одно, а на клиенте получилось другое?

Например, вы не хотите показывать компонент <Input />, пока приложение не гидрировалось, и сначала отдаете вместо него <Loader />. При этом во втором условии вы неправильно определили стадию гидрации и подменили его сразу. Тогда всё и может сломаться. Очевидно, что не только React полагается на это требование — на него завязаны все библиотеки, которые реализуют CSS-in-JS для React.

Разработчики SuperJob изучили три довольно популярных решения: JSS, Styled Components и Emotion. Оказалось, что JSS самый чувствительный к этому ограничению. Если вы что-то отрендерите по-разному, он сразу выдаст ошибку и, вероятно, сломает все стили на странице. Styled Components и Emotion умеют лучше обрабатывать такие ситуации.

Другая общая проблема у этих решений — они перевставляют стили во время гидрации. То есть вы сначала стили вставили на сервере и отдали всё на клиент. После чего проснулся рантайм и все ваши стили вставил заново, но при этом чуть-чуть по-другому. JSS делает это со всеми стилями, которые есть на странице, а Styled Components и Emotion — лишь с частью из них.

Обратите ваше внимание на библиотеку Stitches. Она пока в alpha, но уже выглядит здорово. Во-первых, она очень маленькая — 6,2 kB gzipped. Во-вторых, работает не только с React, но и с любым фреймворком. У нее есть core-версия, которая без React-обвязок занимает еще меньше места (6kB gzipped). В третьих, вы получите SSR сразу из коробки. У нее нет интерполяции стилей в рантайме, то есть на странице рантайм библиотека не меняет ваши стили, не делает лишние repaint и reflow. 

Это делает библиотеку немного более эффективной, чем большинство популярных сейчас решений для CSS-in-JS. Но пока она не зарелизилась, используем другие решения.

В таблице ниже можно сравнить размеры рантайма и АПИ для перечисленных библиотек:

Видно, что цифры могут быть относительно большими. Поэтому разработчики решили, что лучше использовать старые добрые CSS модули. Правда, они не умеют работать на сервере «из коробки»…

CSS Modules + SSR?

Чтобы CSS модули смогли работать на сервере, взяли готовое решение Isomorphic Style Loader. Он предоставляет интерфейс для сбора critical-path-стилей на сервере, прямо из строки, в которую был отрендерен HTML документ.

С ним, правда, есть та же проблема: он тоже перевставляет стили заново во время гидрации.  Но с ним пошли на небольшую хитрость — пропатчили его интерфейс методом getID(). 

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

FID — First Input Delay

Как работает FID, посмотрим на примере главной страницы приложения SuperJob. Наверху у нее строка поиска. Допустим, пользователь вводит слово «инженер» и ждет реакции системы. Время, которое прошло до момента, когда элемент ответил на внешнее воздействие, стал интерактивным и выдал инженерные вакансии — и  есть First Input Delay:

Google нам говорит, что если мы уложились в 100 мс, то у нас все прекрасно. От 100 до 300 — еще куда ни шло, а больше 300 — всё плохо. Что мы можем сделать здесь?

Снижаем First Input Delay

Во-первых, мы можем унести долгие блокирующие операции на клиенте в Web Workers, чтобы они не блокировали выполнение основного потока.

Во-вторых, мы можем решать проблему в лоб — взять приложение, нарезать на кусочки, и эти кусочки отдавать. Если мы отдаем меньше кода, он быстрее загружается, быстрее выполняется, и отрисовка происходит более эффективно.

Разбиваем постранично

Как минимум, мы можем разбить приложение постранично, то есть отдавать пользователю только ту страницу, за которой он пришел, и все связанные с ней компоненты. В SuperJob это делают с помощью WebPack, с 4 версии он это умеет из коробки:

В объекте, который возвращает роутер, есть поле chunks с информацией о том, в какой чанк нужно сложить страницу и всё, что с ней связано — чтобы отдать на клиентское устройство только необходимый для первого отображения страницы код. Очень удобно.

Ленивые компоненты

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

Но нужно сказать, что React.lazy, который для этого прекрасно подходит, до сих пор не умеет работать на сервере. Разработчики React рекомендуют использовать Loadable Components, и эта библиотека справляется с такой задачей прекрасно.

Такой же подход можно применить и к гидрации.

Ленивая гидрация

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

Сделать это можно с помощью dangerouslySetInnerHTML. Префикс тут выделен намеренно — метод считается опасным, потому что позволяет вставлять в приложение код, который мог быть получен из неконтролируемых нами источников. При этом, если вставляемый код находится полностью под нашим контролем, можно смело пользоваться этим методом.

Участки интерфейса, вставленные через dangerouslySetInnerHtml будут проигнорированы React'ом при первой гидрации. Об этом говорят и разработчики React:

С таким подходом можно зайти очень далеко. Например, можно присваивать компонентам приоритеты гидрации, но разработчики SuperJob ограничились тем, что просто проверяют обычным Intersection Observer’ом — попали эти тяжелые компоненты в экран или не попали. Если попали, их перерендерят и они гидрируются. 

Понятно, что это немного костыльный подход, и разработчики React в будущих версиях предлагают способы оптимизировать гидрацию из коробки более правильными методами. Чтобы понять, что это за методы, давайте еще раз посмотрим на то, как серверный рендеринг работает с точки зрения пользователя.

Новая архитектура SSR Suspense

Как гидрация работает сейчас:

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

Новая архитектура SSR Suspense будет примерно так:

Пользователю не нужно ждать всю страницу с клиента, потому что мы можем отдать только часть компонентов, которые уже собрались. Остальные «подъедут» чуть позже. Таким образом пользователь быстрее что-то увидит. То же самое с гидрацией — нам не надо дожидаться, пока вся страница гидрируется, мы можем начинать гидрировать компоненты по очереди и делать активными те, что нужны раньше, чтобы пользователь смог с ними взаимодействовать.

React Server Components

Также разработчики пока еще экспериментируют с серверными компонентами, но идея уже ясна и довольно интересна. Объясню, как это будет работать. Сейчас все происходит так: допустим, какому-то клиентскому компоненту понадобились данные. Мы пошли за ними на бэкенд, получили их, обновили state, новые props прислали компоненту, он перерендерился и стал показывать что-то  новое. 

Как в этом случае будут работать серверные компоненты? Компонент будет также забирать данные из API бэкенда, но перерисуется он уже не на клиенте, а прямо на сервере бэкенда для фронтенда. Клиентский же код сделает немного другой запрос — это запрос по новому протоколу, который в React будет из коробки:

Обратите внимание на прямоугольник lib.js — вам не обязательно доставлять на клиент библиотеки, которые нужны серверному компоненту для получения данных из API. Код таких библиотек может оставаться на сервере бэкенда для фронтенда и использоваться только там, без доставки лишнего кода на клиент. Вся информация будет получена серверным компонентом с использованием этого кода, а на клиент отправится только отрендеренная разметка, сократив несколько запросов в API до одного запроса за готовой разметкой. В этом случае code splitting мы получаем автоматически из коробки

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

Перейдем к пользе, которую получили разработчики SuperJob, и выводам.

PROFIT!

Благодаря серверному рендерингу разработчикам SuperJob легко работать с быстро меняющимися требованиями к поисковой оптимизации (SEO). Не нужно ходить к бэкендерам — фронтендеры могут всем управлять сами, при этом они точно знают, в каком виде получат страницы пользователи и поисковые роботы. Важно, чтобы страницы при этом точно совпадали, потому что иначе Google понизит SEO Ranking для страницы приложения.

Во-вторых, у фронтедеров теперь есть большой выбор способов контролировать производительность рендеринга. Например, можно выносить на дашборды не только количество запросов сервера, но и время, которое занимает генерация страницы или получение данных. Можно даже смотреть, насколько сильно блокируется Event Loop. Это очень здорово — можно вовремя узнавать, когда что-то пошло не так.

Третий плюс — можно гибко управлять сборками, делая их заранее перед релизом сразу несколько сборок приложения, например, для старых браузеров и для новых. В новых браузерах не нужны лишние полифилы и старый код. Тут стоит не забывать о том, что скоро появится новый Privacy Sandbox, который может усложнить определение версии клиентского браузера.

Также с SSR можно закэшировать что угодно. Причем не только страницы, но даже компоненты прямо во время рендеринга. Для этого есть готовое решение. Разработчики SuperJob еще не пробовали в продакшене, но выглядит оно очень перспективно. Оно позволяет помечать компоненты кэшируемыми, и тогда они будут отдаваться из кэша.

Выводы

Конечно, SSR подходит не для всех приложений. Например, если вам не очень важна оптимизация поиска, и ваше приложение загружается один раз, а потом по несколько дней живет в какой-то вкладке браузера — скорее всего, вам ничего рендерить на сервере не надо. 

Если у вас статичный блог, в котором статьи обновляются раз в пару дней, то вы можете обойтись обычным пререндерингом, то есть перед релизом всё отрендерить в статику и отдать на клиент без всяких прослоек в виде бэкендов для фронтендов. У вас тоже будет поисковая оптимизация из коробки, всё будет работать быстро. Но если приложение сложное и подразумевает много ролей пользователей, которые определяют разные правила формирования контента — тогда вам, наверное, пререндеринг не подойдет.

Если вам все-таки нужен серверный рендеринг, и вы его берете, то он прямо из коробки улучшает LCP. Еще вы можете улучшить FID Code Splitting’ом. Тут надо отметить, что эти метрики взаимодополняются двумя подходами, то есть серверный рендеринг вам не только LCP улучшает, но и FID — также, как и Code Splitting. Все это довольно тесно связано.

И наконец, React 18 позволит убрать те костыли, которые сейчас нужны для некоторых оптимизаций SSR.

Выступление Виталия Старова на конференции FrontendConf 2021:

Профессиональная конференция фронтенд-разработчиков FrontendConf 2022 пройдет 7-8 ноябре в Сколково, Москва. Уже можно забронировать билеты и купить записи выступлений с прошедшей конференции FrontendConf 2021.

До 22 мая открыт CFP, и, если вы хотите выступить, подумайте об этом.

Комментарии (0)