Казалось бы такая простая тема как написание css-классов не должна быть проблемой, однако я встречал довольно много проектов, где допускаются ошибки, пишутся непроизводительные велосипеде, что приводит к ошибкам на продакшене и плохо читаемому коду.
Где проблема актуальна? В экосистеме React, и где мы пользуемся замечательным синтаксисом под названием JSX.
Согласно данным NPM Trends, если мы сложим количество использований двух популярных библиотек clsx
и classnames
для помощи в написании классов, мы увидим, что на данный момент около 300 тысяч проектов не имеют этих библиотек в качестве зависимостей. Добавив сюда 1 миллион 100 тысяч проектов на библиотеке Preact, получим около 1,5 миллиона проектов, где ни одна из этих двух библиотек не используется.
Также бывает, что библиотека есть, просто не используется разработчиками.
![react vs classnames vs clsx react vs classnames vs clsx](https://habrastorage.org/getpro/habr/upload_files/4d2/ffc/79e/4d2ffc79e50551cd2dbc6402e9e6d84c.png)
Почему именно эти библиотеки?
Они добавляют удобство за счет продуманного синтаксиса, который прекрасно подходит для решения большинства задач, связанных с множеством классов, которые мы задаем согласно определенным условиям.
Конечно, Javascript предлагает множество вариантов решения такой проблемы. Например, Template Literals или использование массива с последующим join
.
Но давайте для начала рассмотрим примеры кода.
Вот, простой компонент, который должен иметь условный класс в зависимости от определенных условий:
const Button = (props) => (
<button className={`btn ${props.pressed && 'btn-pressed'}`}>
{props.children}
</button>
)
Терпимо? В целом да, если не смотреть на феерию скобочек и кавычек в конце '}`}>
Однако данный код зарендерит следующее в зависимости от значения pressed
:
<!--- pressed: true --->
<button class="btn btn-pressed">Button</button>
<!--- pressed: false --->
<button class="btn false">Button</button>
<!--- pressed: undefined --->
<button class="btn undefined">Button</button>
Окей. Нехорошо. Давайте попробуем тернарный оператор:
const Button = (props) => (
<button className={`btn ${props.pressed ? 'btn-pressed' : ''}`}>
{props.children}
</button>
)
и... получаем лишний пробел в конце:
<button class="btn btn-pressed">Button</button>
<button class="btn ">Button</button>
Это уже не так критично, однако, давайте рассмотрим пример из реального мира, путем усложнения количества свойств, а также добавим возможность передавать класс с помощью props:
const Card = (props) => {
const { className, elevated, outlined } = props;
return (
<div className={`card ${className ? className : ''} ${outlined ? 'card-outlined' : ''} ${elevated ? 'card-elevated' : ''}}>
{props.children}
</div>
)
}
Конечно, можно использовать промежуточные переменные, использовать массив и метод join(' ')
, иначе у нас появятся двойные или тройные пробелы. Или добавить очередной webpack-плагин, который бы это исправил... или... просто использовать библиотеку:
import clsx from 'clsx'
const Card = (props) => {
const { className, elevated, outlined } = props;
return (
<div className={clsx('card', className, {
'card-outlined': outlined,
'card-elevated': elevated,
})}>
{props.children}
</div>
)
}
В данном случае эта библиотека добавит вам 228 дополнительных байт, что будет ничем не хуже самописного велосипеда. А благодаря популярности и унифицированному синтаксу, который работает в этих обоих библиотеках, это больший выигрыш в отношении поддержки такого кода.
Как результат мы получили более читаемый код, который легче поддерживать, и который создает аккуратный html без случайных символов и слов.
Внимательный читатель заметит, что использовать глобальные css-классы нынче моветон, и будет прав. Если вы используете css-modules или JSS подход, это намного повышает надежность продуциемого кода, искореняя потенциальные конфликты стилей.
import clsx from 'clsx'
import classes from './index.modules.css'
const Card = (props) => {
const { className, elevated, outlined } = props;
return (
<div className={clsx(classes.root, className, {
// получается все так же чисто и аккуратно
[classes.outlined]: outlined,
[classes.elevated]: elevated,
})}>
{props.children}
</div>
)
}
Так какую библиотеку использовать? clsx или classnames
Если вы введете этот вопрос в Google, то возможно получите такой же ответ как и я:
![Просто используй clsx Просто используй clsx](https://habrastorage.org/getpro/habr/upload_files/512/b37/a6a/512b37a6a8626212c6ffaa4551376e90.png)
Из статьи "Вы не знаете библиотеку classnames" Арека Нао вы сможете узнать, что библиотека classnames
имеет более богатый функционал, которым... никто не пользуется. А синтаксис библиотеки clsx
такой же, при том, что она быстрее и легче (правильно: функционала-то меньше).
Причина в высокой скорости библиотеки -- ее простота и использование for
, while
циклов, конкатенция строк вместо операций над массивами. Исходный код на GitHub.
Позвольте, но есть же альтернатива
Конечно же есть. Один из паттернов, про который все забыли -- это так называемые data-
атрибуты. Ничто не мешает заменить лапшу из css-классов btn btn-elevated btn-large
на data-variant="elevated"
data-size="large"
.
А затем, написать подобный css:
.button {}
.button[data-size="small"] {}
.button[data-size="large"] {}
.button[data-variant="elevated"] {}
.button:disabled,
.button[data-state="disabled"] {
/** Последний вариант иногда нужен,
чтобы иметь возможность кликнуть по кнопке
для получения определенного фидбека
*/
}
К сожалению, у этого подхода на самом деле один жирный минус. И нет, это не производительность браузера при поиске селекторов. Так никто не делает. А это значит отсутствие привычных инструментов: минификация css-классов доступна из коробки, а здесь придется что-то придумывать. Неудобный синтаксис, если мы используем JSS решения с object нотацией.
Напишите в комментариях, что вы думаете по поводу такого подхода?
Бонус для разработчиков на Preact
Одной из киллер-фич этой библиотеки на заре была возможность использования ключевого слова class
для использования его в JSX. Я помню, как способ задания css с помощью className
был камнем преткновения для множества разработчиков, которым показали React и JSX. Однако... время показало, что className
удобнее своей универсальностью. И сейчас я покажу почему:
В примере сверху мы разбирали вариант, где в компонент передавался параметр className
, который обычно добавляется к корневому DOM-элементу компонента.
И если мы еще можем передавать class
внутри JSX разметки, использовать этот ключ при декомпозиции объекта props
или указывать его в интерфейсах Typescript уже не получится никак. Как результат, на моей практике я сталкивался с таким зоопарком в наименовании: customClass
, parentClass
, rootClass
, mainClass
, и так далее. Как результат, вместо упрощения мы получили усложнение и неконсистентность.
Поэтому во всех Preact проектах я использую привычное всем className
вместе с набором совместимости preact/compat
.
Бонус к бонусу или ремарка о статическом кодоанализе
Если что-то можно автоматизировать ценой пары кликов, оно должно быть автоматизировано.
Для того, чтобы запретить эти нестандартные атрибуты в JSX можно сконфигурировать очень популярный плагин для eslint следующим образом:
"react/forbid-component-props": ["on", {
"forbid": ["class", "customClass", "parentClass"]
}]
Мораль сей басни такова
Лишняя пара-тройка килобайт всегда стоит того, чтобы ваш код был более читаемым, поддерживаемым и содержал меньше ошибок. А порой, такая библиотека как clsx
может оказаться быстрее вашей имплементации.
Комментарии (25)
sinneren
03.02.2022 14:15+2Боже, ну и жесть, хорошо что в vue есть computed, где можно нормально построить строку и выдать всё в переменной
Carduelis Автор
03.02.2022 15:03+1А в чем "нормально построить строку" внутри
computed
будет фундаментально отличаться от приведенных выше примеров?Не могли бы вы привести пример того, как вы бы использовали
computed
в случае приведенного примера из статьи?SergeyPeskov
03.02.2022 16:34+2тут дело не в computed, а том что vue сам умеет формировать строку с названиями классов из объектов и массивов(примерно так же как и в clsx).
Carduelis Автор
03.02.2022 16:34Действительно. Полезная штуковина тогда. Было бы здорово иметь этот функционал встроенным в Реакт
Spaubleit
04.02.2022 14:05Это всё классно, пока класс не нужно передаать дальше. И приходится всё равно подключатаь clsx в впроект.
faiwer
03.02.2022 16:58Во Vue в целом очень много синтаксического сахара, в отличии от JSX. Очень комфортный шаблонизатор. А как сейчас в нём с поддержкой TSX? Все эти
v-
и прочие аттрибуты подружили с проверкой типов?SergeyPeskov
03.02.2022 18:14+1кажется нет, будет достаточно сложно добавить проверку типов для аргументов и модификаторов директив.
sinneren
04.02.2022 10:42не работаю с tsx...
Есть, конечно, и минусы. во 2 версии v-for работает раньше v-if, что очень тупо. Но в 3, говорят, всё стало прекрасно, и сделали небольшой разворот в сторону хуков реакта
Sadler
03.02.2022 15:31+1Когда не знал о classnames, написал свой костыль, которым благополучно и пользовался. Собственно, переход на classnames прошёл с помощью простой автозамены, т.к. синтаксис тот же самый.
Data-атрибуты в своей практике использовал в случаях, когда один и тот же атрибут необходимо таскать очень часто по всему приложению. Например, при реализации кастомного 2d-фокуса, управляемого стрелками на клавиатуре. Ну, и эти дата-атрибуты никогда не задавались ручками, сугубо через HoC. Так мы не теряем типизацию и линтинг.
faiwer
03.02.2022 17:00Ну, и эти дата-атрибуты никогда не задавались ручками, сугубо через HoC. Так мы не теряем типизацию и линтинг
А можно с этого момента поподробнее. Не совсем понятно как HoC решил проблемы с типизацией и линтингом data-аттрибутов.
Sadler
03.02.2022 17:20HoC внедряет prop'ы, с помощью которых мы работаем с функциональностью, которая под капотом внутри HoC разворачивается в работу с data-атрибутами. В моём случае был проп "focus", указывающий на соседние элементы в графе, который может либо получаться из data-атрибута, либо быть задан программистом из jsx (и записан в data-атрибут). Сами эти пропы элементарно типизируются средствами TypeScript. По типизации в HoC, по-моему, даже на хабре статья была.
faiwer
03.02.2022 17:25А, теперь понятно. Не, типизация HoC не интересна, там нет ничего особенного (кроме ломающихся генериков). Интересна была именно типизация data-аттрибутов. Я думал у вас что-то связанно именно с этим.
Carduelis Автор
04.02.2022 14:05+1Но по идее мы можем глобально расширить тип HTMLAttributes, либо же создать новый тип-дженерик, который принимал бы на вход параметры для data-атрибутов.
Вот пример (может кому пригодится), с глобальной модификацией:
faiwer
04.02.2022 14:14Спасибо за наводку. Способ немного брутальный, и не отрезает возможности передавать любую пургу (вроде
<div b-b={true}/>
), но уже позволяет лимитировать какие-то конкретные глобальные аттрибуты.
nuclearland
04.02.2022 13:45А я лично использую styled-components, фактически полностью уходя от классов. Но этот способ включает другие проблемы, но сейчас не об этом)
Carduelis Автор
04.02.2022 13:49Одно время я тоже ими пользовался, даже перевел статью про выход пятой версии. Однако, не очень гладкая интеграция с Mui четвертой версии заставила перейти на JSS объектный синтаксис. А сейчас, в пятой версии этой великолепной библиотеки, разработчики взяли emotion как решение по умолчанию.
Возможно, стоит задуматься о возврате к простому kebab-case синтаксису css без upperCase девиаций =)
faiwer
Я так делаю. Многие годы. Из недостатков — никаких linter-ов, которые будут гарантировать что этот аттрибут присутствует в стилях. Что не сделал опечатку. Можно конечно самому написать, но всё как-то некогда было.
Ещё из недостатков — проброс таких аттрибутов ниже по React древу в качестве props. Я имею ввиду типизация этого добра на уровне TS… мягко говоря хромает.
В общем и целом думаю об этом хорошо. Посему и использую. Сильно упрощает код.
Carduelis Автор
Все верно. И я так использовал во времена, когда прямой доступ к DOM был поведением по умолчанию, а Virtual DOM еще не популяризировали.
Свойство
HTMLElement.dataset
вызывало чувства красоты, удобства и каноничности.Я был удивлен, когда в React и JSX не оказалось инструмента работы с этим API. Было бы здорово иметь
dataset
наряду сstate
иprops
faiwer
Да в нём вообще почти ничего не оказалось. Ни поддержки протокола итерации, ни условий\вветвлений. Даже
key
и тот сбоку приделан. Проref
вообще молчу. Сdata-*
аттрибутами вообще смешно вышло, любое свойство с дефисом в TSX валидное, хехе.Alexandroppolus
там же есть стандартные js-ные циклы, ифы и тернарники
а что с ним?
faiwer
Ага, есть. Не в JSX а в JS. В "X" ничего кроме тегов и аттрибутов нет. Собственно из-за этого мы пишем в коде то, за что раньше били по рукам, а теперь единственная рабочая альтернатива. Уже лет 7 пишу на React и мне до сих пор тошно от JSX-лапши со всякими
{arr.map(() =>
. И эти условия видаvar && ...
,var ? ... : ...
. Бррр.Тут претензия не к JS, а именно к JSX. Сразу не продумали сахара, а потом уже ничего не стали менять.
Начинаются всякие бубны и пляски с forwardRef (пример: цепочки из HoC), и привет проблемы с типами. В частности когда нужна
useImperativeHandle
. В общем я давно взял за правило, на уровне компонент не использоватьref
, а делать явныйprop
onRef
, которые не прибит к React сбоку.Carduelis Автор
Да, вот что меня действительно раздражает, так этот момент, когда компонент написан, но тут возникает задача пробросить ref, и начинается синтаксический ад с forwardRef и Typescript. Пока все скобочки в нужном порядке расставишь, все желание кодить пропадает =)
faiwer
Ага. Да к чёрту этот
ref
, если честно. Как иuseImperativeHandle
. Всё тоже самое явным образом черезprop
делается тривиально и без лишних телодвижений.Alexandroppolus
Я не пробовал Vue и Ангуляр, но часто слышу о проблемах с типизацией в них, и подозреваю, что это связано с сахаром в шаблонах.