Skip to main content

Проблеми та рішення CSS-in-js

CSS-in-JS - підхід до стилізації компонентів, де CSS записується як JavaScript template literals або об'єкти прямо у файлах компонентів, а бібліотека генерує унікальні хешовані класи під час виконання.

Теорія

TL;DR

  • Аналогія: рецептна картка приклеєна до страви - стилі подорожують разом з компонентом і не виходять за його межі
  • Головний компроміс: динамічні стилі через пропси, але 10-50KB JS-рантайму і додаткове навантаження на кожен рендер
  • Три реальні проблеми: продуктивність рендерингу, розмір бандлу, SSR-гідратація
  • Правило вибору: динамічні стилі більше ніж ~20% застосунку - CSS-in-JS; переважно статична верстка - CSS Modules або Tailwind
  • Для SSR-застосунків: Linaria та Vanilla Extract витягують CSS під час збірки, без рантайм-витрат

Швидкий приклад

jsx
// styled-components: клас генерується під час виконання, скоп автоматичний import styled from 'styled-components'; const Button = styled.button` background: ${props => props.primary ? 'blue' : 'gray'}; color: white; padding: 10px; `; // <Button primary>Клік</Button> // Результат: <button class="sc-a-xyz123"> з <style> тегом у <head> // Ім'я класу - хеш стилів, жодного конфлікту з глобальним CSS

Бібліотека хешує template literal, вставляє правило через sheet.insertRule() і кешує результат. При повторному рендері з тими самими пропсами береться кешований хеш, DOM не чіпається.

Три проблеми

1. Продуктивність рендерингу

Styled-components та Emotion вставляють стилі в DOM під час виконання JS. Браузер парсить JS, запускає функції стилів, хешує результат і тільки тоді малює. У списку на 1000 елементів без мемоізації це означає 1000 окремих викликів insertRule() на кожен цикл рендерингу. На практиці таке падіння виглядає як деградація з 60fps до ~10fps на великих списках.

2. Розмір бандлу

Styled-components важить ~20KB gzipped у рантаймі. Tailwind після PurgeCSS - близько 10KB без рантайму взагалі. Для більшості застосунків різниця непомітна. Але для сторінок де швидкість завантаження вимірюється - лістингів продуктів або дашбордів на мобільних - вона має значення.

3. Помилки SSR-гідратації

Якщо стилі вставляються тільки на клієнті, сервер відсилає HTML без визначення класів. React гідратує, стилі з'являються - і користувач бачить flash of unstyled content (FOUC). Рішення є: ServerStyleSheet у styled-components, extractCritical у Emotion - але їх потрібно налаштовувати явно.

Коли використовувати

  • Застосунок з тематизацією і runtime-перемиканням кольорів - styled-components або Emotion
  • Статичний маркетинговий сайт або блог - CSS Modules
  • SSR-застосунок, де швидкість першого рендеру вимірюється - Linaria (zero-runtime)
  • Прототип для команди без глибоких знань CSS-архітектури - Tailwind

Порівняння підходів

АспектCSS-in-JS (runtime)CSS ModulesTailwind
СкопінгХешовані класи в рантайміЛокальні ID під час збіркиUtility-класи
Динамічні стиліНативно через пропсиПотрібні обхідні шляхиТільки arbitrary values
Розмір бандлуRuntime JS (~10-50KB)Zero runtime~10KB після purge
Налаштування SSRПотребує окремого конфігуПрацює за замовчуваннямПрацює за замовчуванням
Коли використовуватиДинамічні UI, темиСтатичні компонентиПрототипи, MVP

Як це працює всередині

Styled-components використовує Babel-плагін для виділення template literals під час парсингу. В рантаймі V8 виконує функцію стилів, хешує результат через внутрішній hashObject() і вставляє правила через CSSStyleSheet.insertRule(). StyleSheet Map виступає кешем - якщо пропси не змінилися (shallow-equal), хеш береться з кешу і DOM залишається незміненим. Для SSR ServerStyleSheet.collectStyles() збирає всі правила під час серверного рендерингу і вставляє їх у <head> до того, як клієнт гідратується.

Типові помилки

1. Генерація стилів у циклах без мемоізації

jsx
// Неправильно: новий хеш на кожен елемент, 1000 insertRule-викликів const ListItem = ({ id }) => { const style = css`color: hsl(${id % 360}, 70%, 50%);`; return <li css={style}>Item {id}</li>; }; // Правильно: хеш обчислюється раз на кожен унікальний id const ListItem = memo(({ id }) => { const style = useMemo( () => css`color: hsl(${id % 360}, 70%, 50%);`, [id] ); return <li css={style}>Item {id}</li>; });

Без useMemo кожен рендер батьківського компонента створює новий хеш і записує в DOM. Це найчастіша причина деградації продуктивності CSS-in-JS у продакшені.

2. Відсутня SSR-екстракція стилів

jsx
// Неправильно: стилі вставляються після гідратації, FOUC гарантований export default function App() { return <ThemeProvider theme={theme}><Page /></ThemeProvider>; } // Правильно: збираємо стилі на сервері (Next.js _document.js) const sheet = new ServerStyleSheet(); ctx.renderPage = () => originalRenderPage({ enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), }); // Стилі йдуть у <head> до гідратації клієнта

3. Пропси потрапляють у DOM

jsx
// Неправильно: React попереджає про невідомий атрибут, проп просочується в HTML const Box = styled.div` opacity: ${props => props.isVisible ? 1 : 0}; `; // Правильно: вказуємо styled-components не передавати цей проп далі const Box = styled.div.withConfig({ shouldForwardProp: (prop) => prop !== 'isVisible', })` opacity: ${props => props.isVisible ? 1 : 0}; `;

