javascript

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

  • четверг, 3 апреля 2025 г. в 00:00:06
https://habr.com/ru/companies/timeweb/articles/874486/
На эту статью меня вдохновил вопрос из раздела Q&A «Как выбрать «опорные точки» перехода ширины экрана для стилей страниц сайта?». Занимаясь в последнее время адаптивной вёрсткой, я пришёл к нескольким выводам, которыми и хочу с вами здесь поделиться. Заодно разберём некоторые полезные (и не очень) техники для адаптивной вёрстки, и пересоберём Bootstrap с их учётом.



Лучшая машина — та, которой нет


Как говорят приверженцы ТРИЗ, лучшая машина — та, которой нет (но её функции исполняются).

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

Иногда мы начинаем верстать сложный интерфейс, а чтобы при этом не изобретать велосипед — берём за основу какой-то фреймворк (с брейкпоинтами). Затем нам хочется добавить немного адаптивности в какой-то конкретный блок… и вот мы уже ловим себя на выстраивании подобных цепочек с условиями:

@media (min-width: 1400px) and (max-width: 1599px)
{
	.circle-graph-percentage
	{
		font-size: 2.5rem;
	}
}

@media (min-width: 1200px) and (max-width: 1399px)
{
	.circle-graph-percentage
	{
		font-size: 2.1875rem;
	}
}

@media (min-width: 992px) and (max-width: 1199px)
{
	.circle-graph-percentage
	{
		font-size: 1.875rem;
	}
}

@media (min-width: 768px) and (max-width: 991px)
{
	.circle-graph-percentage
	{
		font-size: 1.2rem;
	}
}

@media (max-width: 767px)
{
	.circle-graph-percentage
	{
		font-size: 1rem;
	}
}

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



