company_banner

Адаптивный или отзывчивый? Разбираем структуру React-компонентов



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

    Сначала давайте разберемся с терминологией. Мы часто слышим термины adaptive и responsive. Что они означают? Чем отличаются? Как это относится к нашим компонентам?

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

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

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

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

    Я сосредоточусь на двух сферах отображения интерфейсов — мобильной и десктопной. Под мобильным отображением мы будем подразумевать ширину, например, ≤ 991 пикселей (само число не принципиально, это просто константа, которая зависит от вашей дизайн-системы и вашего приложения), а под десктопным отображением — ширину больше выбранного порога. Я намеренно пропущу отображения для планшетов и широкоформатных мониторов, потому что, во-первых, не всем они нужны, а во-вторых, так будет проще излагать. Но паттерны, про которые мы будем говорить, расширяются одинаково для любого количества «отображений».

    Также я почти не буду говорить про CSS, в основном речь пойдет о компонентной логике.

    Frontend @youla


    Коротко расскажу о нашем стеке в Юле, чтобы понятно было, в каких условиях мы создаем наши компоненты. Мы используем React/Redux, работаем в монорепе, используем Typescript и пишем CSS на styled-components. В качестве примера давайте рассмотрим три наших пакета (пакеты в концепции монорепы — это связанные между собой NPM-пакеты, которые могут представлять собой отдельные приложения, библиотеки, утилиты или компоненты — степень декомпозиции вы выбираете сами). Мы рассмотрим два приложения и одну UI-библиотеку.

    @youla/ui — библиотека компонентов. Их используем не только мы, но и другие команды, которым нужны «юловские» интерфейсы. В библиотеке есть много всего, начиная с кнопочек и полей ввода, и заканчивая, например, шапкой или формой авторизации (точнее ее UI-часть). Мы считаем эту библиотеку внешней зависимостью нашего приложения.

    @youla-web/app-classified — приложение, отвечающее за разделы каталога/товара/авторизацию. По бизнес-требованиям все интерфейсы здесь должны быть адаптивными.

    @youla-web/app-b2b — приложение, отвечающее за разделы личного кабинета для профессиональных пользователей. Интерфейсы этого приложения исключительно десктопные.

    Далее мы рассмотрим написание адаптивных компонентов на примере этих пакетов. Но сначала нужно разобраться с isMobile.

    Определение мобильности isMobile && <Component />


    import React from 'react'
    
    const App = (props) => {
     const { isMobile } = props
    
     return (
       <Layout>
         {isMobile && <HeaderMobile />}
         <Content />
         <Footer />
       </Layout>
     )
    }
    

    Прежде чем начинать писать адаптивные компоненты, нужно научиться определять «мобильность». Eсть множество способов реализации определения мобильности. Я хочу остановиться на некоторых ключевых моментах.

    Определение мобильности по ширине экрана и по user-agent


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

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

    1. Создаем константы с граничными точками и сохраняем их в теме (если ваше CSS-решение позволяет). Сами значения могут быть такими, какие ваши дизайнеры посчитают наиболее подходящими для вашей UI-системы.
    2. Сохраняем текущий размер экрана в redux/mobx/context/any-источнике данных. Где угодно, лишь бы у компонентов и, желательно, у прикладной логики был доступ к этим данным.
    3. Подписываемся на событие изменения размера и обновляем значение ширины экрана на то, которое будет вызывать цепочку обновлений дерева компонентов.
    4. Создаем простые вспомогательные функции, которые с помощь ширины экрана и констант вычисляют текущее состояние (isMobile, isDesktop).

    Вот псевдокод, который реализует эту модель работы:

    const breakpoints = {
     mobile: 991
    }
    
    export const state = {
     ui: {
       width: null
     }
    }
    
    const handleSubscribe = () => {
     state.ui.width = window.innerWidth
    }
    
    export const onSubscribe = () => {
     window.addEventListener('resize', handleSubscribe)
    }
    
    export const offSubscribe = () =>
     window.removeEventListener('resize', handleSubscribe)
    
    export const getIsMobile = (state: any) => {
     if (state.ui.width <= breakpoints.mobile) {
       return true
     }
    
     return false
    }
    
    export const getIsDesktop = (state) => !getIsMobile(state)
    
    export const App = () => {
     React.useEffect(() => {
       onSubscribe()
    
       return () => offSubscribe()
     }, [])
    
     return <MyComponentMounted />
    }
    
    const MyComponent = (props) => {
     const { isMobile } = props
    
     return isMobile ? <MobileComponent /> : <DesktopComponent />
    }
    
    export const MyComponentMounted = anyHocToConnectComponentWithState(
     (state) => ({
       isMobile: getIsMobile(state)
     })
    )(MyComponent)
    

    При изменении экрана значения в props для компонента будут обновляться, и он станет корректно перерисовываться. Есть множество библиотек, которые реализуют эту функциональность. Кому-то будет удобнее использовать готовое решение, например, react-media, react-responsive и т.д., а кому-то проще написать своё.

    В отличие от размера экрана, user-agent не может динамически меняться во время работы приложения (строго говоря, может, через инструменты разработчика, но это не пользовательский сценарий). В этом случае нам не нужно использовать сложную логику с хранением значения и пересчётом, достаточно единожды распарсить строку window.navigator.userAgent, сохранить значение, и готово. Есть куча библиотек, которые помогут вам в этом, например, mobile-detect, react-device-detect и т.д.

    Подход с user-agent проще, но использовать только его недостаточно. Любой, кто серьезно разрабатывал адаптивные интерфейсы, знает про «магический поворот» iPad-ов и подобных ему девайсов, которые в вертикальном положении попадают под определение мобильных, а в горизонтальном — десктопных, но при этом имеют user-agent мобильного устройства. Также стоит отметить, что в рамках полностью адаптивно/отзывчивого приложения по одной лишь информации о user-agent невозможно определить мобильность, если пользователь использует, например, десктопный браузер, но сжал окно до «мобильного»размера.

    Также не стоит пренебрегать информацией о user-agent. Очень часто в коде можно встретить такие константы, как isSafari, isIE и т.д., которые обрабатывают «особенности» этих устройств и браузеров. Лучше всего комбинировать оба подхода.

    В нашей кодовой базе мы используем константу isCheesySafari, которая, как следует из названия, определяет принадлежность user-agent к семейству браузеров Safari. Но помимо этого у нас есть константа isSuperCheesySafari, которая подразумевает под собой мобильный Safari, соответствующий iOS версии 11, который прославился множество багов вроде такого: https://hackernoon.com/how-to-fix-the-ios-11-input-element-in-fixed-modals-bug-aaf66c7ba3f8.

    export const isMobileUA = (() => magicParser(window.navigator.userAgent))()
    
    import isMobileUA from './isMobileUA'
    
    const MyComponent = (props) => {
     const { isMobile } = props
    
     return (isMobile || isMobileUA) ? <MobileComponent /> : <DesktopComponent />
    }
    

    А что с media-запросами? Да, действительно, в CSS есть встроенные инструменты для работы с адаптивностью: медиа-запросы и их аналог, метод window.matchMedia. Их можно использовать, но логику «обновления» компонентов при изменении размера всё равно придется реализовывать. Хотя лично для меня использование синтаксиса media-запросов вместо привычных операций сравнения в JS для прикладной логики и компонентов — это сомнительное преимущество.

    Организация структуры компонента


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

    Первый вид — это компоненты, заточенные либо под мобилку, либо под десктоп. В таких компонентах в наименованиях часто встречаются слова Mobile/Desktop, которые явно указывают на принадлежность компонента к одному из видов. В качестве примера такого компонента можно рассмотреть <MobileList /> из @youla/ui.

    import { Panel, Cell, Content, afterBorder } from './styled'
    import Group from './Group'
    import Button, { IMobileListButtonProps } from './Button'
    import ContentOrButton, { IMobileListContentOrButton } from './ContentOrButton'
    import Action, { IMobileListActionProps } from './Action'
    
    export default { Panel, Group, Cell, Content, Button, ContentOrButton, Action }
    export {
     afterBorder,
     IMobileListButtonProps,
     IMobileListContentOrButton,
     IMobileListActionProps
    }
    

    Этот компонент, помимо очень вербозного экспорта, представляет из себя список с данными, разделителями, группировками по блокам и т.д. Наши дизайнеры очень любят этот компонент и повсеместно используют его в интерфейсах «Юлы». Например, в описании на страничке товара или в нашей новой функциональности тарифов:


    И еще в N мест по всему сайту. Также у нас есть похожий компонент <DesktopList />, который реализует эту функциональность списков для десктопного разрешения.

    Компоненты второго вида содержат в себе логику как десктопную, так и мобильную. Давайте посмотрим на упрощенную версию отрисовки нашего компонента <HeaderBoard />, который живет в @youla/app-classified.

    Мы для себя нашли очень удобным выносить все styled-component-ы для компонента в отдельный файл и импортировать их под неймспейсом S, чтобы отделить в коде от других компонентов: import * as S from ‘./styled’. Соответственно, «S» представляет собой объект, ключи которого — это названия styled-component-ов, а значения — сами компоненты.

     return (
       <HeaderWrapper>
         <Logo />
         {isMobile && <S.Arrow />}
         <S.Wraper isMobile={isMobile}>
           <Video src={bgVideo} />
           {!isMobile && <Header>{headerContent}</Header>}
           <S.WaveWrapper />
         </S.Wraper>
         {isMobile && <S.MobileHeader>{headerContent}</S.MobileHeader>}
         <Info link={link} />
         <PaintingInfo isMobile={isMobile} />
         {isMobile ? <CardsMobile /> : <CardsDesktop />}
         {isMobile ? <UserNavigation /> : <UserInfoModal />}
       </HeaderWrapper>
     )
    

    Здесь isMobile — это зависимость компонента, на основании которой сам компонент внутри себя решит, какой интерфейс нужно отрендерить.

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

    Давайте теперь немного абстрагируемся от «юловских» компонентов и рассмотрим подробнее такие два компонента:

    • <ComponentA />с жестким разделением десктопной и мобильной логики.
    • <ComponentB />комбинированный.

    <ComponentA /> vs <ComponentB />


    Структура папки и корневой файл index.ts:

    ./ComponentA
    - ComponentA.tsx
    - ComponentADesktop.tsx
    - ComponentAMobile.tsx
    - index.ts
    - styled.desktop.ts
    - styled.mobile.ts
    

    
    import ComponentA  from './ComponentA'
    import ComponentAMobile  from './ComponentAMobile'
    import ComponentADesktop  from './ComponentADesktop'
    
    export default {
     ComponentACombined: ComponentA,
     ComponentAMobile,
     ComponentADesktop
    }
    

    Благодаря уже не новой технологии tree-shaking webpack (или с помощью любого другого сборщика) можно отбросить неиспользуемые модули (ComponentADesktop, ComponentACombined), даже при таком реэкспортировании через корневой файл:

    import ComponentA from ‘@youla/ui’
    <ComponentA.ComponentAMobile />
    

    В финальный bundle попадет только код файла ./ComponentAMobile.

    Компонент <ComponentA /> содержит в себе асинхронные импорты при помощи React.Lazy конкретной версии компонента <ComponentAMobile /> || <ComponentADesktop /> для конкретной ситуации.

    Мы в «Юле» стараемся придерживаться паттерна единой точки входа в компонент через индексный файл. Это упрощает поиск и рефакторинг компонентов. Если содержимое компонента не реэкспортируется через корневой файл, то его можно смело редактировать, поскольку мы знаем, что он не используется вне контекста этого компонента. Ну и Typescript подстрахует в крайнем случае. У папки с компонентом есть свой «интерфейс»: экспорты на уровне модуля в корневом файле, а его подробности реализации не раскрываются. В результате при рефакторинге можно не бояться сохранения интерфейса.

    import React from 'react'
    
    const ComponentADesktopLazy = React.lazy(() => import('./ComponentADesktop'))
    const ComponentAMobileLazy = React.lazy(() => import('./ComponentAMobile'))
    
    const ComponentA = (props) => {
     const { isMobile } = props
    
    // какая то общая логика
    
     return (
       <React.Suspense fallback={props.fallback}>
         {isMobile ? (
           <ComponentAMobileLazy {...props} />
         ) : (
           <ComponentADesktopLazy {...props} />
         )}
       </React.Suspense>
     )
    }
    
    export default ComponentA
    

    Далее компонент <ComponentADesktop /> содержит в себе импортирование десктопных компонентов:

    import React from 'react'
    
    import { DesktopList, UserAuthDesktop, UserInfo } from '@youla/ui'
    
    import Banner from '../Banner'
    
    import * as S from './styled.desktop'
    
    const ComponentADesktop = (props) => {
     const { user, items } = props
    
     return (
       <S.Wrapper>
         <S.Main>
           <Banner />
           <DesktopList items={items} />
         </S.Main>
         <S.SideBar>
           <UserAuthDesktop user={user} />
           <UserInfo user={user} />
         </S.SideBar>
       </S.Wrapper>
     )
    }
    
    export default ComponentADesktop
    

    А компонент <ComponentAMobile /> содержит импортирование мобильных компонентов:

    import React from 'react'
    
    import { MobileList, MobileTabs, UserAuthMobile } from '@youla/ui'
    
    import * as S from './styled.mobile'
    
    const ComponentAMobile = (props) => {
     const { user, items, tabs } = props
    
     return (
       <S.Wrapper>
         <S.Main>
           <UserAuthMobile user={user} />
           <MobileList items={items} />
           <MobileTabs tabs={tabs} />
         </S.Main>
       </S.Wrapper>
     )
    }
    
    export default ComponentAMobile
    

    Компонент <ComponentA /> адаптивный: по флагу isMobile может сам решить, какую версию отрисовать, умеет асинхронно загружать только требуемые файлы, то есть мобильные и десктопные версии могут быть использованы раздельно.

    Давайте теперь рассмотрим компонент <ComponentB />. В нем мы не будем глубоко декомпозировать мобильную и десктопную логику, оставим все условия в рамках одной функции. Точно так же мы не будем разделять и компоненты стилей.

    Вот структура папки. Корневой файл index.ts просто реэкспортирует ./ComponentB:

    ./ComponentB
    - ComponentB.tsx
    - index.ts
    - styled.ts
    

    
    export { default } from './ComponentB'
    

    Файл ./ComponentB с самим компонентом:

    
    import React from 'react'
    
    import {
     DesktopList,
     UserAuthDesktop,
     UserInfo,
     MobileList,
     MobileTabs,
     UserAuthMobile
    } from '@youla/ui'
    
    import * as S from './styled'
    
    const ComponentB = (props) => {
     const { user, items, tabs, isMobile } = props
    
     if (isMobile) {
       return (
         <S.Wrapper isMobile={isMobile}>
           <S.Main isMobile={isMobile}>
             <UserAuthMobile user={user} />
             <MobileList items={items} />
             <MobileTabs tabs={tabs} />
           </S.Main>
         </S.Wrapper>
       )
     }
    
     return (
       <S.Wrapper>
         <S.Main>
           <Banner />
           <DesktopList items={items} />
         </S.Main>
         <S.SideBar>
           <UserAuthDesktop user={user} />
           <UserInfo user={user} />
         </S.SideBar>
       </S.Wrapper>
     )
    }
    
    export default ComponentB
    

    Давайте попробуем прикинуть достоинства и недостатки этих компонентов.



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

    Наш опыт с @youla


    Мы в своей в библиотеке компонентов @youla/ui стараемся не смешивать вместе десктопные и мобильные компоненты, потому что это внешняя зависимость для многих наших и чужих пакетов. Жизненный цикл этих компонентов максимально долгий, хочется держать их как можно более стройными и легкими.

    Нужно обратить внимание на два важных момента.

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

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

    Современные движки вроде V8 умеют кэшировать и результат парсинга, но это пока работает не очень эффективно. У Эдди Османи есть отличная статья на эту тему: https://v8.dev/blog/cost-of-javascript-2019. А ещё можно подписаться на блог V8: https://twitter.com/v8js.

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

    В пакетах приложений @youla-web/app-* разработка более «бизнес-ориентированная». И в угоду скорости/простоты/личным предпочтениям выбирается то решение, которое разработчик сам посчитает наиболее корректным в данной ситуации. Часто бывает, что при разработке маленьких MVP-фич лучше сначала написать более простой и быстрый вариант (<ComponentB />), в таком компоненте вдвое меньше строк. А, как мы знаем, чем больше кода — тем больше ошибок.

    После проверки востребованности фичи можно будет заменить компонент на более оптимизированный и производительный вариант <ComponentA />, если это потребуется.

    Также советую банально присмотреться к компоненту. Если UI мобильного и десктопного варианта сильно различаются между собой, то, возможно, их стоит разделить, сохранив некую общую логику в одном месте. Это позволит избавиться от боли при написании сложного CSS, проблем с ошибками в одном из отображений при рефакторинге или изменении другого. Ну и наоборот, если UI максимально близок, то зачем делать лишнюю работу?

    Заключение


    Подытожим. Мы разобрались в терминологии адаптивного/отзывчивого интерфейса, рассмотрели несколько способов определения мобильности и несколько вариантов организации структуры кода адаптивного компонента, выявили достоинства и недостатки каждого. Наверняка много из перечисленного было вам и так уже известно, но повторение — лучший способ закрепления. Надеюсь, что вы узнали что-нибудь новое для себя. В следующий раз мы хотим опубликовать сборник рекомендаций по написанию прогрессивных веб-приложений, с советами по организации, переиспользованию и поддержанию кода.
    Юла
    Сlassified 2.0

    Комментарии 16

      +1
      «по одной лишь информации о user-agent невозможно определить мобильность, если пользователь использует, например, десктопный браузер, но сжал окно до «мобильного»размера»

      Тут всё очень просто, на десктопе просто ограничиваем минимальную ширину в 960 пикселей и всё. Дальше горизонтальный скролл. Потому что на десктопе мобильная версия не нужна. Например я, когда читаю Википедию, постоянно сужаю максимально ширину браузера, чтобы текст не расползался по всей ширине экрана, отодвигаю горизонтальный скролл и спокойно продолжаю читать десктопную версию.

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

        У нас же есть требования поддержки «узкиx» разрешений на десктопах и наша аналитика говорит о том, что коло 8% пользователей с к экранами меньше 800px на десктопных ос. По этому нам приходится поддерживать.

        Но ключевой момент именно в наличии или отсутствии возможности отказаться от разделения ui по ширине экрана, потому что при разделении только по user-agent, это во первых проще, а во вторых можно лучше оптимизировать код.
          0
          «8% пользователей с к экранами меньше 800px на десктопных ос»

          Жесть какая, а они в каких браузерах сидят? Стандартные последние Chrome и хромоподобные?
            0
            В основном Chrome — да.
            0

            Именно с экранами или с размером окна?

              0
              К сожалению у меня есть только статистика по экрану, по окну конечно корректнее судить
                0
                Наборот интересная статистика получается. Если по окну, то может быть любители не в фулскрине на большом мониторе.
          +2
            0
            а в чем преимущества?
              +2
              не дергается 100500 раз при ресайзе, а только когда границу, заданную в query пересекает
            0
            Используете SSR? Что использовали для раутинга и как решали проблему Sode Splitting + SSR + Lazy Loading? Довелось в доке react-router видеть такую фразу:
            Godspeed those who attempt the server-rendered, code-split apps.
              0
              Частично используем, но подружить код-сплиттинг с SSR пока не пробовали, но это и правда очень не простая задача, учитывая то, что ReactDOMServer не поддерживает пока Lazy и Suspense
              0
              и пишем CSS на styled-components


              основной проблемой больших веб-приложений. Многие уже догадались: да, речь идет о длительности парсинга.


              Может попробовать выкинуть styled-components? Они ж вроде как раз в рантайме считаются.
              А вместо них перейти на css-модули, которые высчитываюся в «компайл-тайме».
              По идее приложению станет «легче дышать».
                0
                cssInJS конечно добавляет Вес бандлу, но не думаю что значительный, для нас по крайней мере это не повод отказываться, уж слишком удобный он для наших задач. Было бы кстати интересно посчитать, сколько весят например стили в нашем приложении, может прикину на досугу
                  0
                  cssInJS конечно добавляет Вес бандлу

                  так даже не в весе дело, а в парсинге. В ж сами писали о проблеме длительности парсинга.

                  Классический css обычно скачался и всё; как только у браузера есть dom и cssom — он это дело «мержит» и готов что-то показать.

                  При cssInJS, качается js-бандл,… пааааарсится… и вот тут доп. нагрузка… кроме js самого приложения, нужно ещё высчитать стили в рантайме(!)… для абсолютно каждого компонента, и только потом получить cssom.

                  Это можно сравнить с показом картинки:
                  классический css === скачать картинку и показать в теге img
                  cssInJs === скачать картинку, js-ом вставить в канвас и отрисовать.
                    0
                    А что там удобного? Возможность использовать JS-переменные при формировании темы?

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

                Самое читаемое