Skip to main content

Вам (вероятно) не нужен CSS-in-JS

00:04:12:53

В студии PSI мы всегда ищем оптимальный баланс между современными инструментами и производительностью. Когда мы впервые попробовали библиотеки CSS-in-JS вроде Styled Components и Emotion, нас привлекла идея передачи значений и состояния напрямую в стили. Это замыкало цикл с концепцией React, где UI — это функция состояния.

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

Типы динамических стилей

Мы выделяем два основных типа динамической стилизации в React-компонентах:

  1. Значения: цвет, задержка, позиция — любое единичное значение для CSS-свойства
  2. Состояния: варианты кнопки, состояние загрузки — каждое со своим набором стилей

Традиционные подходы

Для сравнения мы рассмотрим SCSS (с BEM) и Styled Components — самые популярные способы стилизации в React. Мы не рассматриваем библиотеки, где CSS пишется как JavaScript-объекты — для этого есть отличные решения вроде Vanilla Extract. Наше решение для тех, кто любит писать CSS как CSS, но хочет лучшей интеграции с реактивностью компонентов.

Если вы знакомы с проблемой, переходите к решению.

Проблема с динамическими значениями

Традиционно в чистом CSS динамические значения передавались через встроенные стили:

jsx
function Button({ color, children }) {
  return (
    <button className="button" style={{ backgroundColor: color }}>
      {children}
    </button>
  );
}

Проблемы этого подхода:

  • Высокая специфичность — сложно переопределить
  • Стили разбросаны между CSS и JSX
  • Нет единого источника стилей

CSS-in-JS решал это через props:

jsx
function Button({ color, children }) {
  return <StyledButton color={color}>{children}</StyledButton>;
}

const StyledButton = styled.button`
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: ${props => props.color};
`;

Проблема с состояниями

В традиционном CSS использовалась конкатенация строк классов. С BEM это выглядело так:

jsx
function Button({ color, size, primary, children }) {
  return (
    <button
      className={['button', `button--${size}`, primary ? 'button--primary' : null]
        .filter(Boolean)
        .join(' ')}
      style={{ backgroundColor: color }}
    >
      {children}
    </button>
  );
}
scss
.button {
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;

  &--primary {
    background-color: $primary-color;
  }

  &--small { height: 30px; }
  &--medium { height: 40px; }
  &--large { height: 60px; }
}

SCSS выглядит чисто, но JSX превращается в кошмар с конкатенацией строк и тернарными операторами.

Styled Components тоже громоздкий:

jsx
const StyledButton = styled.button`
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;

  ${props => props.primary && css`
    background-color: $primary-color;
  `}

  ${props => props.size === 'small' && css`
    height: 30px;
  `}

  ${props => props.size === 'medium' && css`
    height: 40px;
  `}

  ${props => props.size === 'large' && css`
    height: 60px;
  `}
`;

Повторяющиеся функции для получения props делают код шумным и трудночитаемым.


Наш подход: чистый CSS

В студии PSI мы вернулись к чистому CSS с пользовательскими свойствами (CSS-переменными). Когда эти методы появились, поддержка браузеров была слабой. Сегодня она практически универсальна.

Мы наблюдаем тренд к платформенным стандартам — фреймворки вроде Remix и Deno придерживаются веб-стандартов, а не изобретают свои решения. То же происходит с CSS: нативные функции становятся лучше, чем препроцессоры и CSS-in-JS.

Вот как мы стилизуем React-компоненты. Технически мы используем postcss для поддержки будущих функций (вложенность, пользовательские медиа-запросы), но по мере роста поддержки браузерами эти инструменты постепенно исчезают.

Динамические значения

Элегантное решение — пользовательские свойства CSS в атрибуте style:

jsx
function Button({ color, children }) {
  return (
    <button className="button" style={{ '--color': color }}>
      {children}
    </button>
  );
}
css
.button {
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: var(--color);
}

Преимущества:

  • Нет проблем со специфичностью — свойство объявлено в CSS
  • Все стили в одном месте
  • Чистый и читаемый код

Это особенно удобно для сложных свойств вроде transform:

jsx
// Передаём только нужное значение, а не весь transform
function Button({ offset, children }) {
  return (
    <button className="button" style={{ '--offset': `${offset}px` }}>
      {children}
    </button>
  );
}
css
.button {
  border: 0;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;
  transform: translate3d(0, var(--offset), 0);
}

Состояния компонентов

Для состояний мы используем data-атрибуты. Это прекрасно сочетается с нативным синтаксисом вложенности CSS:

jsx
function Button({ color, size, primary, children }) {
  return (
    <button className="button" data-size={size} data-primary={primary}>
      {children}
    </button>
  );
}
css
.button {
  border: 0;
  border-radius: 4px;
  padding: 8px 12px;
  font-size: 14px;
  color: dimgrey;
  background-color: whitesmoke;

  &[data-primary='true'] {
    background-color: var(--colorPrimary);
  }

  &[data-size='small'] { height: 30px; }
  &[data-size='medium'] { height: 40px; }
  &[data-size='large'] { height: 60px; }
}

Попробуйте живой пример:

Почему это лучше:

  • Похоже на BEM, но без конкатенации строк
  • Гораздо читабельнее, чем функции Styled Components
  • Единственный компромисс — дополнительная специфичность атрибута

Сначала data-атрибуты для стилей могут показаться непривычными, но они решают проблему грязной конкатенации классов. Плюс это согласуется с тем, как мы стилизуем интерактивные состояния через aria-атрибуты:

css
.button {
  &[aria-pressed='true'] {
    background-color: gainsboro;
  }

  &[disabled] {
    opacity: 0.4;
  }
}

Структура стилизации

Нам нравится этот подход, потому что он создаёт чёткую структуру:

  • Класс стилизует базовый элемент
  • Атрибут стилизует состояние

Для изоляции стилей мы используем CSS Modules, который встроен в большинство React-фреймворков (Next.js, Create React App, Remix).

Заключение

Эти техники не требуют только чистого CSS — их можно комбинировать с CSS-in-JS или препроцессорами. Но с новыми функциями вроде нативной вложенности и относительных цветов необходимость в дополнительных инструментах снижается.

В студии PSI мы стилизуем весь наш сайт именно этими техниками. Если хотите увидеть, как это работает на реальных компонентах, изучите наш исходный код.


Итог: Современный CSS достаточно мощный, чтобы решать задачи динамической стилизации без тяжеловесных библиотек. Попробуйте наш подход в следующем проекте!