В этой статье мы разберёмся, в чем сложность написания адаптивных компонентов, поговорим о 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
Большинство из вас хорошо знает, как реализовать оба варианта, но давайте коротко пробежимся по основным моментам еще раз.
При работе с шириной экрана принято устанавливать граничные точки, после которых приложение должно вести себя как мобильное или десктопное. Порядок действий такой:
- Создаем константы с граничными точками и сохраняем их в теме (если ваше CSS-решение позволяет). Сами значения могут быть такими, какие ваши дизайнеры посчитают наиболее подходящими для вашей UI-системы.
- Сохраняем текущий размер экрана в redux/mobx/context/any-источнике данных. Где угодно, лишь бы у компонентов и, желательно, у прикладной логики был доступ к этим данным.
- Подписываемся на событие изменения размера и обновляем значение ширины экрана на то, которое будет вызывать цепочку обновлений дерева компонентов.
- Создаем простые вспомогательные функции, которые с помощь ширины экрана и констант вычисляют текущее состояние (
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 максимально близок, то зачем делать лишнюю работу?
Заключение
Подытожим. Мы разобрались в терминологии адаптивного/отзывчивого интерфейса, рассмотрели несколько способов определения мобильности и несколько вариантов организации структуры кода адаптивного компонента, выявили достоинства и недостатки каждого. Наверняка много из перечисленного было вам и так уже известно, но повторение — лучший способ закрепления. Надеюсь, что вы узнали что-нибудь новое для себя. В следующий раз мы хотим опубликовать сборник рекомендаций по написанию прогрессивных веб-приложений, с советами по организации, переиспользованию и поддержанию кода.
Автор: 666granik