Всем привет, меня зовут Роман Пятаков! Я техлид во фронтенд-команде Lamoda. И сегодня хочу поговорить с вами о разработке сложных компонентов.

Lamoda — это технически сложный продукт, которым пользуются 10 миллионов пользователей ежемесячно, насчитывающий более 100 внутренних подсистем. Вершина этого айсберга – интерфейс онлайн-магазина, или фронтенд. Наша команда занимается разработкой и поддержкой UI десктопного и мобильного сайтов, тех частей нативных приложений для iOS, Android, которые сделаны на WebView, а также разными маркетинговыми «добавками» (это баннеры и лендинги).

В своей работе мы, конечно же, используем компоненты. Компоненты нам попадаются всякие, но есть и такие, которые по праву можно назвать сложными. Вы сразу подумали про навороченные формы или эффектные графики? Я вас удивлю, но даже обычное модальное окно может создать проблемы в разработке. Около года назад на мои плечи легла задача разработать этот с виду несложный компонент, в итоге вылившаяся в три календарных месяца работы. К счастью, из этого неуклюжего опыта я вынес три урока – три принципа, которыми сегодня с вами и поделюсь, чтобы вы не наступали на те же грабли и сделали свою работу эффективней.

Сразу оговорюсь, что мы в компании используем стек на основе Vue.js. Но если у вас React или Angular, эта статья все равно будет полезна, так как выделенные мною принципы универсальны.

image


Принцип №1: “Компонент — это песочница”


На этом слайде видно модальное мобильное окно до и после того, как поступила задача от продактов провести A/B-тест и улучшить UX. В новой версии модал выезжает снизу и не занимает весь экран. Пользователь может его смахнуть в любой момент и вернуться к тому месту, с которого начал.

image

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

К сожалению, на GitHub не оказалось нужной библиотеки или готового решения. Такие библиотеки как Hammer.js нам не подходят, потому что они умеют распознавать лишь базовые жесты. А изобретать велосипед и пилить с нуля на JavaScript не хотелось. И у меня возникла идея применить библиотеку Swiper.js, которая уже использовалась на нашем сайте для touch-галерей товаров. Swiper подходил под требования задачи, так как он умеет делать свайпы и следить за пальцем.

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

image

Swiper — небезопасная среда для children

Казалось бы, все работает и все отлично. Но тут стали возникать проблемы…
У нас есть модал с окном авторизации, где пользователь логинится на сервис. Тут располагается гугловская Captcha, которая в свою очередь использует очень избитый паттерн — position:fixed. Он необходим для того, чтобы позиционировать себя в правом нижнем углу.

image

Проблема в том, что Swiper использует CSS-трансформацию для перемещения слайдов. Это значит, что теперь он становится containing block для капчи, а position:fixed рассчитывается от его координат. В итоге наша капча подобно марионетке будет следить за движениями Swiper и “улетать” с экрана. То есть ее поведение становится абсолютно неконтролируемым, что, конечно же, нам не подходит.

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

image

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

И тогда я пришел к выводу, что использовать тут Swiper — не лучшее решение. На выходе мы получили промежуточный слой между нашим компонентом и его детьми. Это привело к созданию различных сайд-эффектов, которые влияют косвенным образом на позиционирование элементов, на возможность использования компонентов.

image

Альтернативное решение

Есть такое нативное решение как CSS Scroll Snap. Сейчас эта технология в нашем проекте на стадии тестирования, но мы ожидаем, что все будет работать абсолютно прозрачно и никак не влиять на наши дочерние компоненты.

.modal {
  scroll-snap-type: y mandatory;
}

.modalFrame,
.emptyFrame {
  scroll-snap-align: start;
}

function onScroll(event) {
  if (event.target.scrollTop === 0) {
    // modal is closed
  }
}


Всего несколько строк CSS, и все заводится. В JavaScript мы можем легко чекнуть, что модалка смахнулась с экрана обычным незатейливым хэндлером.
Вывод: исходя из этого опыта, я понял, что стоит следить за тем, чтобы компоненты не давали никаких наводок или сайд-эффектов для своих дочерних компонентов. По-другому это называется принципом ортогональности или низкой связанности в программировании. Особенно это важно для базовых UI-компонентов, таких как модальное окно. Если мы будем за этим следить, то получим компоненты, которые работают, как часы, и которые проще переиспользовать.

Но есть и обратная сторона медали. Такое “чистое” решение, как CSS Scroll Snap, не всегда лежит на поверхности. Чтобы его найти, понадобится время. Так как это решение нативное, оно может работать не везде. В таком случае придется идти на компромиссы. Например, пожертвовать такой немаловажной функцией модального окна, как свайп.

Принцип №2: “Fancy-фича может подождать”


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

image

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

  • Следить за порядком модалов.
  • Узнавать высоту контента.
  • Создавать стор для данных.
  • Вычислять видимость.

А в ряде случаев индикатор и вовсе может не работать. Например, в окне с галереей товаров, о которой я рассказывал чуть выше. Дело в том, что индикатор реализован как псевдоэлемент ::before. Фотография же в галерее использует свойство overflow: hidden, чтобы обрезать свой верхний край. В результате, псевдоэлемент тоже обрезается. То есть физически наш индикатор остается в документе, но на экране его уже нет. В итоге мы потеряли нашу фичу, а релиз пришлось отложить.

