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


Всем привет! Меня зовут Юрий Голубев, я разрабатываю frontend в Почте Mail.ru. Сегодня я хочу поделиться опытом того, как мы добавили адаптивности и возможности кастомизации в интерфейс, а заодно — открыли новый для себя способ написания адаптивных компонентов.


Почта прошла долгий путь от списка текстовых писем до сложной системы с динамическим контентом, поиском, файлами, папками и так далее. С ростом функционала выросла сложность интерфейсов и когнитивная нагрузка. Теперь пользователь может создать дерево вложенных папок, а на экране чтения может быть свёрстанное HTML-письмо с вложениями. А ещё некоторым пользователям удобнее работать со списком папок, списком писем и просмотром письма на одном экране.


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


Подобные запросы явно не получится решить чекбоксом или двумя: тут слишком много вариантов. Хочется чего-то более точного, и желательно — прямо в интерфейсе, чтобы пользователь мог изменять почту под свои потребности "на лету".


Таким образом, мы пришли к задаче — дать пользователю возможность персонализировать интерфейс с помощью изменения размера колонок (они же области с контентом). Персонализировать — с точностью до пикселей.


Ну и что тут сложного?


Изменить размер колонки — не основная проблема. Что важно: чтобы контент адаптировался не под размер экрана пользователя, а под размер колонки.


Почта Mail.ru — технически сложный проект. И верстка в нем непростая. На уровне layout'a мы поддерживаем компактный вид, режим отображения списка и чтения письма на одном экране, а ещё есть адаптация под b2b-пользователей.


При этом layout должен адаптироваться на экранах от 768px до бесконечности. В зависимости от разных параметров изменение ширины viewport'а по-разному перестраивает layout приложения. А теперь мы пришли к тому, что хотим настраивать ширину колонок.


Немножко цифр:


  • код UI-части основных страниц Почты состоит из более чем 50 тысяч строк кода (и это не считая написания письма и некоторых других страниц, которые разрабатываются отдельно);
  • из них 13 тысяч — это HTML-like шаблоны, 12 тысяч — стили.

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


Планы...


Уже на этапе проектирования задачи стало ясно, что придется затронуть довольно большое количество кода. Настолько большое, что вместить это всё в один релиз мы бы просто не рискнули. Было решено делать и релизить задачу поэтапно:


  1. Сначала реализовать техническую возможность делать колонки произвольного размера.
  2. Адаптировать контент колонок (списка папок и списка писем) к произвольной ширине.
  3. Научиться ограничивать, насколько пользователь может увеличить/уменьшить колонку.
  4. Начать включать данную возможность пользователям.

… и суровая реальность


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


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


Но со временем стало понятно, что пики становятся выше и не собираются исчезать. Количество попаданий в метрику было небольшим, так что мы знали, что проблема не массовая.


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


Изначально колонки в нашем интерфейсе были спозиционированы через position: absolute и отступы (которые регулировались в JS по подписке на matchMedia). Прежде чем вы начнете закидывать меня камнями, уточню: когда это делалось, мы ещё рассчитывали на поддержку Internet Explorer 9. Так что наши опции были, скажем мягко, ограничены.


Однако с тех пор прошло много времени, поддержка IE9 (и даже IE10) канула в лету, и у нас поддерживаются только новые браузеры, пользователи старых же отправляются в "легкую" версию почты. Также мы успели обкатать Grid Layout (в том числе в IE11) в паре более мелких проектов. Он выглядел замечательным инструментом для текущей задачи, позволяя задавать и удобно изменять ширину колонок в одном месте.


Немного технических подробностей

Уход от position: absolute также имеет дополнительные преимущества — элементы (колонки) теперь располагаются на одном слое. Это, как правило, ускоряет отрисовку, а также улучшает плавность анимаций. Это важно для подобной задачи, в которой размер элементов должен меняться в ответ на действия пользователя.


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


Mozilla/5.0 (Windows NT 6.0; Win64; x64; rv:52.9) Gecko/20100101 Goanna/3.3 Firefox/52.9 PaleMoon/27.5.1
Mozilla/5.0 (Macintosh; Intel Mac OS X 10.6; rv:52.9) Gecko/20100101 Goanna/3.4 Firefox/52.9 ArcticFox/27.10.1
Mozilla/5.0 (Windows NT 6.1; rv:52.9) Gecko/20100101 Goanna/3.4 Firefox/52.9 K-Meleon/76.2

