CSS-live.ru

Отзывчивые компоненты: решение проблемы выражений от контейнера

Перевод статьи Responsive Components: a Solution to the Container Queries Problem  с сайта philipwalton.com, опубликовано на css-live.ru с разрешения автора — Филипа Уолтона.

Выражения от контейнера (Container queries) — предложение, которое позволило бы веб-разработчикам оформлять DOM-элементы в зависимости от размера содержащего их элемента, а не окна браузера.

Каждый веб-разработчик наверняка слыхал о таких выражениях. Разработчики нуждались в них (сначала это были «выражения от элемента», затем — «выражения от контейнера») почти всё время, сколько существует отзывчивый дизайн. Фактически, выражения от контейнера — пожалуй, самая востребованная возможность CSS, которой до сих пор нет в браузерах.

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

Чем зацикливаться на одном конкретном CSS-предложении под названием «выражения от контейнера», я лучше сосредоточусь на построении компонентов, подстраивающихся под своё окружение, в широком смысле. И если вы не против такой широкой постановки вопроса, то у нас есть новый API для веба, который эту задачу уже решает.

Всё верно, нам не нужно ждать выражений от контейнера, чтобы начать делать отзывчивые компоненты. Можно делать их уже сейчас!

Стратегию, которую я предлагаю в этой статье, можно использовать хоть сегодня, и она задумана как улучшение, так что браузеры без поддержки новых API или с выключенным JavaScript будут работать в точности как прежде. А еще ее просто внедрить (достаточно copy/paste), она очень производительна и не требует никаких специальных инструментов, библиотек или фреймворков.

Чтобы показать несколько примеров этой стратегии в действии, я сделал демо-сайт «Отзывчивые компоненты». Каждый пример ссылается на свой CSS-исходник, так что видно, как он действует.

Открыть демо-сайт →

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

Стратегия

Большинство стратегий и методологий отзывчивого дизайна (эта — не исключение) работают согласно двум главным принципам:

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

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

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

Вот пример, определяющий базовые стили для компонента .MyComponent, а затем переопределяющий их в двух произвольных точках, 36em и 48em:

.MyComponent {
  /* Базовые стили, работающие на всех экранах */
}

@media (min-width: 36em) {
  .MyComponent {
    /* Переопределяет эти стили на экранах от 36em и выше */
  }
}

@media (min-width: 48em) {
  .MyComponent {
    /* Переопределяет эти стили на экранах от 48em и выше */
  }
}

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

.Container:media(min-width: 36em) > .MyComponent {
  /* Переопределения только для средних размеров контейнера */
}

К сожалению, такой синтаксис в браузерах не работает и едва ли заработает в обозримое время.

Но уже сейчас работает что-то вроде такого:

.MyComponent {
  /* Базовые стили, работающие на всех экранах */
}

.MD > .MyComponent {
  /* Переопределения только для средних размеров контейнера */
}

.LG > .MyComponent {
  /* Переопределения только для больших размеров контейнера */
}

Конечно, этот код предполагает, что контейнерам для компонента добавлены правильные классы (в данном примере, .MD и .LG). Но помимо этого пустяка, если вы — CSS-разработчик, желающий сделать отзывчивый компонент, то этот второй синтаксис вам всё ещё подходит.

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

Для определённости, отсюда и до конца статьи классы для именованных диапазонов будут соответствовать следующим условиям (где min-width относится к контейнеру, а не окну браузера):

Имя диапазона Ширина контейнера
SM min-width: 24em
MD min-width: 36em
LG min-width: 48em
XL min-width: 60em

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

Отслеживание изменений размера контейнеров

Почти всю историю веб-разработки можно было отслеживать изменения размеров окна, но делать это для отдельных DOM-элементов было трудно, если вообще возможно (по крайней мере, с приемлемым быстродействием). Это изменилось, когда вышел Chrome 64 с поддержкой ResizeObserver.

ResizeObserver, идущий по стопам других подобных API типа MutationObserver и IntersectionObserver, позволяет разработчикам отслеживать изменения размеров DOM-элементов с отличной производительностью.

Вот код, чтобы CSS из предыдущего раздела заработал благодаря ResizeObserver:

