Проблеми та рішення 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 під час збірки, без рантайм-витрат
Швидкий приклад
// 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 Modules | Tailwind |
|---|---|---|---|
| Скопінг | Хешовані класи в рантаймі | Локальні 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. Генерація стилів у циклах без мемоізації
// Неправильно: новий хеш на кожен елемент, 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-екстракція стилів
// Неправильно: стилі вставляються після гідратації, 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
// Неправильно: 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-інтерполяція всередині стилів зникає.
Приклади
Базовий: кнопка з пропсами для стилів
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>
);
}
// Результат: два компоненти з унікальними хешованими класами, ізольовані від глобального CSSThemeProvider передає тему через React context. Будь-який styled-компонент всередині читає props.theme без прокидання пропсів вниз. Зміна теми в одному місці автоматично оновлює всі компоненти, що її використовують.
Середній рівень: мемоізовані картки метрик у дашборді
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
// 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>, і клієнт отримує повністю стилізований документ з першого байта.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.