4. Runtime CSS-in-JS з React Server Components

Server Components виконуються без контексту браузерного JS. Styled-components та Emotion не можуть вставляти стилі там. Рішення - Linaria або Vanilla Extract: вони витягують CSS під час збірки і генерують статичні файли, які працюють у будь-якому середовищі.

Де зустрічається в реальних проєктах

  • Vercel dashboard: styled-components з ThemeProvider для перемикання теми за налаштуваннями користувача
  • Chakra UI: Emotion з shouldForwardProp для чистоти DOM
  • Remix: Linaria витягує CSS у статичні файли для SSR-роутів
  • Storybook: Stitches для design token пропсів у shared UI kit
  • Gatsby-блоги: Emotion з babel-plugin-emotion без рантайм-накладних

Питання на співбесіді

Q: Яка реальна вартість styled-components у віртуалізованому списку на 1000 елементів?
A: Сама бібліотека важить ~50KB gzipped. Реальна вартість - промахи кешу. Якщо стилі перераховуються на кожен рендер, виходить 1000 викликів insertRule() за один цикл малювання. React.memo і мемоізація стилів знижують це до нуля.

Q: Як CSS-in-JS вирішує проблему SSR-гідратації?
A: Сервер збирає всі стилі під час рендерингу через ServerStyleSheet.collectStyles() або extractCritical() і вставляє їх блоком <style> у <head>. На клієнті React порівнює пре-рендерений HTML. Без цього перше малювання відбувається без класів і виникає FOUC.

Q: Коли Tailwind виграє у CSS-in-JS за розміром бандлу?
A: Майже завжди для статичних застосунків: Tailwind після purge важить ~10KB без рантайму. Styled-components додає ~20KB JS, який повинен виконатись до появи стилів. У дуже динамічному застосунку з сотнями унікальних runtime-генерованих стилів CSS-in-JS може видати менший загальний CSS, бо невикористані правила просто не існують.

Q: Як розшарити кеш CSS-in-JS між мікрофронтендами в Module Federation?
A: Потрібно виставити StyleSheet.getInstance() як singleton через webpack provider. Без цього кожен мікрофронтенд ініціалізує власний кеш і дублює правила. Цей патерн використовується в Nx-монорепозиторіях зі спільними Emotion-інстансами.

Q: Чому перехід на zero-runtime Linaria ламає динамічні пропси?
A: Linaria витягує стилі під час збірки і не може обчислювати runtime-значення типу props.color. Замість цього динамічні значення передаються через CSS custom properties (var(--color)) і встановлюються inline на елементі. Скопінг класів зберігається, але JS-інтерполяція всередині стилів зникає.

Приклади

Базовий: кнопка з пропсами для стилів

jsx
import styled, { ThemeProvider } from 'styled-components'; const theme = { colors: { primary: '#007bff' } }; const Button = styled.button` background: ${props => props.primary ? props.theme.colors.primary : 'gray'}; color: white; padding: 10px 20px; border: none; border-radius: 4px; `; function App() { return ( <ThemeProvider theme={theme}> <Button primary>Зберегти</Button> <Button>Скасувати</Button> </ThemeProvider> ); } // Результат: два компоненти з унікальними хешованими класами, ізольовані від глобального CSS

ThemeProvider передає тему через React context. Будь-який styled-компонент всередині читає props.theme без прокидання пропсів вниз. Зміна теми в одному місці автоматично оновлює всі компоненти, що її використовують.

Середній рівень: мемоізовані картки метрик у дашборді

jsx
import { css } from '@emotion/react'; import { memo, useMemo } from 'react'; // Визначається поза компонентом - хеш обчислюється раз при завантаженні модуля const cardBase = css` border-radius: 8px; padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); `; const MetricCard = memo(({ label, value, color }) => { // Перераховується тільки при зміні color, а не на кожен рендер батька const valueStyle = useMemo( () => css`color: ${color}; font-size: 24px; font-weight: 600;`, [color] ); return ( <div css={cardBase}> <span>{label}</span> <span css={valueStyle}>{value}</span> </div> ); }); <MetricCard label="Дохід" value="$10k" color="#22c55e" /> <MetricCard label="Користувачі" value="1.2k" color="#3b82f6" />

cardBase живе поза компонентом і хешується раз при завантаженні модуля. valueStyle перераховується тільки коли color дійсно змінюється. У дашборді, де 20 карток оновлюються щосекунди, такий підхід усуває більшість зайвих записів у DOM.

Просунутий рівень: SSR-екстракція в Next.js

jsx
// pages/_document.js import Document, { Html, Head, Main, NextScript } from 'next/document'; import { ServerStyleSheet } from 'styled-components'; export default class MyDocument extends Document { static async getInitialProps(ctx) { const sheet = new ServerStyleSheet(); const originalRenderPage = ctx.renderPage; try { ctx.renderPage = () => originalRenderPage({ enhanceApp: (App) => (props) => sheet.collectStyles(<App {...props} />), }); const initialProps = await Document.getInitialProps(ctx); return { ...initialProps, // Стилі вставляються у <head> до клієнтської гідратації styles: [initialProps.styles, sheet.getStyleElement()], }; } finally { sheet.seal(); } } }

Без ServerStyleSheet Next.js надсилає HTML з правильною DOM-структурою, але без визначень класів styled-components. React гідратує, стилі вставляються - і користувач бачить короткий спалах нестилізованого контенту. З цим налаштуванням сервер збирає всі правила під час renderPage, відправляє їх у <head>, і клієнт отримує повністю стилізований документ з першого байта.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?