// Запускать только если ResizeObserver поддерживается
if ('ResizeObserver' in self) {
  // Создать один экземпляр ResizeObserver для отслеживания всех
  // элементов-контейнеров. Этот экземпляр создается с колбэком,
  // который вызывается при самом начале отслеживания элемента,
  // а также при каждом изменении его размеров.
  var ro = new ResizeObserver(function(entries) {
    // Граничные размеры по умолчанию, которые должны применяться ко всем
    // отслеживаемым элементам, для которых не заданы свои собственные.
    var defaultBreakpoints = {SM: 384, MD: 576, LG: 768, XL: 960};

    entries.forEach(function(entry) {
      // Если для отслеживаемого элемента заданы свои граничные размеры,
      // использовать их. Иначе, использовать дефолтные.
      var breakpoints = entry.target.dataset.breakpoints ?
          JSON.parse(entry.target.dataset.breakpoints) :
          defaultBreakpoints;

      // Обновить совпадающие диапазоны для отслеживаемого элемента.
      Object.keys(breakpoints).forEach(function(breakpoint) {
        var minWidth = breakpoints[breakpoint];
        if (entry.contentRect.width >= minWidth) {
          entry.target.classList.add(breakpoint);
        } else {
          entry.target.classList.remove(breakpoint);
        }
      });
    });
  });

  // Найти все элементы с атрибутом `data-observe-resizes`
  // и начать следить за ними.
  var elements = document.querySelectorAll('[data-observe-resizes]');
  for (var element, i = 0; element = elements[i]; i++) {
    ro.observe(element);
  }
}

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

Этот код создаёт единственный экземпляр объекта ResizeObserver с колбэк-функцией. Затем он запрашивает у DOM элементы с атрибутом data-observe-resize и начинает следить за ними. Колбэк-функция, вызываемая при начале отслеживания, а затем после каждого изменения, проверяет размер каждого элемента и добавляет (или убирает) соответствующие размерные классы.

Другими словами, этот код превратит контейнер шириной 600 пикселей из этого:

<div data-observe-resizes>
  <div class="MyComponent">...</div>
</div>

вот во что:

<div class="SM MD" data-observe-resizes>
  <div class="MyComponent">...</div>
</div>

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

Так что теперь все селекторы с .SM и .MD из предыдущего раздела подойдут к нашему компоненту (а селекторы .LG и .XL — нет), и тот код сам собой заработает!

Настройка диапазонов

Код в колбэке для ResizeObserver выше задает набор диапазонов по умолчанию, но также позволяет указывать произвольные диапазоны для каждого компонента, передавая JSON в атрибуте data-breakpoints.

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

<div data-observe-resizes
     data-breakpoints='{"BP1":400,"BP2":800,"BP3":1200}'>
  <div class="MyComponent">...</div>
</div>

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

Обработка динамических изменений DOM

Пример кода выше работает только для элементов-контейнеров, которые уже есть DOM.

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

Универсальное решение этой проблемы — расширить пример кода выше, чтобы он включал MutationObserver, следящий за всеми добавленными DOM-элементами. Такой подход я использую на своем демо-сайте отзывчивых компонентов, и он хорошо работает для небольших и средних сайтов с ограниченными изменениями DOM.

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

Например, кастомный элемент <responsive-container> может выглядеть как-нибудь так:

// Создаем один объект для отслеживания всех элементов
const ro = new ResizeObserver(...);

class ResponsiveContainer extends HTMLElement {
  // ...
  connectedCallback() {
    ro.observe(this);
  }
}

self.customElements.define('responsive-container', ResponsiveContainer);

Примечание: хотя бывает соблазн создавать новый ResizeObserver для каждого элемента-контейнера, лучше всё-таки создать один ResizeObserver, следящий за многими элементами. За подробностями обратитесь к исследованиям производительности ResizeObserver Алекса Тотика в почтовом архиве blink-dev.

Вложенные компоненты

В моих первых экспериментах с этой стратегией я не оборачивал каждый компонент элементом-контейнером. Вместо этого я использовал по одному элементу-контейнеру для разных областей контента («шапка», боковая панель, «подвал» и т.д.), а в CSS у меня были не дочерние комбинаторы (>), а контекстные (пробел).

Это упрощало разметку и CSS, но тотчас же рассыпалось, когда я попытался вложить один компонент в другой (что делают многие сложные сайты). Проблема в том, что при подходе с контекстными комбинаторами селекторы совпадут со многими контейнерами одновременно.

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

Продвинутые селекторы и другие подходы

В стратегии, что я обрисовал в этой статье, выбран такой подход к оформлению компонентов, при котором стили последовательно добавляются. Другими словами, вы начинаете с базовых стилей и добавляете поверх них всё новые и новые. Но это не единственный способ оформлять компоненты. Иногда бывает нужно задать стили, которые применяются исключительно в определенном диапазоне (т.е. вместо (min-width: 48em) вам понадобится что-то вроде (min-width: 48em) and (max-width: 60em)).

