Tailwind CSS глубоко: каскад, JIT и дизайн-токены
В краткой версии ты написал первые компоненты на Tailwind и почувствовал главное — скорость. Но скорость здесь следствие, а не причина. Причина в том, что Tailwind радикально меняет модель работы с каскадом: вместо того чтобы писать CSS и бороться с его природой, ты вообще отказываешься от большей части каскада. Эта глава — про механику этого отказа: почему BEM родился и умер, как Tailwind генерирует CSS из твоей разметки, где граница у @apply и что реально влияет на стилизационный перфоманс в браузере.
Продакшен-контекст: в проекте на 200+ компонентов классический CSS неизбежно превращается в либо «спагетти из переопределений», либо в жёсткий BEM с именами вида card__header-title--highlighted. Оба варианта работают, пока проект маленький. Когда команда растёт и дизайн меняется каждую неделю, стоимость изменения каждого селектора становится слишком высокой. Tailwind снижает эту стоимость до перестановки классов в разметке.
Утилитарный CSS против BEM: войны специфичности
Заголовок раздела «Утилитарный CSS против BEM: войны специфичности»Как работает каскад и специфичность
Заголовок раздела «Как работает каскад и специфичность»Браузер применяет стили в три этапа: каскад (порядок и источники), специфичность (вес селектора), наследование. Вес считается по тройке (ID, классы/псевдоклассы/атрибуты, элементы). Инлайн-стили и !important — отдельные плоскости, ломающие обычный порядок.
/* специфичность: (0, 1, 0) vs (0, 2, 1) — второе победит */.card { color: black; } /* 0-1-0 */.card .card__title span { color: red; } /* 0-2-1 */Проблема в том, что в реальном проекте специфичность — это гонка вооружений. Кнопку внутри .card внутри .modal не переопределить без ещё более длинного селектора или !important. Каждый фикс повышает планку для следующего.
BEM как перемирие
Заголовок раздела «BEM как перемирие»BEM (Блок, Элемент, Модификатор) — соглашение об именовании, цель которого — держать всю специфичность на уровне одного класса (0-1-0), чтобы побеждал просто порядок в файле:
/* Блок — независимая сущность */.button { display: inline-flex; padding: 0.5rem 1rem; }/* Элемент — часть блока, не существует вне его */.button__icon { margin-right: 0.5rem; }/* Модификатор — состояние или вариант */.button--primary { background: #4f46e5; color: #fff; }BEM работает, но цена — двойная бюрократия: имена классов длинные и хрупкие, а каждый новый элемент — это правка CSS-файла, тесты на регрессию визуала и риск «утечки» стилей блока в элементы. Есть и исторический слой: BEM родился в эпоху, когда CSS писали отдельные «верстальщики», а JS-программисты боялись трогать стили. Когда компонентный подход (React) стёр эту границу, отдельный слой именования потерял оправдание — стили стали частью компонента, а не его внешним контрактом. Исторически эстафету BEM подхватывали CSS-in-JS-библиотеки (styled-components, Emotion): они решали изоляцию через генерацию уникальных классов в рантайме, но платили за это ростом бандла, runtime-стоимостью и усложнением SSR. Tailwind пришёл к тому же результату (изоляция через одноразовые классы) без рантайма: в прод попадает обычный статический CSS.
Tailwind как капитуляция перед каскадом — и победа
Заголовок раздела «Tailwind как капитуляция перед каскадом — и победа»Tailwind делает радикальную вещь: все утилиты имеют специфичность (0-1-0), конфликт решается порядком классов в сгенерированном CSS, а не в разметке, и ты пишешь стили прямо в компоненте:
// компонент целиком описан в разметке — отдельный CSS-файл не нуженexport function Button({ children }: Props) { return ( <button className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:opacity-50"> {children} </button> );}Важно: конфликт px-4 против px-2 не решается тем, что «последний класс в className». Порядок определяется Tailwind’ом при генерации CSS (утилиты отсортированы по внутренней шкале: сначала layout, потом типографика, потом цвета). Управлять конфликтами помогает tailwind-merge:
// lib/utils.ts — стандартный cn() из shadcnimport { clsx, type ClassValue } from 'clsx';import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
cn('px-4', 'px-2'); // → 'px-2' — merge знает шкалу утилит и оставляет последнююПод капотом: content-сканирование и JIT
Заголовок раздела «Под капотом: content-сканирование и JIT»Классический CSS-фреймворк (Bootstrap) шипит сотни готовых классов, и ты тащишь весь CSS независимо от использования. Tailwind v3+ работает иначе — JIT-компиляция: он сканирует файлы из content, находит строки, похожие на утилиты, и генерирует только нужные правила.
// tailwind.config.ts — ключевая настройкаexport default { content: [ './src/**/*.{ts,tsx,html}', // твой код './node_modules/@acme/ui/dist/**/*.js', // сторонний пакет с tailwind-классами ],};Как это работает на практике: запускаешь dev-сервер — Tailwind инкрементально пересобирает CSS при каждом сохранении файла, добавляя новые классы за миллисекунды. В продакшене собирается один минифицированный файл, обычно 8–15 КБ (gzip) для среднего приложения. Для сравнения: Bootstrap — ~30 КБ (gzip) до твоих стилей.
Три вещи, которые стоит знать про сканер:
- Он ищет целые строки. Утилита, собранная через конкатенацию
'p-' + size, не найдётся:'p-4'— найдётся. Используй полные имена классов в исходниках. - Safelisting. Если классы приходят из базы или пользовательского ввода, добавь их в
safelist:
safelist: [ { pattern: /^(bg|text)-(red|green|blue)-(100|700)$/ },];- Репортаж о размере.
npx tailwindcss -i input.css -o output.css --minifyпокажет итоговый размер — сверяй его в CI, рост выше порога — сигнал добавить что-то лишнее вcontent.
@layer и @apply: три стратегии организации CSS
Заголовок раздела «@layer и @apply: три стратегии организации CSS»Даже в Tailwind-проекте бывает нужен «обычный» CSS: базовые стили, стилизация сторонних библиотек, сложные кейфреймы. Для этого есть директивы:
@tailwind base; /* reset + твои base-стили */@tailwind components; /* «компонентные» классы через @apply */@tailwind utilities; /* все утилиты — всегда последним слоем */
@layer base { /* переопределяем базу аккуратно, без глобальных тегов-«бомб» */ html { @apply antialiased; } body { @apply bg-slate-50 text-slate-900; }}
@layer components { /* редкий случай: повторяющийся паттерн, который не тянет на компонент */ .field { @apply rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500; }}
@layer utilities { /* кастомная утилита, участвующая в той же системе весов */ .scrollbar-thin { scrollbar-width: thin; }}@layer — это современный механизм каскада (Cascade Layers): стили в объявленном позднее слое бьют стили в более раннем слое независимо от специфичности. Tailwind использует его, чтобы утилиты всегда побеждали base/components.
Когда @apply вреден
Заголовок раздела «Когда @apply вреден»@apply копирует объявления утилиты в твой класс. Осторожно: каждый @apply-класс — это дублирование CSS. Классическая ошибка — «BEM на Tailwind», когда весь проект переводят на @apply-обёртки и получают обратно специфичность, неудобные переопределения и бОльший бандл. Правило: если класс используется в одном компоненте — пиши утилиты в разметке; если паттерн повторяется в 5+ местах и не тянет на отдельный компонент — допустим @apply.
Дизайн-токены через theme-расширение
Заголовок раздела «Дизайн-токены через theme-расширение»Дизайн-система проекта должна жить в конфиге, а не в произвольных #[ arbitrary values. Полный рабочий пример:
import type { Config } from 'tailwindcss';import typography from '@tailwindcss/typography';import forms from '@tailwindcss/forms';import containerQueries from '@tailwindcss/container-queries';
export default { content: ['./src/**/*.{ts,tsx}'], theme: { extend: { colors: { // семантические токены, а не голые hex в разметке brand: { 50: '#eef2ff', 100: '#e0e7ff', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 900: '#312e81', DEFAULT: '#4f46e5', }, surface: { DEFAULT: '#ffffff', muted: '#f8fafc' }, }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], mono: ['"JetBrains Mono"', 'ui-monospace', 'monospace'], }, borderRadius: { xl: '0.875rem', '2xl': '1.25rem' }, boxShadow: { card: '0 1px 2px rgb(15 23 42 / 0.06), 0 4px 12px rgb(15 23 42 / 0.06)', }, keyframes: { 'fade-up': { from: { opacity: '0', transform: 'translateY(8px)' }, to: { opacity: '1', transform: 'translateY(0)' }, }, }, animation: { 'fade-up': 'fade-up 300ms ease-out both' }, }, }, plugins: [typography, forms, containerQueries],} satisfies Config;Теперь в разметке — bg-brand-600 shadow-card animate-fade-up, и смена бренда — это правка одного файла.
Плагины: что брать, а что нет
Заголовок раздела «Плагины: что брать, а что нет»@tailwindcss/typography— классproseдля длинного текста (статьи, документация, markdown-рендер). Без него будешь вручную стилизовать каждыйh2,p,ulвнутри контента.@tailwindcss/forms— сброс дефолтных стилей форм до редактируемых. Без него кастомизация чекбоксов и селектов на разных платформах — боль.@tailwindcss/container-queries— префиксы@md:для container queries (репозиторий плагина; подробно в главе CSS Modules, PostCSS и современный CSS).
Писать свой плагин — тоже нормально: это функция, добавляющая утилиты/компоненты через API plugin().
Arbitrary values: выходной клапан
Заголовок раздела «Arbitrary values: выходной клапан»<!-- когда значения нет в шкале — без правки конфига --><div class="w-[357px] grid-cols-[1fr_2fr_auto] text-[#1a1a2e]"> <!-- ссылки на переменные и calc --> <div class="h-[calc(100vh-4rem)] text-[length:var(--sidebar-width)]"></div> <!-- произвольные варианты модификаторов --> <div class="bg-red-500 [@supports(backdrop-filter:blur(1px))]:bg-red-500/50"></div></div>Arbitrary values — инструмент для редких случаев. Если [#1a1a2e] встречается в пятом месте — это токен, его место в theme.extend.
Миграция: Tailwind рядом с legacy CSS
Заголовок раздела «Миграция: Tailwind рядом с legacy CSS»Реальные проекты редко стартуют с чистого листа. Типовой сценарий: старый CSS на BEM, новые экраны — на Tailwind. Стратегия мирного сосуществования:
/* 1. Все legacy-стили — в собственный слой, раньше утилит */@layer legacy { @import './legacy/vendor.css'; @import './legacy/bem-blocks.css';}
/* 2. База Tailwind не трогает существующие селекторы-«якоря» проекта */@layer base { :root { --brand: #4f46e5; } /* токены, на которые опирается старый код */}Три правила перехода: новые компоненты — только на утилитах (не размножай BEM дальше); legacy-правки делай минимальными и точечными; при рефакторинге блока переноси его целиком, а не «по классу за раз» — полураспиленный блок хуже любого из вариантов. Заодно это отвечает на вопрос собеседований «как бы ты внедрял Tailwind в существующий проект».
Честный CSS-перфоманс
Заголовок раздела «Честный CSS-перфоманс»Что реально влияет на скорость стилизации:
- Размер CSS. Меньше CSS — быстрее загрузка и меньше работы CSSOM. JIT Tailwind решает это почти полностью.
- Сложность селекторов. Селектор вида
.a .b .c:hover > .d— это матчинг по дереву на каждый кадр при стиле-инвалидации. Утилиты (0-1-0) дёшевы. Не создавай глубокую вложенность через PostCSS-nesting без нужды. - Layout thrashing. CSS сам по себе не тормозит — тормозит чтение layout-параметров в JS после записи стилей. Не читай
offsetHeightмеждуstyle-изменениями. - Animations. Анимируй только
transformиopacity— они вынесены на композиторный поток и не вызывают layout/paint. Анимацияwidth,top,box-shadow— работает на главном потоке. - Containment.
content-visibility: autoиcontain: layout paintизолируют поддерево от пересчёта соседей — для длинных списков карточек даёт заметный выигрыш.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Динамическая сборка имён классов.
className={text-${color}-500}— сканер не найдёт классы, в проде стили пропадут. Хорошо: маппинг объектомconst tones = { red: 'text-red-500', green: 'text-green-500' }. - Конфликт классов без
tailwind-merge.cn('px-4', props.className)с отсутствующим merge оставит оба класса, победит не тот. Всегда используйtwMergeвcn(). - Перенос всего в
@apply. Получаешь BEM с хуже поддержкой: специфичность выросла, бандл раздут, переиспользовать утилиты нельзя.@apply— исключение, не правило. - Не туда положили сторонний пакет в
content. Компонентная библиотека изnode_modules, написанная на Tailwind, молча не стилизуется — её dist не входит в сканирование. !importantповсюду. Tailwind имеет конфигimportant: true, и есть префикс!для отдельных утилит (!px-4). Использование для победы над сторонней библиотекой — признак того, что нужен@layerили более точечный селектор.- Глобальные стили тегов вне
@layer base.div { ... }без слоя побьёт утилиты в неожиданных местах. Всё глобальное — только в слои.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем утилитарный CSS решает проблему специфичности? Все утилиты равны по весу (0-1-0), конфликт решается порядком в сгенерированном CSS через слои, а не наращиванием вложенности. Гонка вооружений заканчивается, потому что писать «более специфичный» селектор физически негде.
- Как Tailwind понимает, какие классы генерировать? Сканирует файлы из
contentрегулярными выражениями, ищет полные имена классов, генерирует только найденное (JIT). Поэтому конкатенация имён ломает сборку. - Когда
@applyоправдан? Для повторяющегося паттерна без JSX-обвязки (стилизация стороннего виджета, базовые стили) и в@layer base/components. В компонентах — пиши утилиты в разметке. - Что делает
tailwind-mergeи зачем он вcn()? Разрешает конфликты утилит по внутренней шкале Tailwind, оставляя последнюю:cn('px-2', 'px-4')→px-4. Без него остаются оба класса и побеждает порядок в CSS. - Как организовать дизайн-токены? В
theme.extend: семантические имена (brand,surface), шкала отступов/радиусов из макета, тени и анимации. В разметке — только токены; arbitrary values — для разовых случаев. - Почему нельзя анимировать
width/top? Они вызывают layout и paint на главном потоке;transformиopacity— композиторные, работают без пересчёта раскладки и не бьют по INP. - Что попадает в итоговый CSS при
content: ['./src/**/*.tsx']? Только утилиты, чьи полные имена нашлись в файлах, + base + то, что добавлено через@layer/safelist. Всё остальное вырезается.
Практика
Заголовок раздела «Практика»- Рефакторинг BEM-компонента. Возьми 3–4 BEM-блока из любого проекта и перепиши на утилиты в разметке. Критерий: отдельный CSS-файл удалён или сокращён до
@layer base, визуальная регрессия — нулевая (сравни скриншотами). - Токены проекта. Собери полный
tailwind.config.tsпод макет pet-проекта: палитра, типографика, радиусы, тени, две анимации. Критерий: ни одного hex-кода в JSX-файлах — проверь поиском. - Свой плагин. Напиши плагин, добавляющий утилиты
text-balance(text-wrap: balance) иscrollbar-thin. Критерий: утилиты работают с модификаторами (md:scrollbar-thin). - Охота за размером. Собери CSS в prod-режиме, добавь неиспользуемый компонент с десятками новых утилит, собери снова и сравни размер. Критерий: объясни дельту и выведи размер в CI-пайплайн с порогом +15% к базовой линии.
- Анимации на композиторе. Реализуй модальное появление через
opacity+transform: scale()и сравни с вариантом наwidth/heightв DevTools → Performance. Критерий: запись показывает отсутствие Layout/Paint во время анимации.