Как мы переводим приложение с 100+ страниц на SSR

В данной статье хочу рассказать об опыте перевода существующего проекта на Серверный Рендеринг (SSR). Объясню зачем нам это было необходимо, как внедряли, с какими проблемами столкнулись, что это нам дало. Ну и в целом кратко, что такое SSR, с чем его едят, и главное, как.

Задействованные технологии в проекте

Я старший Frontend Engineer в команде платформы знаний и сервисов компании “Деловая Среда”.

Деловая среда — дочерняя компания Сбера, созданная с целью развития малого и среднего бизнеса. У нас есть одноименный проект для которого мои коллеги и я внедряли SSR.

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

Перед нами стояли задачи: улучшить индексацию данного приложения поисковыми системами и уменьшить время загрузки страниц до первого взаимодействия пользователя с контентом.

С момента старта приложения прошло более 3-х лет и портал, на тот момент, использовал только клиентский рендеринг (CSR, Client Side Rendering) для всех страниц. В феврале 2020 года в команде мы начали обсуждать идею о том, что необходимо переводить приложение на серверный рендеринг (SSR, Server Side Rendering).

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

Зачем вообще нужен SSR?

Не смотря на то, что поисковые системы потихоньку начинают все лучше и лучше работать с JavaScript — эта работа все еще далека от идеала. Поэтому поисковому роботу лучше скармливать уже готовую HTML страницу. Для этого необходимо использовать технологии пререндеринга страниц (этот подход не подошел, далее объясню почему) или переводить приложение на SSR.

Больше почитать на эту тему, можно по ссылкам ниже.

Наше приложение состояло из:

  • основного репозитория, где хранится код конечного приложения;
  • репозитория хэлперов — утилит и выделенной бизнес логики;
  • репозитория UI элементов и компонентов.

Стек по ключевым технологиям на момент перехода: JavaScript, React, Redux, Node.js, GraphQL, Webpack, Ant Design, Axios, Lodash, LESS, CSS-modules.

Провели исследование

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

Есть несколько вариантов перехода на SSR в уже существующем проекте:

  1. Способ 1. Пререндер существующего приложения
  2. Способ 2. Пишем свой велосипед-сервер
  3. Способ 3. Выбираем один из специализированных фреймворков для SSR типа Next.js/Razzle/After.js/etc.

Рассмотрим все три варианта относительно нашего проекта.

Способ 1. Пререндерим существующее приложение

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

Возможные решения:

  1. prerender.io — сторонний сервис, который можно подключить в проект;
  2. поднять свое небольшое “облако” состоящее из headless chrome’ов и написать обвязку, которая будет управлять всем этим делом (кеширование/инвалидация кэшей/и пр.);
  3. использовать что-то похожее на react-snap, который под капотом использует тот же headless chrome для рендеринга SPA.

Плюсы:

  • можно быстро внедрить MVP — хорошо как временное решение.

Минусы:

  • необходима оплата подписки для платных решений (бесплатные сервисы медленные), плюсом нужны инструменты для работы с инвалидацией закэшированных страниц;
  • обрастаем специфическими инструментами и бизнес логикой вокруг стороннего инструмента, вместо написания фронтенда;
  • свой хостинг headless chrome’ов прожорлив к системным ресурсам;
  • при использовании headless chrome’ов при постоянных запросах можно напороться на утечки памяти и нужно будет перезапускать;
  • headless chrome может не дождаться окончательной загрузки SPA и выплюнуть обычное CSR приложение, тоже нужно как-то за этим следить;
  • плохо подходит для приложения, у которого постоянно увеличивается количество страниц и контента.

Способ 2. Пишем свой велосипед-сервер

При таком подходе каждый собирает свой конфиг для рендеринга приложения на сервере самостоятельно.

Как показывает практика и опыт изученных материалов, нет какого-то однозначного подхода как это внедрить. Есть общие концепции, но нюансы у каждого проекта будут свои.

Сначала рассмотрим, что нужно сделать перед внедрением SSR с помощью данного подхода.

Для начала необходимо сформировать полностью плоские роуты и вынести их на уровень конфигурации, иначе придется разбирать дерево маршрутов. Это самый худший вариант — разбирать дерево реакт компонентов и вызывать инициализацию данных для внутренних маршрутов. Плоский список роутов позволяет по сути просто пробежаться по нему, сматчить нужный маршрут и вызвать инициализацию данных, затем прокинуть их в приложение, что касается redux или apollo.

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

При работе с Apollo на клиенте, есть особенность с вложенными запросами в дочерних компонентах: они не будут вызваны на стороне сервера, если компоненты изначально скрыты и их отображение зависит от родительских данных (запроса). С этим тоже нужно что-то делать: например, все манипуляции на запрос данных уносить на верхний уровень необходимой страницы.