Да, фича полезная и прикольная, но не критичная. Без нее вполне можно было обойтись. Ладно бы реализовать ее было легко, так еще нужно приложить немалые усилия, усложнить кодовую базу. И в дальнейшем я решил на эти грабли не наступать.

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

image

И возникла проблема: как же отследить это нажатие в браузере? Нативные приложения позволяют легко с этим работать, однако в браузере вы никак это не отследите, кроме костылей.
Дабы не повторять прошлых ошибок, я сразу же пошел к дизайнеру, и мы пришли к компромиссному решению: вынести эту кнопку явным образом в интерфейс. Что ж, получилось не очень красиво, зато эффективно.
Вывод: я понял, что важно вовремя обнаружить, что фича в компоненте — это fancy-фича. Да, она прикольная, но ее реализация несет в себе большие челленджи. В таком случае лучше найти компромиссное решение: упростить фичу или перенести в следующую итерацию. Соблюдение этого принципа позволит ускорить релизы, упростить реализацию компонента и, как следствие, будет проще поддерживать и добавлять новые фичи в дальнейшем.

Если уж совсем “кровь из носа” и фичу нужно делать здесь и сейчас, то стоит прибегнуть к подходу Git Successful Flow. В таком случае можем пошарить ветку develop с кодом компонента без fancy-фичи. Это позволит хотя бы не задерживать работу команды.

Принцип №3: “Не объединяй и властвуй”


Как-то раз прибежал наш тимлид и сказал: “Ребята, Рома запилил такое, что модалка теперь может все!” И я действительно неплохо так ее прокачал:

  • Реализовано поддержание старого+нового дизайна A/B-теста.
  • Добавились крутые фичи: индикатор перекрытия, свайп, разные анимашки.
  • Появилась возможность кастомизации — поддержка разных тем для различных use-кейсов.
  • Стала возможна поддержка прелоудера и виртуальных списков.

В итоге, наша накачанная модалка занимает 700 строк кода, у нее 15 props, 5 slots.

По-моему, получилось too much. И ситуация может стать еще хуже. Дело в том, что у нас помимо мобильной модалки есть еще и десктопная. А в наших планах было получить респонсивную модалку, XModal, которая была бы то мобильной, то десктопной, в зависимости от того, какой сейчас экран. Если решить эту задачу, то мы получим компонент-монстр. А именно:

  • Добавится еще + 50% к объему кода.
  • Хорошенько обмажемся if’ами, так как добавится куча разных условий.
  • Будем перебивать мобильные стили десктопными, что приведет к полному хаосу в CSS.
  • Будут конфликты в кодовой базе и, как следствие, могут возникнуть конфликты между командами mobile и desktop.

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

image

А вот как это выглядит в коде:

import MModal from ‘components/m-modal/m-modal.vue’;
import DModal from ‘components/d-modal/d-modal.vue’;
import deviceProps from ‘utils/device-props’;

const XModal = {
  functional: true,
  render(createElement, context) {
    return context.parent.$createElement(
      deviceProps.screenSize === ‘phone’ ? MModal : DModal,
      context.data,
      context.children,
    );
  },
};


Сначала в команде была доля скепсиса из-за того, что будет копипаста. Но потом народ оценил и понял, что когда у нас есть серьезные отличия между версией А и версией B, лучше и правда — не объединять. И такой подход нам может пригодиться для A/B-тестирования или в случае с несколькими окружениями (сайт и мобильное приложение).

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


image

Таким образом, у нас есть proxy-компонент и selectorFunction, которая инкапсулирует тот критерий, по которому мы выбираем одну из реализаций компонента. Если кто еще не догадался, то это структурный паттерн проектирования Proxy.

Вывод: в случае с разными реализациями компонента, лучше их не объединять, а оставлять жить раздельно. Паттерн Proxy выступает в роли прозрачного клея, который склеивает разные реализации компонента, и оставляет клиента в неведении, как там все работает. Логика компонента значительно упрощается и не возникнет путаницы в стилях, а команды смогут работать независимо.

Обратная сторона медали в том, что копипасты здесь не избежать. В каких-то случаях ее будет настолько много, что придется отказаться от использования этого подхода.
Также могут возникнуть проблемы с инструментарием. Например, ваша любимая IDE не сможет распознать API из-за ширмы в виде proxy, и не поможет вам с автокомплитом.

Выводы


Таким образом, не стоит недооценивать разработку компонентов. Даже примитивный модал может создать серьёзные проблемы в том, как это сделать и как это реализовать. Описанные в статье принципы позволят:

  • Следить за тем, чтобы не возникали промежуточные слои, которые бы создавали наводки, сайд-эффекты для “детей”.
  • Распозновать вовермя fancy-фичи, которые не критичны, но достаточно сложны в реализации.
  • Если есть несколько реализаций компонента, не валить все в кучу, а использовать proxy-паттерн для того, чтобы склеить куски в один общий компонент.

Соблюдение этих принципов помогло мне достичь главной цели — деливерить быстро, поддерживать легко.