Новая версия портального меню понадобилась в результате крупного обновления портальных гайдлайнов, которое произошло в прошлом году. Подробно о том, что и зачем поменялось, можно прочитать на Behance. В рамках обновления нужно было обеспечить стилистическое единство множества разных проектов портала Mail.Ru, а также единый стиль работы на них навигации, визуально и функционально. На каком бы проекте ни находился пользователь, ему должно быть понятно, как по нему перемещаться. Поэтому меню и технически должно быть единым, кросспроектным.
Итогом нашей работы стало решение, которое фактически представляет собой конструктор с большим набором элементов, в том числе и динамических. Такой подход дал возможность в режиме реального времени «собирать» из этих элементов портальное меню, соответствующее требованиям того или иного проекта, и позволил вывести на первый план именно те возможности, которые больше всего нужны пользователям. Формат элементов также адаптируется в зависимости от разрешения экрана, на котором пользователь открыл страницу.
А теперь подробнее о том, как мы это сделали.
Лебедь, рак и щука
Нужды у каждого проекта свои. Одному надо пять кнопок, другому – две, да с дропдаунами, третьему – чтоб две кнопки слева, а одна справа. Даже если какой-то из элементов портального меню встречается на нескольких проектах, он может везде играть разные роли. Возьмем, например, поисковую строку. В Поиске Mail.Ru она, естественно, будет занимать центральное место. В Почте — отойдет на второй план: людям часто нужно бывает найти в ящике файл или письмо, но все-таки это не главная функция. А, скажем, на Новостях Mail.Ru поиском пользуются редко, так что логично сделать этот элемент компактным. Да и технически проекты отличаются друг от друга кардинально как на сервере (разные платформы, языки и шаблонизаторы), так и на клиенте (разные библиотеки, доктайпы, стили и технологии). Также надо не забыть и о динамическом перестроении на клиенте в SPA.
Один из самых крупных подводных камней, на которые можно натолкнуться при работе с кросспортальными элементами — трудоемкость обновления. Если заранее не позаботиться о том, чтобы обновлять меню было легко, то последующая поддержка превратится в мучение для команды проекта. Можно сделать портальное меню статичным: проект получает от разработчика портального решения готовый код со всеми контролами, обновления также приходят в виде полностью сформированного кода. В этом случае, если команда проекта вносит изменения в меню, совместимость со следующими его версиями теряется. Это еще больше усложняет внесение общих изменений на портале. Кроме того, в новой версии портального меню планировалось куда больше элементов, чем в прошлой, в том числе и динамических с разнообразными состояниями. Ввиду большого разнообразия технологий шаблонизации на проектах со стороны сервера и очевидной технической сложности предполагаемого нового решения мы выбрали построение портального меню на клиенте, что позволило реализовать существенно более сложную логику отображения элементов и ввести такие возможности, как скрываемые или уменьшаемые и тянущиеся элементы.
Чтобы избежать вышеописанных проблем и обеспечить легкость обновления, необходимо выполнить два условия. Во-первых, необходимо разработать компонент, максимально обособленный от окружения, который легко интегрировать в проект и легко обновлять, когда надо будет, например, поменять цвет меню по всему порталу с минимальными усилиями со стороны проекта. Во-вторых, нужно максимально упростить проектный код, спрятав внутреннюю кухню. Проект сказал «хочу кнопку» – на тебе кнопку, «хочу справа» – вот тебе справа. Идеально подойдет что-то декларативное.
Подключение
<script src="hat.js"></script>
<script>
hat.draw({});
</script>
Идеальный случай работы с интегрируемым решением я вижу таким: подключил библиотеку, инициализировал необходимые компоненты. Это позволяет легко обновлять версию, внося минимум правок в код проекта или вовсе его не трогая. Но так как портальное меню – это значимый элемент, находящийся в самом верху страницы, ее нельзя подключать и рисовать абы как и абы когда. Нужно, чтобы при начале отображения страницы блок занимал свое место и далее не влиял на положение иных блоков. Значит, портальное меню должно быть разделено как минимум на три части: стили, скрипт и базовый HTML. HTML должен вставляться в нужное место на странице, рисующееся при загрузке; далее скрипт будет интегрировать туда различные элементы.
<link href="hat.css"/>
<div class="hat">...</div>
<script src="hat.js"></script>
<script>
hat.draw({});
</script>
Функциональности в новом меню планируется очень много, а значит и вес у него будет большой. Нельзя весь код грузить синхронно – слишком сильно скажется на времени загрузки проекта. В идеале, разделить код на две части: код, необходимый для отрисовки плейсхолдера (корневого элемента меню), и остальной код, который будет грузиться асинхронно.
<style>/* base styles */</style>
<div class="hat">...</div>
<script>
loadExternalScript();
</script>
<script>
hat.init();
</script>
В таком случае получается, что использовать портальное меню можно только когда загрузятся все внешние данные. Но совершенно не хочется учить проект определять, когда же загрузится все необходимое. Надо искать иной путь. Дабы обособить проект от этого знания, был написан API прокси, размещаемый в инлайновой части.
hat.draw = new DeferredQuery.getQuery();
hat.draw({}); // put in queue
hat.draw({}); // put in queue
hat.draw.replace(realDrawFn); // run two times
hat.draw({}); // run immediately
Прокси в данном случае – это функция, которая запоминает все ее вызовы с аргументами и в момент ее готовности к вызову подменяется на реальную функцию, запускаемую необходимое количество раз с имеющимися накопленными аргументами. В заглушке лежат все необходимые для работы с портальным меню функции, например, отрисовка/перерисовка.
Чтобы загрузить внешние ресурсы одним запросом, стили включены в скрипт и подключаются динамически. При сборке кода портального меню собранный код стилей помещается строкой в скрипт и вставляется в дом до подмены API.
var styles = ".body{background:url(\"http://...\")}",
style = document.createElement('style');
style.appendChild(document.createTextNode(styles));
body.appendChild(style);
init();
Надо отметить, что некоторые самые популярные браузеры при неведомых обстоятельствах не сразу применяют стили. То есть скрипт, идущий за подключением стилей, работает со старыми данными. Недолго думая, мы добавили в инлайн HTML пустой элемент, к которому во внешних стилях применяется набор правил. По его появлению определяется момент применения стилей и начинается инициализация. Применение стилей проверяем в лоб – по таймауту.
appendStyles();
whenStylesLoaded(init);
Сборка портального меню
У нас предусмотрено три варианта сборки (на все случаи жизни):
- Асинхронная – сборка для проектов, для которых не имеет значения, когда и как грузится портальное меню, главное – что оно работает.
- Полуасинхронная – для проектов, в которых критичен момент подключения стилей.
- Синхронная – для гуру веба.
Асинхронная сборка
<style>/* base styles */</style>
<div class="hat">...</div>
<script>
createAPI();
loadExternalScript();
</script>
<script>hat.draw({});</script>
Таким образом, получилась следующая сборка: втыкаем в нужное место инлайновую часть, сразу же запускаем инициализацию портального меню, а дальше оно само разберется, когда и как нарисоваться. При работе с этой сборкой подключение и обновление на новую версию сводится к копированию в нужное место в коде небольшой инлайновой части.
Полуасинхронная сборка
Для проектов, которым критичен момент подключения стилей, понадобилось сделать сборку со стилями в отдельном файле и аналогичным внешним скриптом, но без стилей, соответственно. Пример такого проекта – Почта. Потребность в полуасинхронной сборке есть из-за наличия разных тем оформления на проекте. В Почте подключается два CSS-файла – один с геометрией и цветами дефолтной темы и второй с цветами выбранной темы. Мы уже рассказывали про это подробнее в отдельной статье. В обычной сборке портальных меню стили подключаются в неопределенный момент в неконтролируемое место: при определенных изменениях в меню или на стороне проекта может легко получиться так, что стили меню подключатся после стилей темы (см. ниже по коду), а значит, будут более весомыми. Полуасинхронная сборка дает контроль над стилями, а остальное делается «само».
<link href="full.css">
<div class="hat">...</div>
<script>
createAPI();
loadExternalScriptWithoutCSS();
</script>
<script>hat.draw({});</script>
Синхронная сборка
Для проектов, которым критичен контроль за процессом загрузки, сделана отдельная сборка, включающая в себя кусок инлайнового HTML и внешние файлы со стилями и скриптом, без загрузчика. Проект может включить файлы в свои сборки и грузить, когда это необходимо.
<link href="full.css">
<script src="full.js"></script>
<div class="hat">...</div>
<script>hat.draw({});</script>
Инициализация
Подключив портальное меню, нужно вызвать функцию отрисовки, передав конфиг необходимых элементов и функцию обратного вызова, в которую будет передан набор API созданных элементов.
hat.draw({
logo: {},
toolbar: {},
submenu: {}
}, function(menuItems){
menuItems.toolbar // главное меню
menuItems.submenu // второй уровень меню
});
В портальных меню есть два уровня меню: основное (в конфиге toolbar) и серое подменю (в конфиге подменю).
О том, что нужно нарисовать тулбар, основной код узнает уже после рендеринга плейсхолдера и увеличивает его высоту, добавив дополнительную строку. Напомню, плейсхолдер нужен для того, чтобы резервировать необходимое портальному меню место. Значит, нужно при первом рендеринге знать о планах насчет подменю и сразу отрисовывать высокое меню.
hatConfig = {
submenu: true
}
Для этого инлайновый код меню научился понимать по объявленной выше опции в конфиге, нужно ли сразу отобразить плейсхолдер для второго уровня.
Дальше я подробнее расскажу о контенте меню.
Элементы
Доступных для использования элементов в портальном меню много. Это строка поиска, кнопки, разделители, тянущиеся разделители и так далее. У большинства элементов есть много опций по настройке внешнего вида. Например, у нас порядка N видов строки поиска. Разнообразие в пределах гайдлайнов позволяет проектам гибко настраивать поведение и вид элементов меню под себя. Все это настраивается конфигом с большим количеством опций. Подробно на них останавливаться не буду. Особо интересующиеся могут погулять по проектам Mail.Ru и оценить разницу между меню.
Пак иконок
Вместе с новым портальным меню мы разработали и внедрили унифицированные портальные иконки. Единообразие иконок помогает обеспечить единый user experience на всех проектах портала. Например, иконка нового уведомления выглядит одинаково и на Играх, и в Моем Мире.
В портальных меню для кнопок задается класс иконки. Если он задан, создается элемент иконки с этим классом. Можно задать свой класс иконки с соответствующими стилями или воспользоваться предустановленным набором. Предустановленный набор автоматически собирается в спрайт из набора иконок в определенной папке. Картинки разбираются по именам и типам. В данный момент есть «ретиновая» и «неретиновая» png с именами name.png и name@2x.png, а также две инвертированные иконки, отображающиеся при открытии дропдауна. Ретиновыми мы называем иконки удвоенного размера, они показываются на экранах с плотностью более чем 2. Если есть оба файла, то иконка добавляется в набор. Из набора с помощью https://github.com/aheckmann/gm собирается два спрайта и генерируются стили для последующего включения в сборку.
.icon {
background: url(icons.png) no-repeat -3427px 0;
background-size: 3458px 21px;
}
@media only screen and (min-device-pixel-ratio: 1.5){
.icon {
background-image: url(icons@2x.png);
}}
Управление элементами портального меню происходит через API.
API элементов
При вызове функции отрисовки в функцию обратного вызова передается набор API созданных элементов. Это набор также можно получить в любой момент вызовом функции hat.getItems(function(items){}).
hat.draw({}, function(menuItems){
menuItems.toolbar // главное меню
menuItems.submenu // второй уровень меню
});
hat.getItems(function(menuItems){});
При помощи API можно получить доступ к DOM-элементам, повесить обработчики на разные события, скрыть/показать тот или иной элемент.
Для элементов определенных типов есть дополнительные возможности. Так, у кнопки можно изменять состояние «текущий раздел» (при этом нельзя сделать две «текущие» кнопки), изменять текст и иконку, обновлять число в нотификации на кнопке. У дропдауна есть функция пересчета и перерисовки, необходимая при обновлении контента HTML-дропдаунов. У поиска — функции по работе с саджестами и контекстным селектором.
Динамические элементы
Схлопывающиеся группы
Каждый из схлопывающихся элементов такой группы может переноситься в выпадающее меню «Еще», если для него не хватает места.
Адаптивные элементы
Например, это кнопка с иконкой, которая может расхлопываться из иконки в кнопку с текстом, когда на это хватает места.
Тянущиеся элементы
На текущий момент тянущиеся элементы – это spacer без указания ширины и поиск с параметром flexible: true и поиск.
По умолчанию свободное пространство, оставшееся после скрытия не умещающихся кнопок и расчета адаптивных элементов, делится поровну между всеми тянущимися элементами, то есть flex = 1. Для любого тянущегося элемента можно установить параметр flex, отличный от 1, — тогда свободное пространство будет делиться в соответствии с этим значением. Например, если есть два тянущихся элемента с flex = 1 и один элемент с flex = 2, при образовании свободного пространства в 100 пикселей последнему элементу будет выделено 50 пикселей, а остальным — по 25.
<дополнительное пространство для элемента> = <свободное пространство> / <? всех flex > * <flex элемента>
Еще одно необходимое для тянущихся элементов понятие – базовая ширина. Это ширина, относительно которой ведется расчет свободного пространства. Берется из width в конфиге (у тянущегося спейсера = 0).
Для определения свободного пространства все элементы рендерятся с шириной, равной базовой. Оставшееся место делится между тянущимися элементами в соответствии с flex-параметрами и прибавляется к базовой ширине.
<ширина тянущегося элемента> =
<дополнительное пространство для элемента> + <базовая ширина>
Рендеринг
hat.draw({});
В общем случае рендеринг портального меню происходит примерно следующим образом. После инлайновой части (т.е. появления на странице API) проект запрашивает рендеринг нужных ему элементов. Грузится внешний скрипт, в котором подключаются стили, создаются «классы» всех имеющихся элементов и запускается запрошенный рендеринг.
Сначала обрабатывается конфиг, toolbar.items приводится к набору групп. Если было
[
{type: ‘button’},
{type: ‘button’},
{type: ‘group’, items:
[{type: ‘button’},{type: ‘button’}]},
{type: ‘button’}
]
то на выходе получится три группы: с первыми двумя кнопками, исходная группа и группа с одной кнопкой.
[
{type: ‘group’, items: ... }
{type: ‘group’, items: ... }
{type: ‘group’, items: ... }
]
Далее нормализуется конфиг каждого элемента. Каждый элемент в отдельности рассматривать не будем. Тут происходит установка дефолтных значений конфига и изменение настроек, не имеющих смысла в сочетании с другими опциями. Например, отключение схлопывания кнопки до иконки, если кнопка текстовая.
item = classes[item.type].getConfig(item);
Конфиги передаются в шаблонизатор (Fest), генерирующий необходимую разметку. Элементы вставляются в нужное место и происходит инициализация JS-компонентов. Каждый элемент внутри себя инициализирует нужные связи, вешает обработчики событий и т.д.
html = fest(config);
block.innerHTML = html;
$(block).find('.elements').bem()
Интереснее было с динамическими элементами: схлопывающимися группами, адаптивными кнопками и тянущимися элементами.
Разбор элементов по группам
getEls();
getCollapsibleGroups();
getMoreButtons();
getAdaptive();
getFlexible();
Для дальнейших расчетов нам понадобится разобрать все элементы по коллекциям – элементы схлопывающихся групп, кнопки «Еще» из схлопывающихся групп, адаптивные элементы, тянущиеся элементы и суммарный коэффициент flex. Это же происходит при изменении в наборе элементов.
Состояние по умолчанию
elements.show();
adaptive.collapse();
flexible.width(baseWidth);
els.each(funcion(el){
el._width = el.width();
});
fullWidth = sum(el._width);
После инициализации отображаются все потенциально видимые элементы. Для тянущихся элементов устанавливается ширина, равная базовой, адаптивные схлопываются, кешируется ширина всех элементов, ширина всех сворачивающихся элементов для групп и полная ширина тулбара.
Аналогичные действия происходят при изменении в наборе (конфиге) элементов, скрытии/показе, то есть изменении роли элементов в расчетах или изменении набора.
Портальное меню разделено на две ячейки с display: table-cell. В левой стоит логотип, а правая — с тулбаром — занимает все оставшееся место. Тублар лежит внутри правой ячейки и спозиционирован абсолютно, дабы не влиять на ширину всего меню.
Таким образом, после приведения элементов к состоянию по умолчанию (видимому), поскольку мы знаем ширину ячейки и тулбара, мы также знаем, сколько пикселей не влезло или, наоборот, сколько есть свободного пространства.
Расчет видимых элементов
Если полная ширина тулбара меньше имеющейся, просто показываем все элементы и скрываем кнопки «Еще».
if (fullWidth <= currentWidth)
moreButtonsHide();
collapsibleEls.show();
}
Иначе показываем кнопки «Еще» и пробегаемся по всем группам с целью скрыть в ней нужное количество элементов.
if (fullWidth > currentWidth)
moreButtonsShow();
groups.each(function(group){
...
})
}
Расчет группы
Если пытаться в лоб скрывать элементы справа налево при наличии нескольких групп, может получиться так, что из одной группы — той, что правее — мы скроем вообще все. Это нехорошо. Ищем иной вариант. Можно скрывать по равному количеству элементов из каждой группы. Тоже нехорошо: скорее всего, получится, что мы скроем больше элементов, чем надо. Значит, надо распределять скрываемое место пропорционально общей видимой ширине группы. Чем больше в группе элементов, тем больше надо в ней скрыть.
Скорее всего, получится так, что группу придется просить скрыть меньше пикселей, чем можно. Ведь нельзя скрыть элемент частично, а значит мы скроем больше. Это показано на картинке: правая линия – сколько просили, левая – сколько в итоге получится.
При таком раскладе выходит, что элементы прыгают при ресайзе. То показываются в одной группе и скрываются в другой, то наоборот. Для решения этой проблемы надо учитывать разницу между запрошенными и реально скрытыми пикселями и корректировать на эту разницу пиксели следующей группы.
var groupToHide = Math.round(pixelsToHide *
group.collapsibleWidth /collapsibleFullWidth);
var hiddenFromGroup = group.expand(groupToHide + notHidden);
notHidden = groupToHide - hiddenFromGroup;
Адаптивные элементы
Когда с группами покончили, можно распределить оставшиеся пиксели.
Сначала надо проверить, можно ли расхлопнуть адаптивные кнопки.
Логика простая. На этапе инициализации кешируются размеры расхлопнутого и схлопнутого адаптивного элемента.
this.expand();
this._expandedWidth = this.el.clientWidth;
this.collapse();
this._width = this.el.clientWidth;
То есть легко можно узнать, уместятся ли расхлопнутые элементы на оставшемся пространстве. Если да, то для кнопок рисуем текст, а поиск переключаем в полный вид. Если нет, кнопки остаются в состоянии иконки, а поиск – в виде кнопки, по клику на которую поверх остальных элементов отображается форма.
if (currentWidth + adaptiveFullWidth <= avaibleWidth){
adaptive.expand();
}
Тянущиеся элементы
Остались тянущиеся элементы. Если им не хватает места, то приводим их размеры к базовой ширине.
if (avaibleWidth <= 0){
flexible.width(baseWidth);
}
В противном случае распределяем свободное пространство между тянущимися элементами поровну.
if (avaibleWidth > 0){
flexible.width(baseWidth +
+ avaibleWidth / flexible.length);
}
Но, как мы помним, у нас есть коэффициенты flex, которые надо учесть. Итого, ширина конкретного тянущегося элемента будет равна его базовой ширине, сложенной с дополнительной шириной, рассчитанной пропорционально коэффициентам flex.
var pix = (flexEl.flex) * (avaibleWidth) / (flexSum);
flexEl.width(baseWidth + pix);
Тут есть нюанс. Допустим, у нас есть 100 пикселей свободного пространства и три тянущихся элемента с flex 1. Получается, каждому мы отдадим по 33 пикселя и 1 останется нераспределенным. Ресайзим страницу, получаем, например, 99 пикселей – все распределили.
То есть периодически элементы справа от последнего тянущегося элемента будут прыгать на этот пиксель относительно правой границы.
Для решения этой проблемы можно воспользоваться схемой, аналогичной примененной для схлопываемых групп. Надо запомнить разницу между расчетной величиной, устанавливаемой элементу, и реально примененной – то есть округлить и скорректировать следующий элемент на эту величину.
var pix = (flexEl.flex) * (avaibleWidth) /
/ (flexSum) + delta;
var roundedPix = Math.round(pix);
flexEl.width(baseWidth + roundedPix);
delta += (pix - roundedPix);
В заключение
Итак, нам удалось создать адаптивное портальное меню, соответствующее обоим исходным требованиям. Во-первых, оно получилась технически независимым — за счет использования единого технического решения мы смогли поддержать на всех проектах портальные гайдлайны, и новый портальный стиль везде выглядит действительно единообразно. Во-вторых, мы обеспечили командам проектов гибкое управление набором элементов в портальном меню. Теперь каждая из команд может оперативно его «тюнинговать», при этом оставаясь в рамках гайдлайнов.
То, что получилось, нам нравится — эту задачу можно считать решенной. Но таких задач, объемных и сложных, у нас еще много. Интересно? Приходите кодить к нам!
Комментарии (15)
la0
27.05.2015 21:59А вот скажите пожалуйста, какой толк от этих улучшений, если по всем интерфейсу разбросана вёрстка обратными слешами (обратными\слешами!, нет, ну я еще понимаю nbsp; или pre+табуляции или на худой конец символы |, но обратный слеш в голове не укладывается, да и как такую косую палку в голове уложишь).
Какой смыл в одном месте гонять пиксели, а в остальном верстать слешами?
Никому из разработчиков и менеджменту не противно смотреть на кривые палки? Или коллектив не пользуется собственной продукцией так как имеет какой-то внутренний инсайд?
По интерфейсу такого добра понатыкано огромное количество.
Только что проверил, всё также.
PS не воспринимайте слишком серьёзно, я не хочу никого обидеть, просто это «эталонная» проблема, которая просто показывает качество внутренних процессов. Простейший баг тянется два года.По постам так Лев Толстой — а на деле слеш косой .
ilyaplot
27.05.2015 22:11+1Мне кажется, что такие гиганты, как мейл.ру должны делать адаптивное меню модулем ко всем проектам еще до того, как появится гора фреймворков с такими меню. Впереди нужно идти, а не позади.
z3apa3a
28.05.2015 14:09А еще проще было бы работать всем на одном, фреймворке. Но на практике многие проекты уже приходят готовыми, например в результате слияний или поглощений. Где-то mail.ru выступает в качестве издателя, локализатора или партнера.
sanitar
27.05.2015 23:02+5«Как нарисовать сову на чистом JS?»
owl.drawCircle(); owl.drawTheRestOfTheFuckingOwl();
Это к тому, что по своему содержанию листинги что есть, что нет.madimp Автор
27.05.2015 23:51Вижу только пару таких мест, подходящих под Ваше определение. Да и как Вы предполагаете было бы лучше? Приводить сотню-другую строк кода, оторванных от статьи, вместо простого схематичного init()? Или расписывать, что за elements.show() стоит перебор элемента и вызов соответствующей функции в их контексте?
sanitar
28.05.2015 00:14+5Вероятно мы имеем разные взгляды на необходимость листинга кода в целом. Я не вижу в ваших листингах смысла присутствия, если бы их не было, то информативность статьи никак не пострадала бы.
Например:
«Применение стилей проверяем в лоб – по таймауту.»
appendStyles(); whenStylesLoaded(init);»
Но какая читающему разница как вы функции назвали? Фразы про то, что вы проверяете подгрузку стилей по таймауту более чем достаточно.
Исключительно субъективное мнение — хоть как-то листинг обоснован только в абзаце про расчет ширины. И то очень сомневаюсь, что тем, кто дочитал до конца необходимо иллюстрировать как округлить число.
a1exDi
28.05.2015 14:49Какую систему cms/cmf используйте? Наверняка самописную? Хотелось бы узнать, что под капотом :)
Lux_In_Tenebris
30.05.2015 19:47-4Ага, то есть пользователи с отключённым JavaScript (не спрашивайте по каким причинам) получают пустоту, вместо хоть какого-нибудь меню? Браво, Mail.ru!
z3apa3a
31.05.2015 17:31+2значит, портальное меню должно быть разделено как минимум на три части: стили, скрипт и базовый HTML. HTML должен вставляться в нужное место на странице, рисующееся при загрузке; далее скрипт будет интегрировать туда различные элементы.
А значит, если нет скрипта — будет показываться базовый HTML, который показывается при загрузке.
mckalech
Круто