Также, необходимо пробежаться по всему приложению и найти компоненты и библиотеки использующие обращение к window объектам при рендеринге страницы:

  • обернуть эти компоненты в динамические импорты без инициализации на сервере;
  • или переписать их, чтобы вызов window происходил только на клиенте, но компонент рендерился на сервере.

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

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

Нужно оптимизировать работу с API, чтобы эндпоинты отдавали данные максимально быстро. Иначе пользователь будет видеть белый экран продолжительное время — больше время ответа от API, больше время ответа клиенту. И SSR будет отъедать больше ресурсов сервера, чем это необходимо. Соответственно, в единицу времени мы сможем обработать меньше запросов — нужно будет увеличивать ресурсы.

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

Информация по SSR и проблемам возникающим с таким подходом очень разрозненна. 100% внедрение этого подхода произойдет не по щелчку пальцев.

Вероятен шанс так до конца и не внедрить полноценный SSR или просто потратить очень много времени на борьбу с багами.

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

Способ 3. Выбираем один из специализированных фреймворков для SSR типа Next.js/Razzle/After.js/etc

Внедрить в текущее решение один из существующих специализированных фреймворков для SSR сложно — это не какой-то npm пакет, который подключил и все работает.

Данный подход подразумевает, что мы будем поддерживать две версии нашего приложения: старого (CSR), которое нужно поддерживать и написание нового приложения (SSR). На время перехода они будут жить параллельно друг другу.

При выборе SSR фреймворка не стоит использовать starter-* сборки на основе фреймворков, потому что:

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

Вместо starter-* лучше использовать специализированный SSR фреймворк и настроить его с чистого листа.

В чем плюс специализированных фреймворков, если мы сетапим их с нуля и только под наши нужды:

  • хорошая поддержка — есть основные мейнтейнеры и их довольно много;
  • хорошее и активное комьюнити;
  • есть достаточно понятная документация;
  • есть roadmap развития проекта;
  • хорошие примеры;
  • понятный путь кастомизации;
  • качество — старый технический долг будет выплачен и произойдет улучшение качества приложения (legacy -> without legacy).

Из минусов специализированных фреймворков: нужно будет мириться с предлагаемой архитектурой, но зато решение будет работать и скорее всего закроет 99% потребностей.

Сравнительный график использования самых популярных решений для SSR за все время. На текущий момент активное использование Next.js на порядок больше чем Razzle/After.js. Nuxt в расчет не берем — так как приложение мы пишем на React.js.

Сравнительный график популярных решений для SSR

Кратко сравним плюсы и минусы подходов.

Плюсы и минусы подходов при переходе на SSR

Обсудили результат исследования

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

От второго подхода тоже решено было отказаться и попробовать использовать третий вариант, но с некоторыми нюансами.

В качестве эксперимента, чтобы окончательно обосновать бизнесу, что необходима разработка параллельного приложения, я попробовал перенести старые UI компоненты на Next.js.

По идее — это самый оптимальный подход по таким характеристикам как:

  • время — большая часть проекта уже написана и перенос проекта не занимает столько же времени, сколько написание полностью с нуля.
  • результат — с костылями, но в конечном итоге проект будет работать, так как перенос по частям позволяет решать проблемы по мере поступления. При этом ровно столько же времени можно потратить на 2 способ, занимаясь написанием своего велосипеда, только без гарантии результата;
  • качество — скорее всего станет лучше, частично будет переосмыслена архитектура приложения и при переносе будет сделан упор на аспекты связанные с SSR.

Минусы:

  • останется старый UI, который в случае ребрендинга потащит за собой все старые болячки;
  • нет возможности сразу писать в едином стиле, всегда будут появляться новые бизнес фичи, которых не было в старом варианте. Например мы хотим TS (TypeScript) и Styled Components, а старый проект написан на JS и LESS/SASS/CSS modules;
  • тащим старый стейт менеджмент, который не всегда оптимален.

Собрали демо-стенд

У нас давно назревал отказ от UI использующего Ant Design — 3 версия, которая обновлялась со 2-ой, плюс было много оберток над внутренними стилями в т.ч. и полный копипаст из Antd компонентов.

И тут этот вопрос встал довольно остро.

В итоге я собрал тестовый стенд с: TypeScript, Styled Components + LESS/SASS/CSS modules (для подключения старых компонент), Next.JS, подключен Apollo Client для работы с нашим GraphQL сервером. Созданы example страницы, содержащие:

  • компоненты использующие Styled Components;
  • запросы к Apollo Server за данными и отображение этих данных на странице;
  • вывод данных из cookies;
  • компоненты: Button, Content, Row, Col из старой UI библиотеки с Ant Design.

Проанализировал итоговый бандл, без старой библиотеки компонентов, и с ней. Получил следующий результат: +350кб к каждой странице при использовании старого UI.

По хорошему нужно было использовать ES модули из Ant Design — в этом случае получаются атомарные компоненты со своими стилями.

Но в текущей реализации этого не получалось сделать из-за сборки стилей. Совсем хорошо отказаться от использования antd, чтобы использовать только то, что действительно необходимо, не затаскивая лишние зависимости.

Пришли к решению

Какое-то время общались с бизнесом, обсуждали внутри команды и в конечном итоге остановились на том, что будем полностью писать с нуля приложение на SSR с использованием Next.js, появятся: новый дизайн, компоненты, требования к стандартам и архитектуре.

Когда новая страница будет реализована, будем переключать ее на балансере (Nginx) на новую SSR версию, а старый код удалять. Таким образом произойдет постепенная миграция приложения на SSR.

Коллаборация команд позволила перейти к новым: UI Kit и Web App

Для взаимодействия с поисковыми роботами есть подход, называемый “Динамический рендеринг” — подход который позволяет отдавать разный контент поисковикам и пользователям.

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

Когда рендерить на клиенте, а когда на сервере?

Первой страницей было решено переделать страницу подписки на доступ к платному контенту — на CSR это был по сути лендинг, который рассказывал о нашем продукте. В новой версии это становится полноценной страницей — витриной материалов с разбиением по категориям, где категории тоже являются отдельными страницами.

Решили проблемы

Было принято решение писать на TypeScript сразу, до этого не было большого опыта с написанием всего проекта на TS. В первые недели бывало уходило пару-тройку часов на то, чтобы разобраться какой тип нужен и как его правильно описать, особенно с npm зависимостями. Было тяжело не писать any везде, но мы справились, иначе зачем внедрять TS? Да это отняло много времени, но в итоге принесло хорошие плоды.

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

Был переосмыслен процесс сборки и выкатки на контуры. До этого у нас был предпрод, для которого собирались контейнеры и они же выкатывались на prod контур. И в старом приложении частенько приходилось писать логику, на основе определения окружения по url или cookies.

В случае с SSR было принято решение, что для prod контуров будет полностью отдельная сборка, чтобы все, что нам нужно мы могли передать через .env файл. Так как рендер происходит на сервере, то нам не нужно на стороне express сервера по заголовкам понимать с какого контура пришел запрос и прокидывать эти данные внутрь Next.js приложения.

На старте не было возможности учесть все нюансы реализации:

  • возможного поведения и состояния компонентов;
  • правки на баги при тестировании QA;
  • что в какой-то момент сломается рендеринг на сервере и придется откатываться по истории git и смотреть, что его сломало;
  • что уйдет время, на то, чтобы провести нагрузочное тестирование;
  • несколько раз пересмотреть часть архитектуры по мере выполнения задач;
  • обсудить и принять решение о том, как мы работаем с картинками (svg и обычными);
  • детальное ревью членами команды и внесение правок, так как это важная веха в развитие проекта;
  • выработать концепцию работы с Apollo — генерация типов, локальное хранилище, работа с запросами на разных уровнях приложения;
  • и еще много различных мелких нюансов.

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

По сути на первом этапе мы заложили 90% фундамента, на основе которого будут строиться все остальные страницы.

Перевод на SSR последующих разделов сайта (например статей), уже обошелся нам без превышения сроков в 3 раза, начали укладываться практически один в один по плану.

В итоге для организации проекта был создан monorepo с использованием lerna.

Итоговая архитектура приложения, в упрощенном виде, выглядит так:

Архитектура SSR приложения

Первоначальная оценка для запуска первой SSR фичи была в 2 недели 5 дней на весь новый раздел. Реальное время на выход в production заняло 9 недель и 2 дня. Время мы считали по YouTrack.

Как мы работаем с рендерингом страниц

Мы используем кастомный сервер на основе express, который позволяет нам описывать дополнительную логику работы с кэшированием страниц в Redis.

Однако, если вы тоже используете Next.js и метод app.renderToHTML, то лучше перестать это делать, так как это приватный метод. Начиная с версии 9.5 мы столкнулись с проблемой выставления заголовков ответа при рендеринге страницы из кэша. Были изменены внутренности этого метода. Теперь необходимо использовать метод app.render, который отрабатывает корректно и является публичным для использования.

Страницы, которые доступны всем, мы кладем в кэш Redis на 5–60 минут, в зависимости от приоритета, чтобы снять нагрузку с сервера, так как операция рендеринга данных, является затратной операцией.