Для этого достаточно всего лишь воспользоваться комбинацией единиц vw/vh (с их помощью можно задать любое числовое значение как линейную функцию от ширины/высоты viewport'а) и min()/max()/clamp, чтобы обрезать нашу функцию с одной или двух сторон (размер 0.2rem, как и 10rem, никому не нужен).



Таким образом, можно было бы вместо группы условий, показанных выше, задать размер шрифта одним-единственным значением в диапазоне от (1 * 16) / 767 * 100 = 2.086vw до (2.5 * 16) / 1500 * 100 = 2.67vw.

Как рассчитаны эти числа? 16 это базовый коэффициент пересчёта rem'ов в пиксели, о чём много будет написано ниже. В нижнем диапазоне ширины окна (меньше 768) размер шрифта в пикселях составит 1 * 16, а в верхнем диапазоне (от 1400 до 1600) — 2.5 * 16. Поделим эти значения на условную ширину окна для данного диапазона (767 и 1500 соответственно). И, наконец, домножим на 100, чтобы получить проценты (vw и vh это не просто доли, а проценты).

Допустим, мы выбрали в этом диапазоне размер, равный 2.2vw. Тогда полностью выражение для размера шрифта будет выглядеть, например, так:

font-size: clamp(1rem, 2.2vw, 4rem);

И всё! И не нужны никакие условные блоки!

В этом примере 1rem и 4rem — минимально и максимально допустимые размеры шрифта соответственно.

Проверим, как это выражение будет работать в других диапазонах (например, при ширине окна, равной 900). Для этого сравним полученный размер шрифта в пикселях с предопределённым размером для этого диапазона (он, как вы помните, равен 1.2rem).



19.2 против 19.8? Что ж, весьма неплохо. Раз изначально у нас размер был задан для больших диапазонов, он и должен немного отличаться. (Для краёв отличие будет ещё больше, так что значение в vw стоит аккуратно подбирать и тщательно проверять).

Кроме того, ничто не мешает нам использовать calc(), чтобы тонко настраивать нашу функцию, придавая ей желаемый вид. MDN вообще рекомендует задавать размер как комбинацию rem + vw (font-size: calc(1.5rem + 4vw);), чтобы не лишать пользователя возможности зумить текст.

В чистом итоге, немалую простыню кода с медиа-запросами мы сумели заменили одной-единственной строкой.

Звёздное небо над головой и виджеты внутри дашборда


Но если немного подумать, привязка к размеру viewport'а выглядит несколько искусственно. Гораздо чаще нам нужно решить задачу из разряда «Подогнать размер заголовка виджета под размер самого виджета». Эти виджеты затем размещаются в каком-нибудь дашборде, причём с таким подходом размер отдельного виджета может сильно прыгать.

Ниже показан один и тот же дашборд при ширине окна 1920 пикселей и 900 пикселей.



Что здесь происходит? Как раз за счёт адаптивной вёрстки браузер «понимает», что не может отобразить все виджеты рядом, гарантируя заданный минимальный размер, и размещает их в отдельных строках, заодно увеличивая ширину каждого виджета до ширины окна.

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

Как привязать размер заголовка и внутреннего текста к размеру самого виджета?

Для этого при написании медиа-запросов можно воспользоваться следующим синтаксисом (поддерживается в браузерах с февраля 2023-го):

/* Подгоняем размер текста не под ширину экрана,
а непосредственно под размер виджета. */
@container (max-width: 500px)
{
	.circle-graph-percentage
	{
		font-size: 1rem;
	}
}

Однако, поскольку мы уже решили, что удобнее задавать функцию размера один раз, а не частями, заменим медиа-запросы на единицы размера, но уже привязанные к контейнеру, а не к viewport'у.

Эти единицы называются cqw/cqh, представляют собой проценты от ширины/высоты контейнера, и также поддерживаются во всех браузерах с февраля 2023-го.

Эра мультикультурализма


Использование традиционных направлений (лево-право) и измерений (ширина-высота) не позволяет создавать по-настоящему универсальные интерфейсы. Во-первых, не все пишут слева направо. Именно поэтому вместо -left- сейчас принято использовать -start-, как указание на то, откуда начинается строка. Скажем, в Bootstrap классы-утилиты для margin'ов и padding'ов содержат в названии буквы s (start) и e (end), которые для LTR превращаются в margin-left/padding-left и margin-right/padding-right соответственно, а для RTL — наоборот.

Но и это недостаточно универсальный подход, потому что не все пишут горизонтально! В некоторых азиатских культурах текст пишут сверху вниз. Так что, культурно-нейтральные измерения называются не «ширина» и «высота», а inline и block. Ось inline — та, вдоль которой пишут текст, а ось block — перпендикулярна ей. Конечно, это не имеет смысла в контексте чисто графической вёрстки (например, при создании игры), для которой -left/-right и width/height всё так же пригодны.

Но теперь вы понимаете, почему в CSS культурно-нейтральная версия margin-left называется не margin-start (как в недостаточно нейтральном Bootsrap'е), а margin-inline-start.



Соответственно, в дополнение к единицам cqw/cqh у нас имеются и культурно-нейтральные версии:
  • cqi — проценты текстового измерения.
  • cqb — проценты блочного измерения.
  • cqmin — проценты меньшего измерения.
  • cqmax — проценты большего измерения.

Даже если вы верстаете для строго заданной культуры, запомните, что такое текстовое (inline) и блочное (block) измерения, поскольку ссылки на эти измерения будут встречаться в CSS'е там и сям, а нам придётся их использовать.

Ещё раз. Для русского и английского языков:
  • Текстовое (inline) измерение — горизонталь.
  • Блочное (block) измерение — вертикаль.

Главное в любом расследовании — не выйти на самих себя


Если бы с единицами размера, привязанными к контейнерам, всё было просто, они появились бы не в 2023-м году, а гораздо раньше (минимум, лет десять назад).

Представьте себе контейнер (div), в который вложен абзац текста (p). Что будет, если мы зададим размер этого текста как 10% от высоты контейнера? Размер контейнера будет зависеть от размера текста, а размер текста — от размера контейнера.



На самом деле, такой контейнер просто схлопнется в 0. Поэтому, во избежание зацикливаний, он должен иметь независимо рассчитанный размер (или указанный явно, или выведенный из особенностей блочных, flex- и grid-элементов).

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

Для удобства нам предоставлен выбор, какой размер контейнера допустимо использовать для расчёта значений вложенных элементов. Если в свойстве container-type указать size, контейнер должен иметь независимый размер по обеим осям (и текстовой, и блочной), а если значение inline-size — то только по текстовой оси.

Возьмём следующий пример:

<body>

	<div class="container">
		<p>Бритва Хэнлона — правило, предписывающее делать более обидное
			предположение, что имеешь дело с дураком, а не с негодяем.</p>
	</div>

</body>

html,
body
{
	padding: 0;
	margin: 0;
}

div.container
{
	container-type: inline-size;
	overflow: hidden;
}

div.container > p
{
	margin: 0;
	font-family: Bahnschrift;
	font-size: 5cqw;
}


Используем обрезку (overflow: hidden;) для упрощения, чтобы не рассматривать здесь случаи, когда элемент отображается, потому что имеет размер, а контейнер — нет. Кроме того, это удобно для вёрстки виджетов.

Что мы увидим, растянув окно браузера на 750 пикселей? Текст, набранный кеглем 37.5px и контейнер, имеющий размер 750 × 184:



Почему? Разве мы не должны увидеть «схлопнутый» контейнер? Ведь мы не задали ему ширину (размер в текстовом измерении), хоть и указали, что он её источник (container-type: inline-size;), и ссылаемся на неё при помощи cqw.

Всё дело в том, что наш контейнер, div — по умолчанию, блочный элемент (display: block;). Удалите из него всё содержимое, и он сам по себе, независимо, будет иметь размер 750 × 0 (потому что такова ширина родительского элемента). Эту ширину браузер делит на 100 и умножает на 5, получая размер шрифта 37.5px. Имея размер шрифта, он может вычислить размер абзаца (p), ну а абзац распирает контейнер изнутри, благодаря чему он и приобретает размер 750 × 184.

Если вам кажется, что это не совсем очевидно, давайте посмотрим, что получится, если размер 5cqw заменить в нашем примере на 5cqh:



Но, Холмс, чёрт возьми, как?! Уж что-что, а высота (размер в блочном измерении) контейнера без содержимого — нулевая. Что не мешает браузеру откуда-то вычислить размер шрифта (34px) и даже определить размер контейнера как 123.

Подсказка содержится на самой картинке. Поскольку у контейнера указан тип inline-size (донор размера только в текстовом измерении, то есть, ширины), браузер его благополучно игнорирует, начинает искать подходящий контейнер вверх по дереву, не находит и использует размер viewport'а (680). Ну а далее — всё как в первом случае: вычисление размера шрифта (680 * 5% = 34px), размера абзаца и размера распираемого контейнера.

Теперь заменим тип контейнера на size (донор размера в обоих измерениях), оставив размер шрифта в cqh. Ну, тут, слава богу, никаких сюрпризов: размер шрифта, высота абзаца и высота контейнера — все нулевые.

Наконец, укажем размер шрифта в cqw для контейнера с типом size:



Этот случай интересен тем, что размер шрифта исправно рассчитывается на основе ширины (div — блочный элемент). Затем на основе уже размера шрифта рассчитывается высота абзаца. Но вот высота контейнера остаётся нулевой! Ведь высота size-контейнера (размер в блочном измерении) больше не рассчитывается на основе содержимого.

Таким образом, контейнер остаётся схлопнутым, хотя абзац, размеры которого были рассчитаны на основе этого контейнера, имеет габариты. И если бы мы не поставили обрезку (overflow: hidden;), к нам бы пришёл сюрприз.

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

  • Если есть возможность указать размеры контейнера явно, так и стоит поступить, заодно присвоив ему тип size. Не придётся бегать по всему дереву, выискивая, чей размер браузер взял за основу.
  • Если при отладке не удаётся найти донора размера, измерьте габариты viewport'а. В дереве DOM поросёнок DevTools их, к сожалению, не показывает (но показывает справа вверху окна в процессе изменения его размеров). Вполне возможно, что контейнером служит viewport, а cqh выродился в vh.
  • Если непонятно, как браузер рассчитал размер в пикселях, удалите из контейнера всё содержимое, чей размер задан относительно контейнера, и посмотрите какого размера он станет. Ещё можно снять с него контейнерность, убрав соответствующее свойство. Легче будет понять, что неявно задавало его размер (и задавало ли что-нибудь вообще).

Поделись размером по-братски!


Разберём ещё один кейс.

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

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

Получается, что истинным источником размера для заголовка выступает содержимое, находящееся с ним на одном уровне вложенности (sibling). Для такого случая я написал следующий класс (на LESS):

.container-size
{
	.pos(0);		/* left: 0; top: 0; */
	.size(100%);	/* width: 100%; height: 100%; */

	position: absolute;
	container-type: size;
}


Всё, что нам теперь нужно — вложить заголовок в div-обёртку (wrapper), помеченную классом container-size. Эта обёртка имеет явно заданные размеры, совпадающие с размерами виджета, и можно указывать font-size в cqh. При этом подходе заголовок оказывается вынесен в отдельный слой (в принципе, все вторичные элементы можно раскидать по слоям), но такое отделение от первичного содержимого, устанавливающего размер виджета, вполне логично.

Пара слов о препроцессорах


Препроцессоры — это удобно! Некоторые вещи из этой серии статей без них сделать просто не получится. А те, что получится — выглядят гораздо более многословно, и что хуже всего — требуют самоповторов. С тех пор, как в одном браузере я увидел нестандартное CSS-свойство size (как шорткат для width и height), я мечтал о возможности использовать его повсюду.

С препроцессором (я использую LESS), мечты стали реальностью:

.size(@w, @h)
{
	width: @w;
	height: @h;
}

.size(@size)
{
	.size(@size, @size);
}


Это миксины — функции, которые генерируют классы на основе переменных. Миксин .size() имеет две перегруженные версии: с одним и двумя параметрами. Аналогично размерам я поступил и с позиционированием:

.pos(@l, @t)
{
	left: @l;
	top: @t;
}

.pos(@offset)
{
	.pos(@offset, @offset);
}


Именно эти миксины вы и видели в описании класса container-size.

Эм-м-м…


Не будем забывать, что container queries units — не единственный способ привязаться к размерам контейнера, и не всегда самый удобный. Для указания размеров текста есть специальная единица — em, означающая размер текста родительского элемента (но не в процентах, а в долях: 1em это 100%).

Иными словами, cqw/cqh задают размер относительно габаритов контейнера, а em — относительно его font-size.

Для span'ов (у которых по умолчанию display: inline;) использовать em — милое дело. Например, так сделаны бейджи в Bootstrap, у которых размер текста составляет три четверти от родительского (для наглядности я убрал font-weight):



.badge {
…
  --bs-badge-font-size: 0.75em;
…
  font-size: var(--bs-badge-font-size);
…
}


Используя размеры в em для блочных элементов (div) можно выстраивать параллельные иерархии размеров (отдельно для font-size, отдельно для габаритов). А иногда их можно пересекать в каких-то точках. Для этого я написал вот такой миксин:

.size-and-font(@size)
{
	.size(@size);

	font-size: @size;
}


Так что…


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

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

.cloud
{
	display: grid;
	gap: rp(30);

	grid-template-columns: rp(240) 1fr rp(240);
	grid-template-areas:
		'first      figure      third'
		'second     figure      fourth';

	@media (max-width: rp(1199))
	{
		gap: rp(20);

		grid-template-columns: rp(240) 1fr;
		grid-template-areas:
			'first      figure'
			'second     figure'
			'third      figure'
			'fourth     figure';
	}

	@media (max-width: rp(899))
	{
		grid-template-columns: 1fr 1fr;
		grid-template-areas:
			'first      second'
			'figure     figure'
			'third      fourth';
	}

	@media (max-width: rp(799))
	{
		grid-template-columns: 1fr;
		grid-template-areas:
			'first'
			'second'
			'figure'
			'third'
			'fourth';
	}
}


Современный CSS стал чертовски наглядным, и позволяет натурально «чертить» таблицы прямо в коде. Ну, почти.

ℹ️ На заметку

Не используйте табуляции внутри CSS-строк (таких, как 'first second'). Браузеры их скушают без проблем, а вот препроцессор или минификатор могут удивиться сами, а затем удивить вас (например, склейкой в одну строку).

За возможность вкладывать медиа-запросы внутрь класса и использовать новые единицы — rp() — отвечает LESS. А теперь поговорим про эти единицы.

Все оттенки адаптивности


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

Иными словами, мало учитывать ширину экрана. Мало учитывать и зум (лично я при вёрстке всегда проверяю, как отображается интерфейс на 75% и 150%, а по-хорошему, в тест-план надо включать все возможные комбинации ширины и зума). Потому что помимо них есть ещё и базовый размер шрифта в настройках браузера, который даёт нам ещё одно измерение в пространстве комбинаций (на радость разработчикам и QA-инженерам).

Вот как настройка базового размера шрифта выглядит в десктопном Firefox:


А вот как эта же настройка выглядит в десктопном Chrome:


Я люблю Chrome как разработчик, и не люблю как пользователь. И часто по одной и той же причине. Там, где Firefox даёт выбрать число, и оно лежит в достаточно большом диапазоне от 9 до 72, Chrome предлагает выбрать размер из пяти вариантов, описанных словами (таки чтоб вы знали: Very small это 9, Small это 12, Medium (по умолчанию) это 16, Large это 20 и Very large — 24 пикселя).

К этому числу пикселей и привязана единица, которая называется rem (root em, em корневого элемента). Иными словами, это переменная, которая зависит от настроек браузера, так что бесполезно искать калькулятор в Интернете для пересчёта (в глупом Гугле поисковая выдача забита этими «калькуляторами»).

ℹ️ Но и это не всё…

Чтобы запутать вас ещё больше, скажу, что это не совсем пиксели. Во-первых, если поменять размер текста в системных настройках Windows 11, внезапно это увеличит размер того, что называется пикселями в браузерах — вдвойне нелогично. Кроме того, при зуминге браузер меняет размеры объектов, свёрстанных в пикселях, хотя в DevTools показывает, что их размер не увеличился — с этим, хотя бы, всё понятно, любая лупа так и должна себя вести.

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

 Почему именно «увеличит»? Потому что в системных настройках Windows 11 100% — минимальный размер текста.

В обоих браузерах по умолчанию выбрано значение 16, и это не случайно. 16 является степенью двойки. А это значит, что при настройках по умолчанию любой целочисленный размер в пикселях при переводе в rem'ы становится конечной, и даже не очень длинной десятичной дробью. Например, 500 пикселей при настройках по умолчанию превратятся в 500 / 16 = 31.25rem. После смены настроек, понятное дело, соотношение изменится.

Давайте убедимся, что всё работает именно так, как предполагается:

<body>
	<div class="rect px-rect">500px</div>
	<div class="rect rem-rect">31.25rem</div>
</body>


.rect
{
	outline: 1px solid black;
	height: 50px;
	font-family: Bahnschrift;
	text-align: center;
	margin: 10px;
}

.px-rect
{
	width: 500px;
	background-color: azure;
}

.rem-rect
{
	width: 31.25rem;
	background-color: aquamarine;
}


Вот как выглядит эта страница при настройках по умолчанию:



А вот как — если мы поставим размер в 24 пикселя (FF) или Very large (Chrome):



Интересный момент: Firefox в данном случае оказывается более дружелюбным к разработчику. После изменения базового размера шрифта каждую вкладку потребуется перезагрузить, чтобы изменения в ней вступили в силу (Chrome применяет настройки ко всем открытым вкладкам автоматически). В результате, если вы использовали компоненты, сделанные императивно, а не декларативно (с расчётами геометрии на Javascript при инициализации, выполняемой сразу после загрузки), ваша разметка в Chrome сломается, а в Firefox — нет.

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

Гордиев узел по-македонски


Очевидно, что разметка не сломается, если всё сверстать в пикселях.



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

Распутываем клубок


Для начала попробуем хотя бы все размеры шрифтов привязать к базовому размеру из настроек.

Там, где у нас раньше был font-size: 16px; напишем font-size: 1rem;. У пользователя с настройками по умолчанию он превратится в 16px. У пользователя, выбравшего Very large, он превратится в 24px. Надо просто делить число на 16 и заменять px на rem.

Пока что всё хорошо. Попробуем заменить font-size: 18px;. Делим на 16, получаем 1.125rem. Э-э-э… ну, допустим.

Теперь попробуем заменить font-size: 17px;. Делим на 16, получаем 1.0625rem… Че-го? И вот с такими значениями нам теперь всюду придётся иметь дело?

Да, благодаря тому, что 16 — степень двойки, эти дроби конечны и имеют не более четырёх знаков в дробной части. Но кто из нас, положа руку на сердце, может, взглянув на размер 1.0625rem, сразу представить, много это или мало?

Перейдя от пикселей на rem'ы мы потеряли выразительность, и с этим надо что-то делать. Вот если бы у нас были «плавающие», относительные пиксели… Увы, в стандарте CSS нет ничего подобного. И тут нам в очередной раз пригодится препроцессор.

В LESS не нашлось встроенной функции для преобразований (а нам нужна именно функция, чтобы результат расчёта можно было универсально вставлять куда угодно, например, в box-shadow или clip-path:rect()). Что ж, напишем её сами!

Создадим файл less-lib.js (и положим его там, где найдёт препроцессор — например, в одной папке с файлами .less). Документации немного не хватает, но в Javascript'е узнать про контекст довольно легко, и вскоре реализация готова:

registerPlugin
(
	{
		install: function (less, pluginManager, functions)
		{
			functions.add('rp', function (rpx)
			{
				return new tree.Dimension(rpx.value / 16, 'rem');
			});
		}
	}
)


Подключим нашу библиотеку, вставив в начало .less-файла:

@plugin "less-lib"; // Functions: rp().

И готово! Теперь мы можем всюду писать просто rp(17). В 1.0625rem оно превратится автоматически, при компиляции.

Почему я выбрал такое имя, rp()? Ведь можно было назвать px-to-rem() или calcRem()? А потому, что такое название не мешает искать в коде вхождения px и rem.

Чем компилировать?


Конечно, у препроцессоров есть и недостатки. Чем короче пайплайн — тем лучше, а если файлы можно сразу открывать в браузере, то это идеальный вариант. LESS же придётся чем-то компилировать. (Вариант компилировать его в рантайме у каждого клиента мы даже не рассматриваем). Лично я, поскольку часто пишу интерфейсы на HTML для приложений, собранных вместе с браузером, предпочитаю, чтобы код компилировался просто при сохранении файла (без какого бы то ни было деплоя). Вообще, у меня в планах сделать расширение для Chrome, которое занималось бы несколькими видами препроцессинга, в т.ч. компилировало LESS на лету, чтобы на машине разработчика можно было обращаться с LESS как с CSS, но руки всё никак не дойдут.

А до тех пор я остановился на вот этом расширении к Visual Studio 2022: Web Compiler 2022+. Один раз .less-файл надо откомпилировать через контекстное меню, после чего он начнёт компилироваться при каждом сохранении. Одна проблема: даже если вам нужно редактирование голых файлов, придётся завести пустой проект. Рекомендую .vcxproj (Empty VC++ project) созданный в одной папке с .sln. Это реально пустой проект, который не тащит за собой ничего, и, в отличие от пустого веб-проекта на C#, он не будет выносить мозг, предлагая проапдейтиться при каждом обновлении .Net.

Ниточка тянется дальше


Хорошо: размеры шрифтов мы переписали на rem'ы. Что дальше?

Не знаю, помнит ли ещё кто-то такой язык разметки — RC, на котором создавались WinAPI-приложения.



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

К счастью, CSS не RC, и избежать таких ситуаций в нём легко. Кнопкам можно вообще не задавать высоту, определив вместо этого padding, из которого вместе с текстом и будут складываться окончательные габариты.

Но с этим есть две проблемы.

Во-первых, если задать padding в пикселях, а размер текста на кнопке — в rem'ах, при смене настроек расстояние до верхнего и нижнего краёв кнопки изменится непропорционально тексту и это будет выглядеть столь же некрасиво, как во времена ресурсных файлов Windows. Значит, padding желательно тоже задать в rem'ах.

Во-вторых, кнопка, зависящая от настройки базового размера шрифта через текст внутри, будет менять габариты и может не вписаться в окружающую разметку. Если мы зададим padding в rem'ах, это ещё больше усугубит проблему. Значит, окружающая разметка должна как-то учитывать размер кнопки в rem'ах. Самое простое для этого — всё, что не является специфически пиксельным, верстать в rem'ах. Только так можно гарантировать, что разметка не разъедется.

ℹ️ Может пригодиться

Если вы пишете интерфейсы на HTML, и запаковываете их вместе с браузером, чтобы получить приложение, то вёрстка в rem'ах даст вам возможность реализовать паттерн UI «Масштабирование интерфейса» (см., например, десктопный клиент Telegram, где масштаб явно вынесен в настройки). Управлять этой настройкой браузера, разумеется, надо будет снаружи, со стороны приложения, а не из кода интерфейса.

Брейкпоинты наносят ответный удар


Вернёмся теперь к Bootstrap'у, чьё название стало нарицательным для breakpoint-based framework'ов. (В вопросе, со ссылки на который началась эта статья, как раз был такой совет по подбору значений: просто взять Bootstrap).

Он содержит в себе заботливо прикопанные грабли. Дело в том, что брейкпоинты в нём задаются в пикселях, а вот зависящие от них размеры (например, margin- и padding-утилиты) — в rem'ах.

Это значит, что в зависимости от настроек у пользователя переключение между брейкпоинтами будет происходит при разных размерах, выраженных в rem'ах, а вся заботливо выстроенная и проверенная rem-вёрстка может разъехаться. И чем более необычный размер выбрал юзер, чем более всё было пригнано к граничным значениям — тем больше наши шансы.

Чтобы этого не происходило, брейкпоинты тоже нужно выражать в rem'ах.

А это вообще законно?


Короткий ответ — да.

Вот ответ немного длиннее. Как вы помните, rem расшифровывается как root em (em корневого элемента). Что вызывает вопрос: а можно ли переопределить размер шрифта корневого элемента средствами самого CSS?

И снова, ответ — да. Но я бы на вашем месте не стал торопиться. Если написать:

html
{
	font-size: 20px;
}


…и открыть в браузере с настройками по умолчанию, то получим следующий замечательный результат:

@media (max-width: 50rem) /* Тут 50rem это 800 пикселей. */
{
	.full-screen-width
	{
		width: 50rem; /* А тут, чтобы тебе не было скучно, разработчик,
		50rem это 1000 пикселей. И ты выиграл один бесплатный скроллбар. */
	}
}


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

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

Я на Гитхабе предложил разработчикам Bootstrap'а учесть всё вышеизложенное и перевести брейкпоинты на относительные размеры, но ответа пока не получил. К счастью, ничто не мешает нам собрать свой собственный Bootstrap.



Инструкция по постройке луна-парка


Сначала качаем актуальную версию исходников. На данный момент взять её можно тут: github.com/twbs/bootstrap/archive/v5.3.3.zip

Стилевая часть Bootstrap'а написана на SASS.

В первую очередь нас интересует то место в файле scss/_variables.scss, где, собственно, и задаются значения брейкпоинтов: $grid-breakpoints. Заменим пиксели на rem'ы:

// scss-docs-start grid-breakpoints
$grid-breakpoints: (
  xs: 0,
  sm: 36rem,
  md: 48rem,
  lg: 62rem,
  xl: 75rem,
  xxl: 87.5rem
) !default;
// scss-docs-end grid-breakpoints

ℹ️ Это печально

После компиляции все значения будут доступны через следующие CSS-переменные:
:root {
  --bs-breakpoint-xs: 0;
  --bs-breakpoint-sm: 36rem;
  --bs-breakpoint-md: 48rem;
  --bs-breakpoint-lg: 62rem;
  --bs-breakpoint-xl: 75rem;
  --bs-breakpoint-xxl: 87.5rem;
}

Однако, привязаться в своих медиа-запросах в CSS/LESS к ним нельзя! В частности, такой медиа-запрос не сработает:
@media (min-width: var(--bs-breakpoint-md))
{
…
}

Полностью избежать копипасты можно только используя SASS в своём проекте.

Далее, нам обязательно нужно исправить такую вещь, как максимальные размеры контейнеров, которые тоже задаются… та-дам!.. в пикселях: $container-max-widths. Заменим на rem'ы и их:

// scss-docs-start container-max-widths
$container-max-widths: (
  sm: 33.75rem,
  md: 45rem,
  lg: 60rem,
  xl: 71.25rem,
  xxl: 82.5rem
) !default;
// scss-docs-end container-max-widths


Заменять ли остальные пиксели — вопрос открытый, мне на проблемы с ними напарываться не довелось.

…и собираем!


Самый простой путь — скачать и установить Node.js.

Открываем консоль в папке с исходниками, запускаем npm install и ждём, пока будут установлены все зависимости.

Затем по очереди выполняем две команды:
  • npm run css
  • npm run js

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




В следующий раз в контексте адаптивной вёрстки и размеров шрифта пользователя поговорим про изображения. Это достаточно большая тема и она потребует отдельной статьи.



Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале



Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.

Читайте также: