![CSS-классы вредны - 1 CSS-классы вредны - 1](https://www.pvsm.ru/images/2024/07/20/CSS-klassy-vredny.png)
Если вы когда-нибудь заглядывали за кулисы пользовательских веб-интерфейсов, то знаете для чего нужно свойство
class
. Оно ведь нужно для связи HTML с CSS, правда? Сейчас я расскажу о том, почему настало время отказаться от него. Имена классов — это архаичная система, используемая как неудачный посредник для примитивов UI; ещё хуже то, что они создают ужасные сочетания, приводящие к комбинаторному взрыву странных пограничных случаев. Давайте изучим этот вопрос, начав со скучного урока истории, который вы уже слышали миллион раз.
Классы очень старые, даже древние
HTML 2.0 (1996 год) стала первой опубликованной спецификацией HTML, в ней имелся фиксированный список имён тегов, и у каждого тега был фиксированный список разрешённых атрибутов. Документы HTML 2.0 нельзя было стилизовать, ведь какой в этом смысл? Экраны многих компьютеров в то время были чёрно-белыми! Самым близким к стилизации в HTML 2.0 был тег <pre>
, имевший атрибут width
. Для разработки HTML 3.0 потребовалось несколько лет, а Netscape и Microsoft тем временем добавляли всевозможные странные расширения, включая любимые нами теги <marquee>
и <blink>
. В конце концов, все разногласия были разрешены, и в 1997 году появился HTML 3.2, что позволило «стилизовать» тег <body>
такими атрибутами, как bgcolor
и text
.
![CSS-классы вредны - 2 CSS-классы вредны - 2](https://www.pvsm.ru/images/2024/07/20/CSS-klassy-vredny-2.png)
Тем временем изобрели CSS, ставший способом добавления в веб-страницы некой структуры и стилизации. Чтобы веб был повеселее. История HTML 3.2 оказалась короткой, потому что в том же 1997 году был опубликован HTML 4.0, в котором появились механизмы поддержки CSS, в том числе новые «идентификаторы элементов» — атрибуты id
и class
:
Чтобы повысить удобство управления элементами, в HTML был добавлен новый атрибут [2] CLASS. Всем элементам внутри элемента BODY можно добавлять классы, а настраивать их можно в таблице стилей CSS Level 1
Эти атрибуты позволили нам определять «классы» элементов, которые можно стилизовать при помощи ограниченного набора тегов. Например, <div class="panel">
может внешне сильно отличаться от <div class="card">
, несмотря на одинаковое имя тега. Концептуально это можно воспринимать как классическое наследование (то есть класс Card расширяет Div
) — наследование семантики и базовых стилей div
с созданием стиля для класса Card
с возможностью его многократного использования.
С 1997 года мы пережили более двадцати лет инноваций в вебе. Теперь существует множество способов структурирования CSS.
«Классы очень стары» — это не аргумент против классов, однако это показывает, что классы решали свою задачу в период серьёзных ограничений. Веб был молодым, браузеры были менее сложными, а цифровой дизайн — гораздо менее зрелым. В то время нам не нужно было более сложное решение.
Масштабирование селекторов Class
Если продолжить рассуждать о свойстве class
как об аналоге классов ООП, то можно отметить, что классы редко не получают параметров и не имеют состояния. Ценность классов в C заключается в том, что они имеют «режимы» благодаря параметрам, и мы можем менять их состояние при помощи методов. CSS имеет псевдоселекторы, описывающие ограниченные части состояния, например, :hover
, но для описания индивидуального состояния или модальности внутри класса нужно использовать ещё больше классов. Проблема в том, что class
получает только список строк…
Вернёмся к нашему примеру с Card
. Допустим, нам нужно параметризировать Card
так, чтобы он получал опцию size
, имеющую одно из значений Big
/Medium
/Small
, булеву опцию rounded
и опцию align
со значением Left
/Right
/Center
. Пусть также наш Card
может загружаться «лениво», так что нам нужно описать состояние Loading
и Loaded
. В нашем распоряжении имеется множество опций, но каждая из них обладает ограничениями:
- Мы можем представить их в виде дополнительных классов, например,
<div class="Card big">
. Проблема такого подхода заключается в том, что ему не хватает пространств имён; могут появиться какие-то другие CSS и вобрать в себя значениеbig
для своего компонента, что может привести к конфликту. Обойти эту проблему можно при помощи комбинирования селекторов в CSS:.Card.big {}
, но это может поднять вопросы специфичности, что в дальнейшем способно привести к проблемам. - Мы можем представить их в виде отдельных «конкретных» классов, например,
<div class="BigCard">
. Проблема такого подхода в том, что потенциально мы создадим множество дублирующихся CSS, так какBigCard
иSmallCard
с большой вероятностью будут иметь много общего CSS. Такой подход создаёт и проблемы масштабируемости, приводя к проблеме комбинаторного взрыва; для одной лишь опцииsize
нам нужно создать три класса, но если добавитьrounded
, то их уже нужно шесть, а если ввести ещё иalign
, то придётся создавать 18 классов. - Мы можем создать пространства имён для параметров классов, например,
<div class="Card Card--big">
. Это помогает смягчить конфликты и избежать проблемы комбинаторного взрыва, но такая система будет слишком многословной, с большим объёмом дублирующейся типизации; к тому же она страдает от ещё одной проблемы, связанной с неправильным использованием: что произойдёт, если я использую классCard--big
безCard
?
Современный CSS может решить некоторые из этих проблем, например, функции псевдоклассов :is()
и :where()
могут манипулировать со специфичностью селектора (.Card:is(.big)
имеет равную специфичность с .Card
). Также при разработке таких систем можно использовать языки наподобие SASS, способным снизить неудобства дублирования благодаря вложенности и примесям. Это повышает удобство для разработчиков, но не решает фундаментальные проблемы.
А ещё у нас есть множество проблем, которые классы не могут решить по своей природе:
- При работе с классами переходных состояний наподобие
loading
иloaded
код может произвольно применять эти классы к элементу, даже когда элемент на самом деле не загружается. Противодействовать этому можно путём дисциплины разработки (плохо масштабируется на большие команды) или инструментарием (сложно поддерживать). - При работе со взаимоисключающими классами наподобие
Big
иSmall
элементы могут применять оба класса одновременно, и никакие системы именования классов не помогут в решении этой проблемы, если только вы не будете решать их целенаправленно добавлением инструментария или кода (например,.Card.big.small { border: 10px solid red }
).
Кроме того, существует «кустарная промышленность» псевдоспецификаций CSS, пытающихся решить эти проблемы, но какого-то полного решения не существует:
▍ BEM — это не решение
BEM, или Block Element Modifier, обеспечивает достаточно надёжное и масштабируемое решение задачи параметризации классов. Он использует пространства имён, позволяющие предотвращать проблемы многократного использования, но ценой многословности. У него есть жёсткие правила именования, что немного упрощает анализ кода.
.Card { }
.Card--size-big { width: 100%; }
.Card--size-small { width: 25%; }
.Card--rounded { border-radius: 6px }
.Card--align-left { text-align: left }
.Card--align-right { text-align: right }
.Card--align-center { text-align: center }
.Card__Title { /* Подкомпоненты! */ }
BEM даёт нам небольшую степень согласованности, но не решает двух фундаментальных проблем классов (контроль за инвариантностью). Я могу применить class="Card--size-big Card--size-small"
к одному элементу, а предоставляемый BEM фреймворк не в силах этому помешать. Аналогично, в BEM нет понятия защищённых свойств, так что я должен положиться на то, что вы не будете добавлять к элементу .Card--is-loading
. Эти проблемы легче выявить благодаря фреймворку именования, но по уровню удобства они находятся на одном уровне с префиксами _
методов JavaScript. Всё работает, если вы соблюдаете правила, но вас никто не заставит это делать.
Ещё одна большая проблема BEM заключается в том, что для описания динамического состояния через JS требуется огромный объём бойлерплейта:
/* Это наименьшее, что я придумал без добавления вспомогательных функций */
function changeCardSize(card, newSize: 'big' | 'small' | 'medium') {
card.classList.toggle('.Card--size-big', newSize === 'big')
card.classList.toggle('.Card--size-medium', newSize === 'medium')
card.classList.toggle('.Card--size-small', newSize === 'small')
}
Для решения проблемы бойлерплейта можно в том числе использовать вспомогательные функции, но это просто проталкивает проблему дальше, а не решает её.
▍ Атомарный CSS — это не решение
Атомарный CSS, или «utility-классы» отходят от концепции ООП описания компонентов систем дизайна наподобие Card, вместо этого используя классы как абстракцию от свойств CSS. Это хорошо подходит большинству систем дизайна, которые, по сути, являются подмножеством самого CSS (например, CSS позволяет использовать практически неограниченное количество цветов, в то время как в вашей палитре бренда может быть меньше сотни цветов). Наиболее примечательной реализацией атомарного CSS, вероятно, является популярная библиотека Tailwind, но это может выглядеть примерно так:
.w-big { width: 100% }
.w-small { width: 25% }
.h-big { height: 100% }
.al-l { text-align: left }
.al-r { text-align: right }
.br-r { border-radius: 6px }
/* и так далее... */
При этом, повторюсь: атомарный CSS не решает двух главных проблем классов. Я всё равно могу применить к своему элементу class="w-big w-small"
, и по-прежнему отсутствует возможность использования защищённых классов.
Кроме того, атомарный CSS обычно приводит к хаосу в разметке. Чтобы снизить многословность, в таких системах обычно предпочитают краткие имена классов из нескольких символов наподобие br
вместо border-radius
. Чтобы описать наш пример с Card в этой системе, требуется китайская грамота из непостижимых имён классов, а ведь это тривиальный пример:
<!-- Big Card -->
<div class="w-big h-big al-l br-r"></div>
Кроме того, при использовании атомарного CSS теряются многие преимущества CSS. Атомарный CSS заставляет всех изучать документацию; опытным дизайнерам с большим опытом в написании CSS приходится обращаться к таблице поиска: «мне нужно сделать flex-shrink: 0
, это будет flex-shrink-0
или shrink-0
»? Все utilities в общем случае представляют собой одно имя класса, то есть мы теряем все преимущества специфичности; если же мы добавляем специфичность при помощи смешения методологий или использования media query или встроенных стилей, то всё становится ещё хуже и начинает разваливаться на части. Обычно проблему специфичности решают добавлением ещё большей специфичности; в Primer CSS GitHub эту проблему обходят добавлением !important
к каждому utility-классу, что в дальнейшем создаёт новые проблемы.
Если уж мы коснулись темы media query, то скажу, что считаю самой большой проблемой атомарного CSS то, что он оставляет адаптивный дизайн (responsive design) открытым к интерпретациям. Многие реализации предоставляют классы, которые применяются только в контрольной точке адаптивности, что лишь ещё больше загрязняет разметку и подвержено проблеме комбинаторного взрыва. Вот фрагмент со всего лишь двумя width в двух контрольных точках, определённый в CSS Tailwind:
.w-96 { width: 24rem }
.w-80 { width: 20rem }
@media (min-width: 640px) {
.sm:w-96 { width: 24rem; }
.sm:w-80 { width: 20rem; }
}
@media (min-width: 768px) {
.md:w-96 { width: 24rem; }
.md:w-80 { width: 20rem; }
}
<!-- Big Card на больших экранах, Small Card на маленьких экранах -->
<div class="w-96 sm:w-80 al-l br-r"></div>
На первый взгляд система utility-классов кажется благословением для системы дизайна, но если применить её в разметке, становятся заметны проблемы. Невозможность удобного представления компонентов в разметке заставляет систему дизайна искать другие решения, например, добавление разметки с прикреплёнными именами классов для описания компонента, что обычно приводит к тому, что система дизайна реализует компоненты во множестве фреймворков.
У методологии Utility CSS есть куча других проблем. Если вы считаете, что это решение вам подходит, то рекомендую потратить время на изучение его недостатков.
▍ CSS-модули — это не решение
На самом деле, CSS-модули решают только одну проблему: «коллизию селекторов». Можно написать CSS в одном файле, который станет пространством имён классов, а затем обработать его инструментом, добавляющим в начало пространств имён, а в конец — случайные символы. Случайные символы генерируются во время сборки, чтобы избежать коллизий самописных стилей, не использующих CSS-модули, с теми, которые их используют. При этом наш CSS Card
.card { /* "Базовый" компонент */ }
.big { width: 100% }
.small { width: 25% }
/* ... и так далее ... */
после преобразования на этапе сборки превращается в
.card_166056 { /* ... */ }
.card_big_166056 { width: 100% }
.card_small_166056 { width: 25% }
/* ... и так далее ... */
Похоже, что это решает проблемы с BEM, потому что теперь не нужно везде прописывать пространства имён! Но вместо этого нужен инструментарий, который необходимо разработать и поддерживать во всём стеке, описывающем UI. Для этого нужно, чтобы фреймворк шаблонизации, среда исполнения JS (если она отличается) и компилятор CSS понимали и использовали одну систему CSS-модулей, что создаёт множество зависимостей в кодовой базе. Если вы работаете в большой организации с кучей обслуживаемых веб-сайтов, которые, вероятно, написаны на разных языках, то вам нужно разработать и поддерживать инструментарий для всего этого. Вашей команде разработки системы дизайна придётся заняться оснасткой этого инструментария (или переложить эту ношу на другие команды разработчиков).
Но у нас всё равно остаются две фундаментальные проблемы! Повторюсь, что проблема class="big small"
по-прежнему не решена. Можно реализовать как бы защищённые классы, если добавить в кодовую базу ещё больше инструментария, чтобы гарантировать, что только один компонент использует один файл CSS-модуля, но это решение обладает всеми недостатками крупной технологии: нужна ещё куча инструментария.
Кроме того, CSS-модули полностью устраняют возможность кэширования CSS вне рамок одного развёртывания. Единственный способ кэширования CSS в такой ситуации — это сделать преобразование имён классов детерминированным, из-за чего теряется сам смысл использования хэшей — без дисциплины разработки (сложно масштабируемой на большие команды) разработчик может жёстко прописывать хэшированные имена классов в своём HTML.
▍ Проблема всех этих решений
Основная проблема всех этих решений заключается в том, что они ставят в центр свойство class
как единственный способ описания состояния объекта. Будучи списком произвольных строк, классы не имеют ключей и значений, приватных состояний, сложных типов (это ещё и означает очень ограниченную поддержку IDE). И чтобы сделать их хотя быть чуть более удобными, приходится пользоваться специальными DSL наподобие BEM. Мы постоянно пытаемся реализовать параметры в виде Set<string>
, хотя на самом деле нам нужно Map<string, T>
.
Решение всех этих проблем
Я утверждаю, что современная веб-разработка предоставляет нам все средства, чтобы отказаться от имён классов и реализовать нечто гораздо более надёжное; для этого достаточно внести довольно простые изменения:
▍ Атрибуты
Атрибуты позволяют нам параметризировать компонент при помощи описания «ключ-значение», что очень похоже на Map<string, T>
. У браузеров есть куча функций селекторов для парсинга значений атрибута. Если взять наш пример с Card, весь CSS можно выразить так:
.Card { /* ... */ }
.Card[data-size=big] { width: 100%; }
.Card[data-size=medium] { width: 50%; }
.Card[data-size=small] { width: 25%; }
.Card[data-align=left] { text-align: left; }
.Card[data-align=right] { text-align: right; }
.Card[data-align=center] { text-align: center; }
HTML-атрибуты можно выразить только один раз, то есть <div data-size="big" data-size="small">
будет соответствовать только data-size=big
. Это решает проблему инвариантов, на что неспособны другие решения.
Это может показаться похожим на BEM и обладает многими его преимуществами. При создании CSS они определённо похожи, но моё предложение демонстрирует свои достоинства, когда дело доходит до создания HTML — ведь тогда становится гораздо проще дискретно различать каждое из состояний:
<div class="Card" data-size="big" data-align="center"></div>
Кроме того, становится гораздо проще сделать значения динамическими при помощи JS:
function changeCardSize(card, newSize: 'big' | 'small' | 'medium') {
card.setAttribute('data-size', newSize)
}
Префикс data-
может быть немного неуправляемым, но он обеспечивает широчайшую совместимость с инструментами и фреймворками. Использование атрибутов без какого-либо пространства имён может быть немного опасным, ведь мы рискуем случайно переписать глобальные атрибуты HTML. Если имя атрибута содержит дефис, то это должно быть достаточно безопасно. Например, можно изобрести своё собственное пространство имён для работы с параметрами CSS, что повышает читаемость:
.Card[my-align=left] { text-align: left; }
Это даёт и другие осязаемые преимущества. Селекторы атрибутов наподобие [attr~"val"]
позволяют работать со значением так, как будто оно является списком. Это может быть полезным, если вам нужна гибкость в стилизации частей компонента, например, применение стиля к одной или нескольким сторонам границы:
.Card { border: 10px solid var(--brand-color) }
.Card[data-border-collapse~="top"] { border-top: 0 }
.Card[data-border-collapse~="right"] { border-right: 0 }
.Card[data-border-collapse~="bottom"] { border-bottom: 0 }
.Card[data-border-collapse~="left"] { border-left: 0 }
<div class="card" data-border-collapse="left right"></div>
Готовая к выпуску спецификация CSS Values 5 также позволяет атрибутам проникать в свойства CSS, подобно переменным CSS. В системах дизайна применяются различные уровни размеров, абстрагирующие значения в пикселях (например, pad-size
может иметь значение от 1 до 6, где каждое число обозначает величину от 3px до 18px):
<div class="card" pad-size="2"></div>
.Card {
/* Берём атрибут `pad-size` и приводим его к значению `px`. */
/* Если оно отсутствует, откатываемся к 1px */
--padding-size: attr(pad-size px, 1px)
/* Делаем размер padding кратным 3px */
--padding-px: calc(var(--padding-size) * 3px);
padding: var(--padding-px);
}
Разумеется, при достаточном объёме типизации эту проблему можно решить уже сегодня, по крайней мере, для ограниченных значений (которые выражают большинство систем дизайна):
.Card {
--padding-size: 1;
--padding-px: calc(var(--padding-size) * 3px)
padding: var(--padding-px);
}
.Card[pad-size=2] { --padding-size: 2 }
.Card[pad-size=3] { --padding-size: 3 }
.Card[pad-size=4] { --padding-size: 4 }
.Card[pad-size=5] { --padding-size: 5 }
.Card[pad-size=6] { --padding-size: 6 }
Согласен, это достаточно большой объём бойлерплейта, но как временное решение подойдёт.
▍ Собственные имена тегов
Если вы дочитали до этого места, то, вероятно, уже кричите в монитор: «Автор, ты клоун, ты ведь по-прежнему использует имена классов! .Card
— это класс!» Ну, тут всё просто. HTML5 позволяет использовать собственные теги, любой тег, не распознанный парсером — это неизвестный элемент, который можно свободно стилизовать как угодно. Неизвестные теги не имеют стандартной стилизации user-agent: по умолчанию они ведут себя как <span>
. Это полезно, потому что мы можем выразить компонент при помощи литерального имени тега вместо class
:
<my-card data-size="big"></my-card>
my-card { /* ... */ }
my-card[data-size="big"] { width: 100% }
Эти элементы представляют собой абсолютно валидный синтаксис HTML5 и не требуют никаких дополнительных определений, никакого специального DTD или метатега, никакого JavaScript. Как и в случае с атрибутами, хорошей идеей будет добавление -
, что соответствует спецификации и предотвращает случайное переписывание. Кроме того, использование -
также позволит использовать ещё более мощные инструменты наподобие Custom Element Definitions, что обеспечивает возможность интерактивности на JavaScript. С Custom Elements можно использовать собственные состояния CSS, благодаря чему мы переходим на новый уровень возможностей:
▍ Custom State (собственные псевдоселекторы)
Если у ваших компонентов есть любой уровень интерактивности, то им может потребоваться изменение стиля из-за какого-нибудь изменения состояния. Возможно, вам знакомы элементы input[type=checkbox]
, имеющие псевдокласс :checked
, позволяющий CSS связываться с их внутренним состоянием. В случае нашего примера с Card мы хотели добавить состояние loading, чтобы можно было декорировать его в CSS; дополнить его анимированными спиннерами, в то время как полностью загруженная card может отображаться с зелёной рамкой. Добавив немного JavaScript, можно определить тег как Custom Elements, взять объект внутреннего состояния и манипулировать им для представления этого как собственных псевдоселекторов для собственного тега:
customElements.define('my-card', class extends HTMLElement {
#internal = this.attachInternals()
async connectedCallback() {
this.#internal.states.add('loading')
await fetchData()
this.#internal.states.delete('loading')
this.#internal.states.add('loaded')
}
})
my-card:state(loading) { background: url(./spinner.svg) }
my-card:state(loaded) { border: 2px solid green }
Custom states могут быть очень мощными, потому что они позволяют элементу представить себя как модальность с определёнными условиями без изменения его разметки, то есть элемент может сохранить полный контроль за своими состояниями, и их нельзя будет контролировать снаружи (если только элемент не разрешит это). Можно даже назвать это внутренним состоянием. Они поддерживаются всеми современными браузерами, а для старых или необычных браузеров создан полифил (хотя он имеет некоторые тонкости).
Заключение
Существует множество замечательных способов выражения состояний и параметров компонента без необходимости привязки их к архаичной системе наподобие атрибута class
. Сегодня у нас есть механизмы для его замены, нам просто нужно освободиться от собственных оков. Будущие стандарты позволяют нам выражать свои идеи новыми способами.
Автор: ru_vds