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

86ujwufflu-omcpppq0p9s0nnng.jpeg

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

import React from 'react'

const App = (props) => {
 const { isMobile } = props

 return (
   
     {isMobile && }
     
     
) }


Прежде чем начинать писать адаптивные компоненты, нужно научиться определять «мобильность». 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 
}

const MyComponent = (props) => {
 const { isMobile } = props

 return isMobile ?  : 
}

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) ?  : 
}


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

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


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

Первый вид — это компоненты, заточенные либо под мобилку, либо под десктоп. В таких компонентах в наименованиях часто встречаются слова Mobile/Desktop, которые явно указывают на принадлежность компонента к одному из видов. В качестве примера такого компонента можно рассмотреть из @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
}


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

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

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

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

 return (
   
     
     {isMobile && }
     
       
     {isMobile && {headerContent}}
     
     
     {isMobile ?  : }
     {isMobile ?  : }
   
 )


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

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

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

  •  — с жестким разделением десктопной и мобильной логики.
  •  — комбинированный.


vs


Структура папки и корневой файл 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’


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

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

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

import React from 'react'

const ComponentADesktopLazy = React.lazy(() => import('./ComponentADesktop'))
const ComponentAMobileLazy = React.lazy(() => import('./ComponentAMobile'))

const ComponentA = (props) => {
 const { isMobile } = props

// какая то общая логика

 return (
   
     {isMobile ? (
       
     ) : (
       
     )}
   
 )
}

export default ComponentA


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

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 (
   
     
       
       
     
     
       
       
     
   
 )
}

export default ComponentADesktop


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

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 (
   
     
       
       
       
     
   
 )
}

export default ComponentAMobile


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

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

Вот структура папки. Корневой файл 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 (
     
       
         
         
         
       
     
   )
 }

 return (
   
     
       
       
     
     
       
       
     
   
 )
}

export default ComponentB


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

jil-pxa15xccew0cgnm6zeauch8.jpeg

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

Наш опыт с @youla


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

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

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

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

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

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

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

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

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

Заключение


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

© Habrahabr.ru