Мистика
Недавно, при миграции части страниц нашего главного сайта на gatsby мы столкнулись со странной проблемой: одна из ссылок в главной навигации была нестилизована при первоначальной загрузке страницы. Что-то вроде:
После первого же перехода на любую другую страницу стили появлялись 🤯.
Ситуацию ещё более усугублял тот факт, что компонент, ответственный за отображение навигации, использовался и в старом приложении, использующем nextjs, но работал он там прекрасно 👻.
Расследование
Недолго думая я полез в DOM и обнаружил, что у этой ссылки css-классы относились к мобильной разметке, хотя смотрел я всё это на десктопе 🤪.
В голове крутилась мысль: react косячит при гидрации. Верить в неё конечно не хотелось: такую серьёзную проблему бы уже давно нашли, да и старом приложении ВСЁ РАБОТАЕТ.
Вспоминаю требование к гидрации:
React ожидает, что отрендеренное содержимое идентично на сервере, и на клиенте.
Бегу смотреть исходный код компонента 🕵️♂️:
// упрощённый NavBar.js
import React from 'react'
import Media from 'react-media'
export const NavBar = () => (
<Media queries={{ maxWidth: 599 }}>
{matches => (matches ? <MobileNav /> : <DesktopNav />)}
</Media>
)
Вердикт
Видите здесь проблему?
- В компоненте используется react-media, за счёт которого для мобильных и десктопных экранов отдаётся разная разметка
- Мы переходим на статическую генерацию страниц (используем gatsby), т.е. SSR у нас происходит во время сборки сайта. И на этом этапе мы, конечно, не знаем на каком устройстве будет отображаться страница, а
react-media
при SSR по умолчанию считает все медиа запросы истинными (в нашем случае отдаст мобильную разметку). - Ну и получается, что пользователь с большим экраном:
- открывает страницу и получает с сервера html c мобильной вёрсткой (непродолжительное время видит её)
- видит десктопный лейаут с нестилизованной ссылкой, т.к. во время гидрации react генерирует кашу из 2-х разных лейаутов
Как быть?
Чтобы избежать таких проблем нужно:
-
Отдавать предпочтение обычным css медиа выражениям.
Даже если с помощью CSS не получается привести одну и ту же разметку к необходимому виду на разных устройствах, можно разделить и DOM под разные разрешения/устройства и одновременно выдать все варианты, а с помощью медиа выражений отображать нужные:let DesktopOnly = styled.div` @media (max-width: 599px) { display: none; } ` let MobileOnly = styled.div`...` export const NavBar = () => ( <> <MobileOnly> <MobileNav /> </MobileOnly> <DesktopOnly> <DesktopNav /> </DesktopOnly> </> )
В этом случае вы сможете не только избежать проблем с гидрацией, но и пользователь получит лучший опыт при работе с вашим сайтом: разметка не будет прыгать при загрузке. Т.к. браузер ещё до исполнения JavaScript отрисует сайт согласно CSS и HTML, а гидрация лишь вдохнёт в него жизнь.
-
В случае когда прибегнуть к вышеупомянутому подходу нельзя - прибегнуть к использованию
react-media
и аналогичных решений. Например когда:- может пострадать SEO (не сталкивался с подобной проблемой, но подозреваю что дублирование контента, увеличение размера страницы... может к этому привести. напишите в комментариях, если знаете кейсы)
- может пострадать производительность (например для десктопа вы отображаете какой-то сложный элемент) ВНИМАНИЕ! Обязательно измеряйте производительность перед тем как делать подобные оптимизации. Факт того, что react'у придётся отрисовать на 50 html-блоков больше, не должен вас пугать.
- может пострадать что-то ещё 🤷♂️
А как же проблема с гидрацией при использовании второго подхода?
Как я заметил в начале, эта проблема не могла остаться незамеченной и решение у неё есть - отрисовка в 2 прохода:
- выберете раскладку которую хотите использовать по умолчанию:
- если вы используете не статический сайт, то можете выбрать его на основе User Agent
- для статических сайтов можете отталкиваться от статистики использования вашего сайта
- рендерите это состояние во время серверной отрисовки и гидрации
- запускаете дополнительную перерисовку на клиенте, если актуальное состояние отличается от того, что предполагали
Официальная документация предлагает использовать для этого флаг в стейте, и react-media
такое умеет, нужно лишь указать свойство defaultMatches
:
export const NavBar = () => (
<Media
queries={{ mobile: { maxWidth: 599 } }}
// мы знаем что большинство наших пользователей
// используют мобильные устройства
defaultMatches={{ mobile: true }}
>
{matches => (matches.mobile ? <MobileNav /> : <DesktopNav />)}
</Media>
)
З.Ы.
"А почему же мы не столкнулись с этой проблемой в старом приложении?" - заметит внимательный читатель. Действительно, ведь оно тоже использует SSR и не использует знание о браузере (User Agent). Если честно, я не стал тревожить легаси проект, чтобы познать его тайну 💀. Но полагаю, что дело в какой-нидь лишней перерисовке которая инициируется вышележащими компонентами.