До июня 2020 мы использовали 9 версию Next.js, и для того, чтобы инициализировать данные на стороне сервера в Apollo мы использовали большой HOC, который разбирал все дерево запросов к apollo-server в компонентах — можно назвать его магическим Dependency Injection, который нам не очень нравился в использовании и вызывал определенные проблемы:

  • некорректная работа с error состоянием (не важно какие были использованы error policy) в запросах при рендере на стороне сервера. Ошибки, которые происходили на стороне сервера в запросах, рендерились только на сервере, но на клиенте при rehydrate исчезали. Для решения этой проблемы мы перенесли ошибочный ответ в data и проверяли пришедший __typename, чтобы определить ошибку или корректный ответ;
  • для того, чтобы сделать редирект или отдать корректный HTTP код на основе полученных данных, приходилось подмешивать серверный контекст, которому совершенно не место в компонентах, но выхода не было;
  • иногда ломалась логика работы loading состояния.

В версии Next.js 9.3 были анонсированы новые вспомогательные методы getServerSideProps / getStaticProps, которые позволили нам избавиться от этого HOC и запрашивать данные в getServerSideProps функции, в которой мы имеем прямой доступ к Apollo Client, серверному контексту и пр. Можем корректно работать с ошибками в error, дожидаться ответа и инициализировать состояние Apollo или делать необходимые редиректы вне React страниц/компонент и оставить запрос за данными на самом верхнем уровне — мы не используем запросы за данными во внутренних компонентах страницы.

Результаты

Пожалуй, это все аспекты, которые хотел затронуть в этой статье.

Работы за последние 8 месяцев с запуска SSR, было проделано колоссальное количество и еще больше предстоит.

Из положительных эффектов, которые принес нам процесс перевода приложения на SSR можно выделить следующее:

  • индексация поисковыми системами страниц отрендеренных на стороне сервера;
  • использование на SSR в качестве стейт менеджера Apollo и соответственно отказ от запросов в микросервисы на прямую в отличие от CSR версии портала, в которой был смешан Apollo, работа с REST API, и управление state через redux;
  • избавились от рендеринга Markdown разметки в React — перешли на формирование контента с использованием editor.js;
  • внедрение разметки по Schema.org;
  • улучшилось восприятия контента пользователями (результат общения с клиентами);
  • потихоньку избавляться от legacy (и идти к новому :)).

поднялась оценка по PageSpeed Insights.

Для переведенных на SSR страниц подписки с 15 пунктов:

  • до 40 пунктов с учетом подключенных third-party скриптов;
  • до 86 пунктов без использования third-party скриптов.

Для переведенных на SSR страниц статей с 20–28 пунктов:

  • до 50–60 пунктов с учетом подключенных third-party скриптов;
  • до 86–96 пунктов без использования third-party скриптов.

В 4 квартале 2020 для страниц подписки и статей:

  • вырос органический трафик из поисковых систем на 26%;
  • глубина просмотра увеличилась на 70%;
  • среднее время на сайте увеличилось на 1 минуту.

Была оптимизирована метрика Time to Interactive (TTI):

  • для мобильных стал ~12.3 секунды (было ~20 секунд) [еще предстоят работы по оптимизации];
  • для ПК стал ~3.8 секунды (был ~4 секунды, фактически ~8.5 секунд — в старой версии приходилось ждать контент еще примерно 3–5 секунд — пользователь мог видеть только меню и футер. Сейчас получает весь контент и может начинать с ним взаимодействие).

Также была оптимизирована метрика First Contentful Paint (FCP):

  • для мобильных стал ~1.6 секунды (был ~8.3 секунды);
  • для ПК стало ~0.4 секунды (был ~2 секунды).

Параллельно с переходом на SSR:

  • удалось запустить Gitlab CI, в котором гоняем: линтеры, валидность TS, тесты. Эти же процессы удалось распространить на все репозитории нашей фронтовой команды;
  • повысилось качество приложения, безопасность изменений и внедрения новых фич, надежность работы приложения при использовании TS;
  • произошел отказ от Ant Design и появилась возможность разрабатывать собственный UI Kit, который мы можем модифицировать и затачивать под наши конкретные нужды.

P.S.

Если вам интересно узнать больше об аспектах с которыми мы столкнулись в процессе перевода приложения на SSR, проголосуйте в комментариях за одну из следующих тем:

  1. Monorepo + TS + Apollo + SSR (Next.js);
  2. Monorepo + TS + HMR в Next.js (Hot Reload на Client / Server);
  3. Apollo + Local state management в Next.js;
  4. Next.js + работа с кэшированием.
  5. Работа с переходами между SSR / CSR и не только.

--

--