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

в 7:53, , рубрики: React, ReactJS, Блог компании Юла, интерфейсы

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

В этой статье мы разберёмся, в чем сложность написания адаптивных компонентов, поговорим о 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
}

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

Адаптивный или отзывчивый? Разбираем структуру React-компонентов - 2
И еще в 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

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

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

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

Наш опыт с @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 максимально близок, то зачем делать лишнюю работу?

Заключение

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

Автор: 666granik

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js