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

Где проблема актуальна? В экосистеме React, и где мы пользуемся замечательным синтаксисом под названием JSX.

Согласно данным NPM Trends, если мы сложим количество использований двух популярных библиотек clsx и classnames для помощи в написании классов, мы увидим, что на данный момент около 300 тысяч проектов не имеют этих библиотек в качестве зависимостей. Добавив сюда 1 миллион 100 тысяч проектов на библиотеке Preact, получим около 1,5 миллиона проектов, где ни одна из этих двух библиотек не используется.

Также бывает, что библиотека есть, просто не используется разработчиками.

react vs classnames vs clsx
react vs classnames vs clsx

Почему именно эти библиотеки?

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

Конечно, 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

Из статьи "Вы не знаете библиотеку 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)


  1. faiwer
    03.02.2022 13:07
    +1

    И нет, это не производительность браузера при поиске селекторов. Так никто не делает

    Я так делаю. Многие годы. Из недостатков — никаких linter-ов, которые будут гарантировать что этот аттрибут присутствует в стилях. Что не сделал опечатку. Можно конечно самому написать, но всё как-то некогда было.


    Ещё из недостатков — проброс таких аттрибутов ниже по React древу в качестве props. Я имею ввиду типизация этого добра на уровне TS… мягко говоря хромает.


    Напишите в комментариях, что вы думаете по поводу такого подхода?

    В общем и целом думаю об этом хорошо. Посему и использую. Сильно упрощает код.


    1. Carduelis Автор
      03.02.2022 13:18

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

      Свойство HTMLElement.dataset вызывало чувства красоты, удобства и каноничности.

      Я был удивлен, когда в React и JSX не оказалось инструмента работы с этим API. Было бы здорово иметь dataset наряду с state и props


      1. faiwer
        03.02.2022 13:25
        +2

        JSX не оказалось инструмента работы ...

        Да в нём вообще почти ничего не оказалось. Ни поддержки протокола итерации, ни условий\вветвлений. Даже key и тот сбоку приделан. Про ref вообще молчу. С data-* аттрибутами вообще смешно вышло, любое свойство с дефисом в TSX валидное, хехе.


        1. Alexandroppolus
          03.02.2022 16:49

          Ни поддержки протокола итерации, ни условий\вветвлений.

          там же есть стандартные js-ные циклы, ифы и тернарники

          Про ref вообще молчу.

          а что с ним?


          1. faiwer
            03.02.2022 16:56
            +2

            там же есть стандартные js-ные циклы, ифы и тернарники

            Ага, есть. Не в JSX а в JS. В "X" ничего кроме тегов и аттрибутов нет. Собственно из-за этого мы пишем в коде то, за что раньше били по рукам, а теперь единственная рабочая альтернатива. Уже лет 7 пишу на React и мне до сих пор тошно от JSX-лапши со всякими {arr.map(() =>. И эти условия вида var && ..., var ? ... : .... Бррр.


            Тут претензия не к JS, а именно к JSX. Сразу не продумали сахара, а потом уже ничего не стали менять.


            а что с ним?

            Начинаются всякие бубны и пляски с forwardRef (пример: цепочки из HoC), и привет проблемы с типами. В частности когда нужна useImperativeHandle. В общем я давно взял за правило, на уровне компонент не использовать ref, а делать явный prop onRef, которые не прибит к React сбоку.


            1. Carduelis Автор
              04.02.2022 14:08

              Да, вот что меня действительно раздражает, так этот момент, когда компонент написан, но тут возникает задача пробросить ref, и начинается синтаксический ад с forwardRef и Typescript. Пока все скобочки в нужном порядке расставишь, все желание кодить пропадает =)


              1. faiwer
                04.02.2022 14:15

                Ага. Да к чёрту этот ref, если честно. Как и useImperativeHandle. Всё тоже самое явным образом через prop делается тривиально и без лишних телодвижений.


            1. Alexandroppolus
              04.02.2022 14:58

              Тут претензия не к JS, а именно к JSX. Сразу не продумали сахара

              Я не пробовал Vue и Ангуляр, но часто слышу о проблемах с типизацией в них, и подозреваю, что это связано с сахаром в шаблонах.


  1. sinneren
    03.02.2022 14:15
    +2

    Боже, ну и жесть, хорошо что в vue есть computed, где можно нормально построить строку и выдать всё в переменной


    1. Carduelis Автор
      03.02.2022 15:03
      +1

      А в чем "нормально построить строку" внутри computed будет фундаментально отличаться от приведенных выше примеров?

      Не могли бы вы привести пример того, как вы бы использовали computed в случае приведенного примера из статьи?


      1. SergeyPeskov
        03.02.2022 16:34
        +2

        тут дело не в computed, а том что vue сам умеет формировать строку с названиями классов из объектов и массивов(примерно так же как и в clsx).


        1. Carduelis Автор
          03.02.2022 16:34

          Действительно. Полезная штуковина тогда. Было бы здорово иметь этот функционал встроенным в Реакт


        1. Spaubleit
          04.02.2022 14:05

          Это всё классно, пока класс не нужно передаать дальше. И приходится всё равно подключатаь clsx в впроект.


    1. faiwer
      03.02.2022 16:58

      Во Vue в целом очень много синтаксического сахара, в отличии от JSX. Очень комфортный шаблонизатор. А как сейчас в нём с поддержкой TSX? Все эти v- и прочие аттрибуты подружили с проверкой типов?


      1. SergeyPeskov
        03.02.2022 18:14
        +1

        кажется нет, будет достаточно сложно добавить проверку типов для аргументов и модификаторов директив.


      1. sinneren
        04.02.2022 10:42

        не работаю с tsx...

        Есть, конечно, и минусы. во 2 версии v-for работает раньше v-if, что очень тупо. Но в 3, говорят, всё стало прекрасно, и сделали небольшой разворот в сторону хуков реакта


  1. Sadler
    03.02.2022 15:31
    +1

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

    Data-атрибуты в своей практике использовал в случаях, когда один и тот же атрибут необходимо таскать очень часто по всему приложению. Например, при реализации кастомного 2d-фокуса, управляемого стрелками на клавиатуре. Ну, и эти дата-атрибуты никогда не задавались ручками, сугубо через HoC. Так мы не теряем типизацию и линтинг.


    1. faiwer
      03.02.2022 17:00

      Ну, и эти дата-атрибуты никогда не задавались ручками, сугубо через HoC. Так мы не теряем типизацию и линтинг

      А можно с этого момента поподробнее. Не совсем понятно как HoC решил проблемы с типизацией и линтингом data-аттрибутов.


      1. Sadler
        03.02.2022 17:20

        HoC внедряет prop'ы, с помощью которых мы работаем с функциональностью, которая под капотом внутри HoC разворачивается в работу с data-атрибутами. В моём случае был проп "focus", указывающий на соседние элементы в графе, который может либо получаться из data-атрибута, либо быть задан программистом из jsx (и записан в data-атрибут). Сами эти пропы элементарно типизируются средствами TypeScript. По типизации в HoC, по-моему, даже на хабре статья была.


        1. faiwer
          03.02.2022 17:25

          А, теперь понятно. Не, типизация HoC не интересна, там нет ничего особенного (кроме ломающихся генериков). Интересна была именно типизация data-аттрибутов. Я думал у вас что-то связанно именно с этим.


          1. Carduelis Автор
            04.02.2022 14:05
            +1

            Но по идее мы можем глобально расширить тип HTMLAttributes, либо же создать новый тип-дженерик, который принимал бы на вход параметры для data-атрибутов.

            Вот пример (может кому пригодится), с глобальной модификацией:


            1. faiwer
              04.02.2022 14:14

              Спасибо за наводку. Способ немного брутальный, и не отрезает возможности передавать любую пургу (вроде <div b-b={true}/>), но уже позволяет лимитировать какие-то конкретные глобальные аттрибуты.


  1. eeeMan
    03.02.2022 19:06
    -2

    нормальные люди отделяют стили от скрипта


  1. nuclearland
    04.02.2022 13:45

    А я лично использую styled-components, фактически полностью уходя от классов. Но этот способ включает другие проблемы, но сейчас не об этом)


    1. Carduelis Автор
      04.02.2022 13:49

      Одно время я тоже ими пользовался, даже перевел статью про выход пятой версии. Однако, не очень гладкая интеграция с Mui четвертой версии заставила перейти на JSS объектный синтаксис. А сейчас, в пятой версии этой великолепной библиотеки, разработчики взяли emotion как решение по умолчанию.

      Возможно, стоит задуматься о возврате к простому kebab-case синтаксису css без upperCase девиаций =)