Казалось бы, всё тривиально, мы каким-то чудом проглядели слишком старую версию Firefox. Но нет, поддержка Grid Layout в FF52 определенно есть. Однако поиск по другим подстрокам в юзер-агенте дал результат: оказывается, в довольно старые времена, некие энтузиасты сделали форк движка Firefox, который получил название Goanna. На нем на данный момент существует несколько браузеров. Ну а 3-я версия движка, Goanna/3.x, притворяется FF52, хотя не поддерживает часть функций которые есть в FF52, в том числе — вы угадали — Grid Layout.


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


Теперь вместо Grid Layout используется flex + css-variables (для того чтобы удобно было изменять ширину колонок из кода).


Адаптация контента к ширине


Что ж, перейдём к второму этапу. Любой веб-разработчик в какой-то мере знает, как адаптировать интерфейс под размер экрана. Для этого придумано много инструментов, как напрямую в браузерах, так и сделанных разработчиками в виде библиотек.


Минутка про то, как работает обычная адаптация к размеру экрана

Чтобы освежить память, давайте пройдемся по самым типовым вариантам реализации.


«Резиновый» интерфейс:



Меняется размер экрана — меняется размер элементов. Пропорции и структура остаются неизменными.


Как реализуем: определяем ширину элементов в процентах от контейнера/экрана.


«Адаптивный» интерфейс:



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


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


Как реализуем: на данный момент есть несколько вариантов, основные перечислены далее.


  • Самый простой и современный — flexbox. Устанавливаем браузеру правила для элементов, дальше он разбирается сам.
  • Более старый и мануальный — media queries. Для каждой ширины/высоты экрана сами задаем стили элементов, браузер слушается.

Что же тогда тут сложного, если уже всё придумано?


Разговор выше идет о адаптации под размер окна браузера. А в данном случае нам надо адаптироваться под размер колонки интерфейса.


А есть ли разница?


Да… И нет. Чуть выше (под спойлером) можно найти описание того, что используется для адаптации интерфейса под размер экрана. А что касательно размера одной части интерфейса? Например, в нашем интерфейсе есть компонент шапки, который состоит из фона, логотипа, поиска и тулбара, который занимает всё доступное место. При изменении ширины экрана набор кнопок внутри тулбара может изменяться. Кнопка с иконкой может сворачиваться до иконки или скрываться в трех точках.


Что мы можем использовать тут из того, что уже упоминалось?


  • ✅ Резиновая (процентная) ширина — она не привязана к размеру окна
  • ✅ Flexbox — для него важен лишь контейнер и опять же не важен размер окна
  • ❌ Media queries. Они привязаны к окну, и мы не сможем их тут применять.

И что, скажете вы, всё? Но как же нам поступить с кнопками? Тут явно не хватит простой ширины в процентах или flexbox-a. Мы ограничены таким небольшим списком вещей, и никак не можем сказать браузеру конкретнее, что мы хотим? Что ж, мы и правда не можем использовать для этого media queries (хотя ситуация может вскоре измениться). Но API, позволяющее достичь похожего поведения, существует (хоть и придется потрудиться руками).


ResizeObserver — API, которое позволяет следить за изменением размера конкретного элемента и реагировать на него. Как раз то, что нам нужно!


Однако работать с подобным API напрямую не очень удобно. Более того, оно делает куда больше, чем нам действительно нужно (об этом чуть дальше).


Что же нам нужно?


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


Что мы делаем?


Конечно же пишем обертку! И позволяем себе некоторые упрощения в процессе. У нас есть некоторые дополнительные условия:


  • нам не нужно работать со множеством элементов (лишь с одним контейнером);
  • из предыдущего пункта следует, что нам не нужно всё, что возвращает ResizeObserver — только размеры контейнера;
  • если мы будем работать с компонентом, то нам нужен способ перевести размер в некоторое состояние (aka props/state в React). Т.к. в нашем проекте используется внутренний UI-фреймворк, то детали взаимодействия с компонентом оставим за кадром. Однако они довольно тривиальны.

В итоге, после некоторых экспериментов, я пришел к вот такому интерфейсу:


this.adaptiveHandler = new GetAdaptive({
    breakpoints: [
        // Короткая кнопка без текста
        {
            conditions(entry) {
                return entry.width < MIN_TEXT_BUTTON_WIDTH;
            },
            props: {
                short: true,
            },
        },
        // Полноразмерная кнопка
        {
            props: {
                short: false,
            },
        },
    ],
    // Если true - коллбэк onResize не будет вызван, если ширина изменилась в пределах брейкпойнта
    pureBreakpoints: true,
    // Коллбэк на изменение размера (брейкпойнта)
    onResize: this.onResize,
    // DOM-элемент
    element: this.el,
});

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


  • мы передаем элемент в утилиту;
  • утилита подписывается на изменение размера элемента;
  • когда размер элемента изменился, мы:
    • ищем брейкпойнт, подходящий к данному размеру;
    • когда нашли, передаем состояние, которое в нём записано, в компонент.

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



