Привет, Хабр! Меня зовут Павел, и я руковожу frontend-направлением в ЕВРАЗе. В рамках цифровой трансформации компании моя команда разрабатывает огромное количество интерфейсов. Только с 2019 года их число превысило 20, и у каждого свой уникальный UX/UI. Несмотря на все разнообразие, проекты являются частью общей дизайн-системы, где повторяются те или иные элементы.
Но отдельные задачи требуют особого подхода, что приводит к локальным изменениям по стилям компонентов. И вот тут мы столкнулись с некоторыми проблемами, разрешив которые, получили идеальный UI Kit.
Как мы строили UI Kit
UI Kit включил не только атомарные компоненты, такие как кнопки, поля ввода, селекторы, но и комплексные – вроде наборов графиков, фильтров, панелей.
Поскольку проект должен содержать весь возможный набор компонентов, мы сформулировали к нему ряд требований:
Полная или частичная кастомизация компонентов. Интерфейсы могут быть использованы на специфичном оборудовании, а значит, должна быть возможность отходить от общей дизайн-системы. Например, сократить отступы, чтобы на экран поместилось больше информации, скорректировать палитру цветов для отдельных компонентов, так как на целевом оборудовании могут быть проблемы с цветопередачей и т. п.
Слабая связанность компонентов или полное ее отсутствие с целью оптимизации размера загружаемого бандла.
Инкапсуляция компонента. Разработчик не должен вникать в реализацию компонента, чтобы внести какие-либо изменения. Компонент должен предоставлять все необходимые интерфейсы, которые должны быть задокументированы.
Пункты 1 и 3 оказались взаимосвязаны, и мы столкнулись с проблемой для компонентов со сложной структурой – либо не даем в достаточной мере стилизовать компонент, либо нарушаем инкапсуляцию.
Атомарные компоненты стилизуются очень просто. Например, данный компонент кнопки не имеет дочерних элементов, достаточно добавить значение атрибута className
к текущему списку классов тега button
и можно стилизовать компонент как душе угодно из родительского компонента.
Button.jsx
function Button({ className }) {
return (
<button
className={joinClassNames('button', className)} ...
>
...
</button>
);
}
А вот с комплексными компонентами не все так прозаично, здесь порой требуется стилизовать элемент, к className
которого нет прямого доступа. А так как подход из примера выше должен остаться, поскольку корневой элемент так или иначе стилизовать приходится, то атрибут className
уже задействован.
Рассмотрим фрагмент компонента переключателя:
Switcher.jsx
function Switcher(className) {
...
return (
<label className={joinClassNames('switcher', className)}>
<input type="checkbox" className="switcher__input" ...>
<div className="switcher__marker" />
<label>
);
}
Switcher.css
.switcher {
font-size: 16px;
}
.switcher__input {
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
}
.switcher__marker {
background-color: #eee;
border-radius: 0.625em;
height: 1.125em;
position: relative;
width: 2em;
}
.switcher__marker::before {
background-color: white;
border-radius: 50%;
content: '';
height: calc(1.125em - 0.25em);
left: 0.125em;
position: absolute;
top: 0.125em;
width: calc(1.125em - 0.25em);
}
:checked + .switcher__marker {
background-color: #ae4;
}
:checked + .switcher__marker::before {
left: 100%;
transform: translateX(-1em);
}
Как и в первом примере, для корневого элемента применяется className из пропсов. Это позволит настраивать отображение компонента в связке с соседями и родителем: задать отступы, позиционирование, размер шрифта и т. д. А вот сам переключатель уже стилизовать просто так не получится.
Проблема: как стилизовать дочерние элементы в компоненте?
Решение 1
Первое, что приходит в голову, — это добавить атрибут для компонента, например, color.
<Switcher color="#334453" />
Этот способ соответствует требованиям выше: компонент можно стилизовать, и он остается инкапсулированным. Но если абстрагироваться и представить что-то посложнее, то парой атрибутов не обойтись.
По мере необходимости что-то менять количество таких атрибутов может вырасти, что усложнит сам компонент. Кроме того, это нарушает современные принципы HTML, ведь таблицы стилей как раз были придуманы для того, чтобы разделить разметку и внешний вид. Подход выше отправляет нас во времена HTML 3.
Записываем это как требование к компоненту «Не использовать стилизующие атрибуты».
Решение 2
Из легальных способов остается className
и style
.
Значение атрибута style
логичнее будет применить к корневому компоненту, как и className
, так как нужно как минимум управлять внешними отступами, позиционированием и поэтому для стилизации других элементов мы эти атрибуты не используем.
В таком случае классифицируем стиль по назначению, например, так:
<Switcher markerStyle={{backgroundColor: '#334453'}} />
Этот способ позволяет стилизовать элементы без каких-либо ограничений, однако нарушается инкапсуляция. Да, в явном виде разработчику не нужно заглядывать в сам компонент, чтобы посмотреть, к чему он еще может применить стили, но вся структура раскрывается в названиях стилей. Также количество таких атрибутов будет расти с количеством дочерних элементов.
Еще стоит понимать, что атрибут style имеет максимальный приоритет для применения стилей, поэтому контролировать порядок переопределения уже будет проблематично.
Записываем и этот способ как не подходящий.
Решение 3
Остается только способ с className, для которого можно воспользоваться вариантом от style, переняв все те же проблемы. Либо при добавлении стилей пользоваться составными селекторами.
<!-- usage -->
<Switcher className="some_class">
<!-- output -->
<label className="switcher some_class">...</label>
.some_class .switcher__marker {...}
/* или */
.some_class > .switcher__marker {...}
Таким образом, весь список классов нужно вынести в документацию к компоненту, и опять же подобный подход рушит всю инкапсуляцию компонента. В том числе становится бесполезным, если начать использовать CSS Modules или CSS In JS.
Решение 4
Использование провайдера. Этот способ в принципе соответствует требованиям, однако тащит дополнительные реализации. Применение компонентов идет всегда с оберткой, что усложняет родительский компонент. Плюс мы имеем два способа стилизации, один через js, один через css, что тоже не очень хорошо. Пометили как неподходящий.
Почему полная стилизация дочернего элемента — это плохая практика?
Прямой доступ к стилям дочернего компонента тоже не всегда хорошо,
реализация структуры компонента разнообразная, внешний вид может зависеть от многих свойств, изменение которых приведет к нарушению работы компонента. Например, это могут быть или анимации, или вычисленные размеры, или использование свойств display.
По нашей задумке, компонент должен быть стилизуем как обычный HTML-тег, а применение свойств, которые могут разрушить внешний вид, не должно оказывать эффекта.
И вот тут мы обратили внимание на кастомные свойства. Не то чтобы мы о них никогда не слышали, просто по неведомым причинам называли их CSS-переменными и пользовались ими как переменными из препроцессоров.
Что такое кастомные свойства?
Кастомные свойства — это CSS-свойства, которых нет в основной спецификации. Их определяет сам разработчик. В дальнейшем значения этих свойств можно применить к стандартным CSS-свойствам.
Синтаксис
/* Объявление */
--some-property-name: some-property-value;
/* Использование */
margin: var(--some-property-name);
Для того чтобы воспользоваться значением свойства, используется CSS-функция var.
Особенности css-свойств
CSS-свойства можно разделить на два вида: сквозные и локальные.
Значения сквозных свойств применяются к тем же свойствам дочерних элементов, как значение по умолчанию (наследуются). Например: font-size
, font-weight
.
Локальные свойства используются по месту и никем не наследуются. Например: margin
, padding
.
По умолчанию все кастомные свойства сквозные. Поэтому, чтобы избежать ненужных переопределений, весь набор свойств нужно инициализировать в корневом блоке.
function Switcher({className}) {
...
return (
<label className={['switcher', className].join(' ')}>
<input type="checkbox" className="switcher__input" ...>
<div className="switcher__marker" />
<label>
)
}
.switcher {
/* Инициализация кастомных свойств */
--background-color: #eee;
--background-color-active: #ae4;
--marker-background-color: white;
--marker-background-color-active: white;
/* =============================== */
font-size: 16px;
}
.switcher__marker {
background-color: var(--background-color);
border-radius: 0.625em;
height: 1.125em;
position: relative;
width: 2em;
}
.switcher__marker::before {
background-color: var(--marker-background-color);
border-radius: 50%;
content: '';
height: calc(1.125em - 0.25em);
left: 0.125em;
position: absolute;
top: 0.125em;
width: calc(1.125em - 0.25em);
}
:checked + .switcher__marker {
background-color: var(--background-color-active);
}
:checked + .switcher__marker::before {
background-color: var(--marker-background-color-active);
}
Предположим, что к стилям, указанным выше, доступа нет, и все, что знает разработчик, — это описанные в документации кастомные свойства. При импорте этого компонента в проект стилизация компонента будет происходить следующим образом:
<Switcher /> Исходный компонент
<Switcher className="switcher_with_new_style"> Компонент с новыми стилями
.switcher_with_new_style {
--background-color: silver;
--background-color-active: blue;
--marker-background-color-active: yellow;
}
Вот и все! Все, что не описано кастомными свойствами, будет приватным. Не нужно использовать сложные селекторы, которые, в том числе, раскрывают реализацию компонента. Также данный подход просто идеально сочетается с CSS Modules и CSS in JS.
Если в компоненте понадобится использование сквозных свойств, то в таком случае указывать кастомные свойства в основном блоке не нужно – достаточно сразу применить его к нужному CSS-свойству.
.switcher {
/* Инициализация кастомных свойств */
/* Убрали свойство --background-color */
--background-color-active: #ae4;
--marker-background-color: white;
--marker-background-color-active: white;
/* =============================== */
font-size: 16px;
}
.switcher__marker {
background-color: var(--background-color); /* Но здесь его оставили */
border-radius: 0.625em;
height: 1.125em;
position: relative;
width: 2em;
}
Теперь неважно, в каком из родительских компонентов мы укажем значение для свойства background-color, оно применяется ко всем компонентам switcher внутри этих блоков. Значение по умолчанию можно задать, передав второй аргумент в функцию var.
.switcher__marker {
background-color: var(--background-color, #eee);
...
}
Но остается еще одна проблема, связанная с именами классов. Даже при использовании БЭМ есть вероятность, что разработчик присвоит имя класса, которое используется для элемента блока, другому элементу. В таком случае применение CSS-модулей должно обеспечить полную инкапсуляцию компонента.
CSS-Modules для обеспечения полной инкапсуляции
Как использовать модули, особо объяснять не нужно. Для этого есть документация. Но один момент хочется прояснить. Это связано с глобальной стилизацией компонента.
Подключая компонент к интерфейсу, разработчик должен иметь возможность стилизовать абсолютно все компоненты в одном глобальном файле стилей. В противном случае придется в обязательном порядке к каждому компоненту добавлять дополнительный класс либо что-то придумывать с темой оформления. Соглашусь, подобный подход – дело вкуса, но такая необходимость возникала, поэтому проще задать какие-то параметры в глобальном файле стилей для всех компонентов сразу.
В таком случае организация файла стилей компонента следующая:
:global(.component_class_name) {
...
}
.element_class_name {
...
}
import styles from './Component.module.css';
function Component({className}) {
return (
<div className={['component_class_name', className].join(' ')}>
<div className={styles.element_class_name} />
</div>
)
}
:global
— сделает селектор глобальным и не будет применять трансформацию имени класса. Если в файле стиля компонента есть только глобальный селектор, то стили импортируются как обычно:
import './Component.module.css';
Именование свойств
С именованием пока не все так гладко, есть ряд рекомендаций, однако в конечном итоге все зависит от разработчика. Как правило, имена вырабатываются по мере использования UI Kit’а, на основе отзывов разработчиков и ревью.
Что в итоге?
А в итоге мы получили гибкий способ кастомизации компонентов из UI Kit. Избавились от «уродливых» конструкций в коде, оберток и провайдеров. Избавились от жесткой привязки к теме оформления, а сама тема стала выглядеть аккуратнее. Разработчики больше не хватаются за голову, когда заказчик просит «поджать отступы…». Благодаря кастомным свойствам появляется контроль, что можно стилизовать в компоненте, а что нельзя. CSS Modules позволяют закрыть доступ к внутренней реализации компонента. Таким образом, компонент становится мобильным, переиспользуемым и соответствующим принципам HTML + CSS.