Рецепты адаптивной раскладки в react приложениях с SSR

October 04, 2019
Also available in:

Мистика

Недавно, при миграции части страниц нашего главного сайта на 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 по умолчанию считает все медиа запросы истинными (в нашем случае отдаст мобильную разметку).
  • Ну и получается, что пользователь с большим экраном:

    1. открывает страницу и получает с сервера html c мобильной вёрсткой (непродолжительное время видит её)
    2. видит десктопный лейаут с нестилизованной ссылкой, т.к. во время гидрации 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). Если честно, я не стал тревожить легаси проект, чтобы познать его тайну 💀. Но полагаю, что дело в какой-нидь лишней перерисовке которая инициируется вышележащими компонентами.