Немного про то, как не надо делать

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


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


Но, вы скажете, это же очевидно! Да, возможно. Что ж, это не единственный способ сесть в лужу. :) Даже если вы не измените размер руками, достаточно сделать что-то, что приведет к изменению размера — и это выльется в тот же самый результат.


Здесь стоит упомянуть важный момент. Размер, за которым следит ResizeObserver, и который возвращается вам в виде contentRect — это, как следует из названия, размер, доступный для контента. То есть за вычетом padding и border. Так что вторая вещь, которую делать не стоит — это менять padding или border у элемента за которым вы наблюдаете.


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




Ограничение свободы (кастомизации)


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


В нашем случае это свелось к довольно тривиальной системе (которую, однако, было труднее продумать, чем ожидалось):


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

Когда мы совмещаем правильную ширину колонок и компоненты, которые могут к ней адаптироваться, паззл складывается: мы получаем адаптивный интерфейс, в котором пользователь может изменять размеры элементов, и всё работает!



И что, всё?


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


Советовал бы ли я такой подход для нового стартующего проекта? Та ли это "убер-фича", которую вы можете прикрутить у себя завтра? Пожалуй, нет. Всё же пока это требует довольно больших усилий для реализации. Без готовых более или менее универсальных инструментов это может быть сложной задачей.


Однако замечу, что адаптивные компоненты вы можете начать создавать уже сегодня.


Использование CSS Containment может помочь нивелировать возможные минусы в перформансе от реализации адаптивности в JS, а через какое-то время, возможно, мы придем и к полноценным Container Queries, на которые будет легко мигрировать с текущего подхода.


Возможно, стоит вспомнить обо всём этом, когда вы будете писать ваш следующий универсальный компонент? :)


И, на прощание, ещё немного цифр!


В процессе реализации задачи мы:


  • Так или иначе затронули 15 компонентов — кнопки, тулбар, список папок, чтение письма, и ещё несколько, которые попросили меня об анонимности. ????
  • Написали и изменили более 4000 строк typescript-кода.
  • Покрыли ещё 1% кода (всего проекта) юнит-тестами.

Комментарии (4)


  1. catz_a
    24.09.2021 18:41
    +2

    прикольно продумано!
    А я не знал, что на майл.ру можно туда суда-двигать прямо в браузере, довольно модно получилось.
    Сейчас верстка - как минное поле, слишком всё друг на друге завязано, то очень вас понимаю - внедрять что-то новое в такой крупный проект - ещё та задачка!


  1. qbaddev
    24.09.2021 18:41

    Божественное имя у поста.


  1. mSnus
    25.09.2021 04:10

    Честно, не понимаю, зачем надо было городить обсерверы, если у вас всего пара "ручек", за которые можно тянуть.

    Брейкпоинты и фиксированные раскладки под них -- да, логично. А ваш подход выглядит каким-то переусложненным. Не сделайте, пожалуйста, из Мэйла подобие Фейсбука по внутреннему устройству.


    1. Ansile Автор
      25.09.2021 10:04
      +2

      Я как раз и старался в статье описать, почему применили именно такой подход :)

      По сути, это и есть "брейкпойнты и фиксированные раскладки". Но брейкпойнты в рамках компонента, а не в рамках приложения. Подобные технологии часто применяют в других платформах, где компонент - что-то более фундаментальное, чем в вебе (тут слишком принято через css дотягиваться "куда угодно" в архитектуре). В качестве яркого примера - flutter, где виджет явно адаптируется под "выделенное" ему место.

      Если мы попробуем сделать это на каком-то другом уровне (например, когда колонка размера X, применяем стиль Y к кнопке "написать письмо"), то получим плохо поддерживаемые селекторы в css на родительский класс + компонент, и кнопку, которую очень дорого будет просто передвинуть на другое место в интерфейсе. Да что там, даже для того чтобы добавить ей отступы - может понадобиться менять брейкпойнты.

      Плюс, когда Container Queries таки выпустят - переход на них с такого подхода будет организовать очень даже просто. Они реализуют такую же возможность. Правда, когда писался этот код (статью выпускали не сразу), они были только в состоянии intent-to-prototype. А вот сейчас, даже когда реализации ещё нет ни в одном браузере, уже постепенно появляются полифиллы.