Если вам ближе такой подход, то вам нужно чуть подправить код колбэка для ResizeObserver, чтобы он применял только класс диапазона, совпадающего в данный момент. То есть если компонент попал в диапазон «больших» размеров, у контейнера будет класс не SM MD LG, а просто LG.

Тогда в CSS можно писать селекторы примерно так:

/* Только для конкретных диапазонов */
.SM > .MyComponent { }
.MD > .MyComponent { }
.LG > .MyComponent { }

/* Для нескольких перекрывающихся диапазонов */
:matches(.SM) > .MyComponent { }
:matches(.SM, .MD) > .MyComponent { }
:matches(.SM, .MD, .LG) > .MyComponent { }

Заметьте, что в стратегии с последовательным добавлением стилей, которую рекомендую я, вы тоже можете задавать стили только для одного определенного диапазона с помощью селектора типа .MD:not(.LG), но это, пожалуй, не так наглядно.

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

Примечание: селектор :matches() пока еще плохо поддерживается в браузерах. Но можно воспользоваться инструментами типа postcss-selector-matches и перевести :matches() во что-то кроссбраузерное.

Диапазоны по высоте

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

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

Браузерная поддержка

Хотя ResizeObserver поддерживается пока только в Chrome, нет абсолютно никаких причин, мешающих (тем более запрещающих) вам использовать его уже сегодня. Стратегия, что я обрисовал, по самой своей задумке будет прекрасно работать в браузерах без поддержки ResizeObserver и даже вообще с отключенным JavaScript. В любом из этих случаев пользователи увидят стили по умолчанию, которых должно быть более чем достаточно для приятного пользования сайтом. Фактически, скорее всего это те самые стили, что вы уже отдаете браузерам.

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

Если вы хотите во что бы то ни стало обеспечить единообразный UI во всех браузерах, можете подключить полифил для ResizeObserver, у которого отличная браузерная поддержка (IE9+). Но убедитесь, что полифил загрузят только те пользователи, которым он действительно нужен.

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

Демо-сайт отзывчивых компонентов использует этот последний подход. Он загружает полифил, но только если браузер пользователя не поддерживает ResizeObserver и экран у него не уже чем 48em.

Ограничения и дальнейшие улучшения

В целом, я полагаю, стратегия отзывчивых компонентов, которую я тут обрисовал, невероятно универсальна, и особых недостатков у нее мало. Я глубоко убежден, что всем сайтам, разные области контента которых могут менять размер независимо от окна браузера, нужно реализовать стратегию отзывчивых компонентов, а не полагаться на одни лишь медиавыажения (или решение на JavaScript, но без всех плюсов ResizeObserver).

Тем не менее, у этой стратегии есть несколько ограничений, о которых, я думаю, стоит поговорить.

Это не чистый CSS

Один очевидный недостаток этого решения — его реализация не довольствуется одним CSS. Вдобавок к определению стилей в CSS вам придется еще пометить нужные контейнеры в HTML и координировать одно с другим с помощью JavaScript.

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

В таких делах я люблю напоминать самому себе эту цитату из W3C-шных принципов разработки HTML:

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

Мелькание неоформленного/неправильно оформленного контента

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

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

Я пришел к выводу, что лучшее решение для этого — встраивать код выражений от контейнера прямо в конец HTML-шаблонов, чтобы он запустился как можно скорее. Тогда вам понадобится добавить элементам-контейнерам класс или атрибут, как только они проинициализируются и станут видимы, и вы будете знать, что их можно смело показывать (и убедитесь, что учли случаи выключенного JavaScript или ошибки при запуске). Можете посмотреть на демо-сайте пример, как это делаю я.

Размеры только в пикселях

Многие (если не большинство) CSS-разработчиков предпочитают задавать стили в более осмысленных в своем контексте единицах (например, в em относительно шрифта, в vh относительно высоты окна браузера, и т.д.), но ResizeObserver, как и большинство DOM API, возвращает все значения в пикселях.

Сейчас нет по-настоящему хорошего способа обойти это.

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

Заключение

Эта статья описывает стретегию использования современных веб-технологий для построения отзывчивых компонентов: DOM-элементов, обновляющих свои стили и раскладку в зависимости от размеров контейнера.

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

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

  • Работает, уже сегодня, на любом сайте
  • Легко внедряется (обычным copy/paste)
  • Работает так же быстро, как решение на CSS
  • Не требует никаких особых библиотек, фреймворков или инструментов сборки
  • Дает все плюсы прогрессивного улучшения, так что пользователи браузеров без нужных API или с выключенным JavaScript всё равно могут пользоваться сайтом.

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

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

P.S. Это тоже может быть интересно:

Оставить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

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