
Работая над продуктом для «внешнего» пользователя, разработчик интерфейсов может столкнуться с необходимостью использовать серверный рендеринг. Поисковые системы по прежнему остаются значительным источником трафика почти для любого сайта, контент на котором не будет проиндексирован, если сервер его не отдаст. Кроме того, «приготовленный» определенным образом, серверный рендеринг может положительно сказаться на всех основных метриках производительности проекта, существенно ускорив загрузку страниц. В то же время, эта технология накладывает определенные ограничения, с которыми необходимо считаться и повышает требования к экспертизе разработчика. В статье будут предметно разобраны подводные камни, с которыми можно столкнуться при разработке платформы, использующей отрисовку HTML в Node.js окружении, и даны практические рекомендации, как их избежать.
Теория
Стратегии серверного рендеринга можно условно разделить на две группы, в первой разметка формируется в рантайме, во второй это происходит задолго до того, как пользователь зайдет на страницу. Основное преимущество стратегий первой группы - гибкость, основное преимущество второй - производительность, с недостатками ситуация зеркальная.
При этом первоосновой всех проблем серверного рендеринга, независимо от того, какой именно выбран подход, является несоответствие HTML, формируемых на сервере и на клиенте. В случае, если разметка, сгенерированная на сервере, не согласуется с той, что сгенерирует клиент, будут проблемы. Это четко прописано в документации и повторяется в каждом обучающем пособии, но практика показывает, что об этой особенности довольно легко забыть.
Можно выделить две проблемы, с которыми наверняка столкнется разработчик, в зависимости от того, как именно серверный HTML не соответствует клиентскому.
В случае несоответствия атрибутов, React проигнорирует эту часть разметки, фреймворк намеренно не обновляет атрибуты при гидратации. На практике это означает, что в коде
<div className={typeof window !== ‘undefined’ ? ‘clientClassName’ : ‘serverClassName’} />
У div атрибут className всегда будет «серверным». Решение данной проблемы будет подробно разобрано в практической части статьи.
В случае, если не совпадает структура разметок, присутствуют разные уровни вложенности, разные теги и разный контент в текстовых нодах, процесс гидратации будет прерван. Фреймворк полностью заменит DOM дерево на то, что было сформировано на клиенте. Определенную сложность проблеме добавляет то, что о сломанной гидратации, по невнимательности, можно узнать не сразу. Если неизменные атрибуты сложно не заметить, то у второй проблемы менее заметные симптомы, но и их можно сразу увидеть, если знать куда смотреть:
Консоль. С 18й версии, фреймворк, по умолчанию, даже в продакшен-окружении сообщит вам о проблеме.
На элементы, наличие состояния у которых может обеспечивать браузер. Инпуты и его производные, радиокнопки и чекбоксы.
Изображения так же могут быть индикатором того, что React не в состоянии «гидрировать» серверную разметку.
Последние два пункта будет проще понять на живом примере. В тестовом репозитории для статьи, есть роут с намеренно сломанной гидратацией.
Зайдите на него, выставите 3G throttling в инструментах разработчика, перезагрузите. Попробуйте повзаимодействовать с инпутами. Состояние радиокнопки, чекбокса и инпута будет сброшено в дефолтное после загрузки клиента. Во вкладке network с выключенным кэшем, вы, вероятно, увидите дубль запроса на изображение. Все это признаки того, что DOM дерево было полностью заменено на «новое». Попробуйте повторить эти действия на аналогичной странице, где гидратация отрабатывает корректно. В разнице и заключается «физический» смысл предмета обсуждения.
При не работающей гидратации ухудшается метрика Time To Interactive. Perfomance преимущества, ради которых и могла внедряться технология, во многом сводятся на нет. Что еще хуже, игнорирование проблемы заложит благоприятную среду для плавающих дефектов или даже уязвимостей, которые будет сложно воспроизвести и исправить. Разработчики React не могут дать гарантий, что инструмент будет стабильно работать, если использовать его вопреки рекомендациям.
Плавно переходим к примерам
Теперь, когда понятны основное требование фреймворка по соответствию разметок, и последствия, к которым невыполнение этого требования приводит, необходимо понять, как эти несоответствия появляются. Ответ здесь кроется в несоответствии сервера и клиента как платформ. На сервере отсутствует window, там есть globalThis, но в его полях отсутствует информация о размере вьюпорта, нет ссылки на document и других вещей, наличие которых на клиенте гарантировано в любом браузере. Все это накладывает определенные ограничения на код, который предполагается использовать на обеих платформах. Здесь можно перейти к конкретным примерам.
Использование window в рендере компонентов и Invalid DOM Nesting
Само по себе использование исключительно клиентский свойств окружения не приводит к несоответствию разметок, но начать разговор об ограничениях SSR нужно именно с этого.
console.log(window); // Этот код приведет к ошибке на сервере
if (window) { // Так же как и этот
console.log(window);
}
При острой необходимости использовать window в рендере, можно использовать уже упомянутое условие
if (typeof window !== ‘undefined’) { // Так ошибки не будет
console.log(window);
}
Так же, хук useEffect не исполняет пробрасываемый в него эффект на сервере, в нем можно безопасно обращаться к свойствам клиента не используя проверки выше
useEffect(() => {
console.log(window); // Здесь проверка typeof window не нужна и нежелательна
},[])
То же самое можно сказать про недопустимую структуру тегов. Даже при отсутствии несоответствий в разметке, React выдаст hydrate error в случае, если, например, тег <a> будет расположен внутри такого же тега <a>. Этот кейс стоит держать в голове разрабатывая как изолированные компоненты, так и no-code конструкторы, в которых структуру разметки может формировать человек, вообще не знакомый с HTML.
Использование параметров window для верстки и ее атрибутов
const defaultWindowSize = { width: 320, height: 768 };
const Component = () => {
const windowSize =
typeof window !== 'undefined'
? { width: window.innerWidth, height: window.innerHeight }
: defaultWindowSize;
const isMobile = windowSize.width <= 768;
return <div className={isMobile ? 'mobile' : 'desktop'} />;
};
const defaultWindowSize = { width: 320, height: 768 };
const Component = () => {
const windowSize =
typeof window !== 'undefined'
? { width: window.innerWidth, height: window.innerHeight }
: defaultWindowSize;
const isMobile = windowSize.width <= 768;
return isMobile ? ( // Абсолютно идентичный пример, несмотря на два <div />
<div className='mobile' />
) : (
<div className='desktop' />
);
};
Код выше приведет к несоответствию разметок, если пользователь зайдет на страницу с десктопа. В примере несоответствие присутствует только на уровне атрибутов, поэтому React просто проигнорирует их, а пользователь на десктопе увидит верстку, предназначенную для мобильных устройств.
const defaultWindowSize = { width: 320, height: 768 };
const Component = () => {
const windowSize =
typeof window !== 'undefined'
? { width: window.innerWidth, height: window.innerHeight }
: defaultWindowSize;
const isMobile = windowSize.width <= 768;
return (
<div className={isMobile ? 'mobile' : 'desktop'}>
{isMobile.toString()} // Это сломает процесс гидратации
</div>
);
};
А вот здесь мы уже получим ошибку, при этом увидим актуальные атрибуты и контент в инструментах разработчика, поскольку React заменит DOM дерево на «новое». Проблему можно решить, используя связку хуков useState и useEffect:
const Component = () => {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
setIsMobile( windowSize.width <= 768);
}, []);
return (
<div className={isMobile ? ‘mobile’ : ‘desktop’}>
{isMobile.toString()}
</div>
);
}
При такой реализации несоответствия на момент гидратации не будет, а эффект, который выполнится после процесса, актуализирует значение isMobile, тем самым вызвав рендер и актуализацию контента непосредственно в DOM дереве.
Тем не менее, такое исполнение не может быть рекомендовано. Пользователь на десктопе может видеть мобильную верстку, которая будет резко перерисована на десктопную после полной загрузки кода клиентом. Это негативно влияет на пользовательский опыт. Наиболее простым решением из категории приемлемых, в примере, будет полностью отказаться от использования Javascript.
const Component = () => {
return (
<>
<div className=‘mobile’ />
{"true"}
</div>
<div className=‘desktop’ />
{"false"}
</div>
</>
);
}
.mobile {
display: block;
@media screen and (min-width:1024px) {
display: none;
}
}
.desktop {
display: none;
@media screen and (min-width:1024px) {
display: block;
}
}
Это очень простой кейс, но представленный для его решения принцип является общим и для более сложных сценариев. Важно понимать, что JS и заложенная в него логика загрузятся значительно позже, чем разметка и стили, поэтому если интерфейс некоего компонента невозможно разработать без использования window, document или других специфичных для клиента свойств, лучше вообще не отрисовывать такой компонент на сервере.
Динамический контент
Использование таймеров, генераторов уникальных чисел, и тому подобные вещи так же наверняка приведут к несоответствиям. Проблему могут создать и волатильные, динамические данные, это наиболее актуально для архитектур, где разметка формируется заранее. Хорошим решением будет вообще не отрисовывать компоненты, содержащие в себе динамический контент на сервере, вместо этого можно показать прелоадер, скелетный каркас или любую другую статическую заглушку, соответствующую вашей дизайн - системе.
Чем руководствоваться при выборе стратегии SSR
Выбирая, формировать разметку заранее или в рантайме, решите, что для вас наиболее критично, производительность или гибкость. Классический SSR при грамотном подходе может работать хорошо, но SSG/ISR будут несравнимо быстрее при прочих равных. В то же время, даже планируемая «когда - нибудь» потом персонализация, даже самая простая, может стать серьезным аргументом в пользу того, чтобы использовать «классику». Если на сайте планируется светлая и темная тема, то, в случае ISR/SSG, нужно будет формировать уже две HTML-ки, светлую и темную соответственно, поскольку заранее неизвестно, какая именно тема будет выбрана у пользователя, в момент, когда он зайдет на страницу. На каждый такой кейс, количество HTML, хранящегося в базе, будет удваиваться. Постарайтесь понять, как будет развиваться продукт, какие «фичи» планируются будущем. Взвесьте все за и против, постарайтесь донести их до заказчика, потому что менять фундамент всегда очень сложно и дорого, иногда невозможно.
Резюме
Общие рекомендации:
Если на вашем проекте в том или ином виде все же используется серверный рендеринг, это важно держать в голове на всем протяжении цикла разработки. Даже небольшое несоответствие серверного и клиентского HTML в одном единственном компоненте, может негативно сказаться на всех страницах, где такой компонент используется.
Совершенствуйте свои навыки верстки. Разработчики интерфейсов нередко пренебрегают этим, зачастую используя JS там, где без него можно обойтись. В проектах с SSR эта проблема встаёт особенно остро. Отдавайте предпочтение решениям на чистом HTML и CSS там, где это возможно.
В качестве закрепления, рекомендую читателю уже упомянутый репозиторий, в котором можно воспроизвести приведенные в статье примеры. Его можно использовать как песочницу, чтобы через эксперименты улучшить свое понимание темы.