Препроцессоры глубоко: Sass, SCSS, PostCSS
Когда-то чистый CSS был языком без переменных, без вложенности, без импортов без блокировки — и инженеры начали писать «расширители языка». Так родились препроцессоры: Sass (2006), Less (2009), Stylus (2010). Они брали свой синтаксис, компилировали его в обычный CSS, и фронтендер получал переменные, миксины и математику за десять лет до их появления в стандарте.
Ситуация в 2026 году другая: вложенность в CSS — стандарт с 2023 года, custom properties — с 2016-го, @layer управляет каскадом, а @scope изолирует стили. Многое из того, ради чего ставили Sass, сегодня делает браузер. Но Sass жив, причём живуч: Bootstrap, Bulma, Vuetify — на Sass; огромный пласт legacy-кода написан на SCSS, и ты гарантированно в него влезешь. А рядом с Sass стоит PostCSS — инструмент, который не расширяет язык, а трансформирует будущий CSS в текущий и прогоняет оптимизации.
В этой главе — полная механика Sass/SCSS, честный ответ «нужен ли он сегодня», и разбор PostCSS: autoprefixer, browserslist, cssnano, preset-env и кастомные плагины на полном рабочем конфиге.
Sass/SCSS: два синтаксиса одного языка
Заголовок раздела «Sass/SCSS: два синтаксиса одного языка»Sass существует в двух синтаксисах:
- SCSS (Sassy CSS) — надмножество CSS: любой валидный CSS — валидный SCSS. Расширение
.scss. Именно его используют все современные проекты. - Indented Sass — старый синтаксис без фигурных скобок и точек с запятой, отступы значат. Расширение
.sass. Почти не встречается, но встречается.
Компиляторы: Dart Sass (официальный, единственный развиваемый; sass на npm), старый LibSass (мертв с 2020) и node-sass (обёртка над LibSass — антивирус не поставишь, не ставь: не поддерживает новые фичи и глючит на новых Node).
npm i -D sassnpx sass src/styles/main.scss dist/main.cssnpx sass --watch src/styles:dist # вотч-режим для разработкиПеременные: компиляция против рантайма
Заголовок раздела «Переменные: компиляция против рантайма»Переменная в Sass — $имя: значение;. Ключевое отличие от CSS Custom Properties: Sass-переменная исчезает при компиляции — её значение подставляется в каждое место использования как текст, и в браузер она не доезжает.
$brand-color: #6366f1;$radius-md: 8px;$font-main: "Inter", system-ui, sans-serif;
.button { background: $brand-color; border-radius: $radius-md; font-family: $font-main;
&:hover { background: darken($brand-color, 8%); // Sass-функции работают со значением }}Сравним с CSS Custom Properties, которые компилировать не нужно — они живут в браузере:
:root { --brand-color: #6366f1; --radius-md: 8px;}
.button { background: var(--brand-color); border-radius: var(--radius-md);}
.button:hover { /* darken() в чистом CSS нет, но зато можно перекрыть переменную */ background: color-mix(in srgb, var(--brand-color), black 8%);}| Критерий | Sass $var |
CSS var(--var) |
|---|---|---|
| Где живёт | Только на этапе сборки | В браузере, в DOM |
| Можно поменять рантаймом | Нет | Да, из JS или по медиа-запросу |
Функции (darken, математика) |
Да, полный набор | Ограниченно (calc, color-mix) |
| Видимость | Скоуп через вложенность/модули | Наследование через DOM |
| Темизация (dark mode) | Компилировать два CSS-файла | Одна переменная — одно переключение |
| Зависимость от сборки | Да | Нет |
Практический вывод 2026 года: дизайн-токены и темизация — на CSS Custom Properties (это то, чем живёт dark mode в Bootstrap 5.3+, Tailwind и Shadcn), а Sass-переменные оставь для значений, которые никогда не меняются рантаймом, и для кода, который Sass-компилирует в чистый CSS для публикации (библиотеки).
Вложенность — и почему нативная изменила правила
Заголовок раздела «Вложенность — и почему нативная изменила правила»Вложенность — главная причина, по которой люди ставили Sass. Пиши селекторы как структуру DOM, а не бесконечные цепочки:
.card { padding: 16px; border: 1px solid #e2e8f0;
&__title { // parent selector & + BEM-конкатенация font-size: 20px; margin-bottom: 8px; }
&:hover { box-shadow: 0 4px 12px rgb(0 0 0 / 8%); }
.theme-dark & { // & в середине: .theme-dark .card border-color: #334155; }}В 2023 году вложенность стала нативной частью CSS — без препроцессора. Синтаксис почти тот же, с одним важным отличием, о котором молчат:
.card { padding: 16px;
&:hover { /* работает */ box-shadow: 0 4px 12px rgb(0 0 0 / 8%); }}
/* НО: вложенный селектор БЕЗ & в начале — это " descendant " */.card { .title { /* = .card .title — то же, что и в Sass */ font-size: 20px; }}
/* ГРАНИЦА: написать .card__title через конкатенацию нельзя! */.card { &__title { /* ОШИБКА в нативном CSS: &__title не существует */ font-size: 20px; }}Нативная вложенность не умеет конкатенацию &__element. Плюс нативный & в ранних реализациях требовал явного начала с & (.card { &:hover }), а неявная вложенность a { color: red; body.dark & { ... } } долго не работала в Safari. Сегодня поддержка полная во всех актуальных браузерах, но в legacy-проектах на Sass нативная вложенность и SCSS-вложенность смешиваются — будь готов читать оба стиля.
Партиалы и модули: @use/@forward против мёртвого @import
Заголовок раздела «Партиалы и модули: @use/@forward против мёртвого @import»Большой SCSS раскладывают на партиалы — файлы _buttons.scss, _variables.scss (подчёркивание в начале = «не компилируй отдельно») — и собирают через директивы.
Старый способ — @import. Он объявлен deprecated в Dart Sass и выдаёт warning при каждой сборке. Причины смерти:
@import— просто текстовая вставка. Один и тот же файл можно импортировать дважды → двойной CSS в бандле.- Глобальное пространство имён. Все переменные и миксины всех файлов — видны всем. В проекте на 200 файлов найти, откуда взялся
$primary— квест. - Конфликты имён при подключении двух библиотек.
Современный способ — модульная система Dart Sass:
// styles/_tokens.scss (партиал с токенами)$brand: #6366f1;$radius: 8px;
@mixin focus-ring { outline: 2px solid $brand; outline-offset: 2px;}@use "tokens"; // файл _tokens.scss, namespace по умолчанию
.button { background: tokens.$brand; // доступ через namespace — как в JS-модулях! border-radius: tokens.$radius;
&:focus-visible { @include tokens.focus-ring; }}// styles/main.scss — точка сборки@use "buttons";Ключевые свойства @use:
- Каждый модуль подключается один раз, повторные
@use— no-op. - Члены доступны через namespace (
tokens.$brand), коллизии исключены. - Можно переименовать:
@use "tokens" as t;→t.$brand, или сделать «голый»:@use "tokens" as *;— но тогда теряешь защиту от коллизий. - Модуль выполняется один раз, при первом
@use.
@forward — «переэкспорт»: barrel-файл, который собирает модули и отдаёт наружу:
// styles/index.scss — публичное API дизайн-системы@forward "tokens";@forward "buttons";@forward "forms";
// а в приложении:@use "styles" as ds; // ds.$brand, ds.button доступны напрямуюИ через @forward "tokens" hide $internal; можно скрывать приватные члены — то есть у дизайн-системы появляется настоящий публичный интерфейс.
Миксины: аргументы, default и @content
Заголовок раздела «Миксины: аргументы, default и @content»Миксин — переиспользуемый блок объявлений с параметрами. В 2026 это главная причина оставаться на Sass: нативного аналога нет.
// Объявление с обязательными и опциональными аргументами@mixin button-base($bg, $padding: 8px 16px, $radius: $radius) { display: inline-flex; align-items: center; gap: 8px; padding: $padding; border: none; border-radius: $radius; background: $bg; cursor: pointer;
&:disabled { opacity: 0.5; cursor: not-allowed; }}
// Вариативность через несколько миксинов-надстроек@mixin button-variant($bg) { @include button-base($bg);
&:hover:not(:disabled) { background: darken($bg, 8%); }}@content — миксин может обернуть переданный блок кода, отсюда — «миксин-блок» для медиа-запросов и состояний:
// Универсальный миксин для hover-устройств (тач-скрины не имеют hover!)@mixin hover { @media (hover: hover) { &:hover { @content; // сюда попадёт тело блока из места вызова } }}
.button { @include button-variant($brand);
@include hover { transform: translateY(-1px); // этот код «провалится» в @content }}Важный подводный камень под капотом: миксин — это подстановка текста. Каждый @include раздувает скомпилированный CSS. Десять @include button-variant(...) — это десять копий всех свойств кнопки в бандле (Sass не дедуплицирует). Для утилит это нормально, для крупных блоков — следи за размером CSS.
Функции и операторы
Заголовок раздела «Функции и операторы»Sass-функции возвращают значение (в отличие от миксинов, которые возвращают CSS):
@function rem($px, $base: 16px) { @return calc($px / $base) * 1rem;}
@function spacing($step) { @return $step * 4px; // система отступов кратна 4px}
.title { font-size: rem(24px); margin-bottom: spacing(3); // 12px}Операторы: математика (+ - * / %), сравнения, и — мощно — операторы над цветами из модуля sass:color:
@use "sass:color";
.button { background: $brand;
&:hover { background: color.scale($brand, $lightness: -8%); // современный API }}
.badge { background: color.change($brand, $alpha: 0.12); // бренд на 12% непрозрачности}Старые глобальные darken()/lighten()/rgba($color, .5) работают, но Dart Sass рекомендует модули sass:color, sass:math, sass:map, sass:list — глобальные функции объявлены deprecated.
@extend и его проблемы
Заголовок раздела «@extend и его проблемы»@extend даёт «наследование селекторов»: %placeholder или класс, чьи стили «подхватывают» другие селекторы без дублирования свойств.
%visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0);}
.sr-only { @extend %visually-hidden;}Компилируется не в копию, а в объединённый селектор: .sr-only { ... }. Звучит экономно. На практике @extend — источник трудноотлаживаемых багов:
- Селекторные цепочки разъезжаются.
@extend .buttonвнутри медиа-запроса, а.buttonопределён вне — Sass либо скопирует, либо выдаст нечитаемую цепочку.theme-dark .button, .theme-dark .icon-button, ...из сотен селекторов. - Специфичность растёт непредсказуемо. Объединённые селекторы имеют специфичность самого «тяжёлого» участника — твой «простой» класс внезапно бьётся об
#idгде-то в другом файле. - Нельзя экстендить внутри
@mediaто, что снаружи — ошибка компиляции в очевидных местах и молчаливый бардак в неочевидных.
Вывод индустрии: не используй @extend с классами вообще. Максимум — placeholder’ы для служебных паттернов вроде visually-hidden, и то сегодня это проще дать готовой утилитой.
Нужен ли Sass в 2026?
Заголовок раздела «Нужен ли Sass в 2026?»Честная таблица:
| Задача | Нужен ли Sass |
|---|---|
| Переменные и темизация | Нет → CSS Custom Properties |
| Вложенность | Нет → нативный CSS Nesting |
| Модули/партиалы, namespace | Да (или CSS Layers + @import нативный, но это другое) |
Миксины, @content, функции |
Да, нативных аналогов нет |
Математика цветов, rem()-хелперы |
Да (или calc/color-mix в простых случаях) |
| Пишешь библиотеку компонентов (Bootstrap-подобную) | Да — потребители получают чистый CSS |
| Работаешь в legacy с SCSS | Да, и это дефолт |
| Новый pet-проект с нуля | Скорее нет: Tailwind или нативный CSS |
Less и Stylus: почему проиграли
Заголовок раздела «Less и Stylus: почему проиграли»Less появился в 2009-м как JavaScript-препроцессор (компиляция в браузере через less.js), и его синтаксис переменных (@brand: #6366f1;) — с @ — сделал невозможным миграцию на нативный CSS: @brand валиден в CSS и означает директиву. Когда CSS завез свои переменные, Less-файлы нельзя было просто «раз-Sass’ить» — все переменные конфликтовали со стандартом. Плюс Less всегда отставал по фичам (не появилась полноценная модульная система уровня @use).
Stylus умирал медленнее — синтаксис без скобок был его фичей и проклятием: файлы читались хуже, форматтеров не было. К 2020-му сообщество консолидировалось вокруг Sass (только он развивается, имеет spec и reference-реализацию Dart Sass), и сегодня встретить Less или Stylus в новом проекте — маркер либо очень старого кода, либо очень специфических вкусов. Знать их — надо для чтения legacy; писать на них — нет.
PostCSS: не препроцессор, а трансформатор
Заголовок раздела «PostCSS: не препроцессор, а трансформатор»PostCSS — фундаментально другой инструмент. Если Sass — новый язык поверх CSS, то PostCSS парсит обычный CSS в AST и прогоняет через плагины, каждый из которых что-то трансформирует. Сам по себе он ничего не делает — вся магия в плагинах. Три класса задач:
1. Полифилы будущего CSS — postcss-preset-env. Пишешь современный синтаксис (вложенность, color-mix, кастомные медиа-запросы @custom-media, логические свойства margin-inline), плагин транспилирует в то, что понимают браузеры из твоего browserslist. Это «Babel для CSS» — полный список фич по stage показывает, что уже можно писать нативно:
/* пишешь так */.sidebar { margin-inline-start: 16px;
&:hover { color: color-mix(in srgb, var(--brand), white 20%); }}
/* получаешь так (для старых браузеров) */.sidebar { margin-left: 16px; /* логические → физические */}.sidebar:hover { color: #8284f3; /* color-mix вычислен на сборке */}2. Автопрефиксинг — autoprefixer. Парсит Can I Use data и добавляет вендорные префиксы только под твои браузеры. В 2026-м -webkit- почти не нужен (border-radius, box-shadow, flex давно без префиксов), но -webkit-appearance, старые @supports и Safari-специфика всё ещё покрываются — пусть работает автоматически, руками не трогай.
3. Оптимизация — cssnano. Минификация: схлопывает margin-top + margin-bottom, выкидывает дубликаты, сокращает цвета, сжимает @media (разворачивает media query как data structure, сливает одинаковые). С ним бандл Tailwind/Bootstrap ужимается на 10–30% после минификации.
Browserslist: единый источник правды
Заголовок раздела «Browserslist: единый источник правды»Все PostCSS-плагины (и Babel, иcore-js) читают один конфиг — .browserslistrc или поле browserslist в package.json. Поменял его — поменял префиксы, полифилы и транспиляцию для всего стека разом:
{ "browserslist": [ "> 0.5%", "last 2 versions", "not dead", "not ie 11" ]}npx browserslist # показать, какие браузеры попали под запросnot dead — ключевое: отсекает браузеры без официальной поддержки производителя (старый Edge, IE). > 0.5% — глобальная статистика; для СНГ иногда добавляют "cover 95% in RU".
Полный postcss.config.js
Заголовок раздела «Полный postcss.config.js»Реальный конфиг из Vite/Next.js-проекта:
// postcss.config.js (ESM; для CommonJS используй module.exports)export default { plugins: [ // будущее CSS → текущее; stage определяет свежесть фич // stage 2 = черновики, стабильные; 0 = всё подряд (экспериментально) ['postcss-preset-env', { stage: 2, features: { 'nesting-rules': true, // нативная вложенность для старых браузеров 'custom-media-queries': true, // @custom-media с fallbacks }, autoprefixer: { grid: 'autoplace' }, // и автопрефиксер внутри preset-env }],
// минификация только для продакшена ...(process.env.NODE_ENV === 'production' ? [['cssnano', { preset: 'default' }]] : []), ],};Кастомный плагин: анатомия
Заголовок раздела «Кастомный плагин: анатомия»Плагин — функция, возвращающая объект с обработчиками AST-узлов. Пример: плагин, который выкидывает все outline: none из продакшен-бандла (ловушка для доступности, см. a11y):
const noOutlineNone = () => ({ postcssPlugin: 'no-outline-none', Declaration(decl) { if (decl.prop === 'outline' && decl.value === 'none') { decl.remove(); // или decl.warn(...) в dev-режиме } },});noOutlineNone.postcss = true;
export default { plugins: [ ...(process.env.NODE_ENV === 'production' ? [noOutlineNone()] : []), ],};AST-узлы: Rule (селектор), Declaration (свойство: значение), AtRule (@media, @use), Comment. Плагины типа stylelint — те же PostCSS-плагины, только валидируют, а не трансформируют.
Связь с соседними темами
Заголовок раздела «Связь с соседними темами»- CSS Modules и современный CSS — PostCSS-пайплайн там же: css-modern. CSS Custom Properties и
@layer— нативная замена половине Sass. - Tailwind глубоко — Tailwind сам плагин PostCSS; сравнение «Sass-миксины против утилит» — там: tailwind-deep.
- Bootstrap — следующая глава: вся кастомизация Bootstrap 5 строится на Sass-переменных, и ты сразу применишь
@use/@forward. - Core Web Vitals — размер скомпилированного CSS напрямую влияет на рендеринг: web-vitals.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Поставил
node-sassв новый проект. Это мёртвый LibSass, не поддерживает@use,@forward, новые модулиsass:*и глючит на Node 18+. Ставьsass(Dart Sass). Если видишьnode-sassв package.json — мигрируй. @importв новом коде. Dart Sass печатает deprecation warning на каждый файл. Мигрируй на@use/@forward: namespace-конфликты решаются переименованием (as t), а не переписыванием половины проекта.- Темизация на Sass-переменных там, где нужен рантайм. Пытаешься переключить dark mode через замену CSS-файла — получаешь мигание (FOUC) и невозможность менять тему из JS. Темы — на CSS Custom Properties, Sass-переменные — для статических значений.
- Копипаст миксинов в цикле/сотнях мест без контроля размера. Каждый
@include— текстовая подстановка. Проект на@include respond-to(...)в 300 местах получает 300 копий медиа-запросов. Следи за итоговым CSS (sass --stats), крупные блоки дедуплицируй в классы. @extendмежду классами. Получаешь селекторные цепочки на весь экран, непредсказуемую специфичность и ошибки при экстенде через@media. Замена: placeholder только для служебных паттернов или просто дублирование через миксин.- Удалил
outline: noneруками в одном месте, оставил в десяти. Решается stylelint-правилом или кастомным PostCSS-плагином (пример выше) — линтинг стилей должен быть частью CI. - Browserslist не настроен → autoprefixer и preset-env работают «по умолчанию на всё». Префиксов многовато, транспиляции избыточно. Одна строчка
.browserslistrcэкономит килобайты.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем Sass-переменные отличаются от CSS Custom Properties? Sass-переменные существуют только при компиляции: значение текстово подставляется в CSS, в браузер не доезжает, рантаймом не управляется. Custom Properties живут в браузере, наследуются через DOM, меняются из JS и медиа-запросов — поэтому темизация и dark mode строятся на них.
- Почему
@importв Sass объявлен deprecated? Чем заменён? Текстовая вставка без дедупликации (двойной код при повторном импорте), глобальный namespace с коллизиями. Замена — модульная система:@use(подключение с namespace, выполняется один раз) и@forward(переэкспорт, barrel-файлы). @mixinпротив@function— в чём разница? Миксин возвращает блок CSS-объявлений и подставляется в место вызова (@include), умеет@content. Функция возвращает значение и используется внутри объявлений (font-size: rem(24px)).- Что такое PostCSS и чем он отличается от препроцессора? Парсер CSS → AST → цепочка плагинов-трансформаторов. Не добавляет язык, а трансформирует стандартный CSS: preset-env (будущее → текущее), autoprefixer (префиксы по browserslist), cssnano (минификация). «Babel для CSS».
- Как устроен browserslist и зачем он нужен?
Единый конфиг (
.browserslistrc/ package.json) с запросами вида> 0.5%, last 2 versions, not dead. Autoprefixer, preset-env, Babel и core-js читают его синхронно — меняешь в одном месте, меняется таргет всего стека. - Проблемы
@extend? Когда его допустимо использовать? Раздувает селекторные цепочки, специфичность становится непредсказуемой, экстенд через@mediaошибочен. Допустим с placeholder’ами для маленьких служебных паттернов (visually-hidden); с реальными классами — не использовать. - Нужен ли Sass в новом проекте в 2026? Обоснуй.
Скорее нет, если нет legacy и библиотечной разработки: вложенность и переменные есть в нативном CSS, темизация — на custom properties. Да — если пишешь UI-библиотеку (потребители получают чистый CSS), нужны миксины/
@content/функции, или проект уже на SCSS. - Что делает cssnano и почему его включают только в продакшене?
Минификация CSS: слияние селекторов и media query, сокращение значений, удаление дубликатов. В dev убирает читаемость и замедляет сборку — пользы ноль, поэтому задокументировано через
NODE_ENV.
Практика
Заголовок раздела «Практика»- Настрой проект:
npm i -D sass, создайstyles/_tokens.scss(брендовый цвет, радиусы, шкала отступов кратная 4px),styles/_buttons.scssс миксиномbutton-variant($bg)и@useс namespace. Собери черезnpx sass styles/main.scss out.css. Критерий: вout.cssнет Sass-синтаксиса, переменные подставлены, файл собирается без deprecation warnings. - Реализуй миксин
hoverчерез@contentи@media (hover: hover)и примени его к кнопке. Проверь скомпилированный CSS: тело блока оказалось внутри@media (hover: hover) { .button:hover { ... } }. Критерий: на устройстве без hover (эмуляция в DevTools) стили не применяются. - Напиши
@function rem($px)и перепиши заголовки наrem(). Критерий: при$base: 16pxзначениеrem(24)даёт1.5remв CSS. - Создай barrel-файл
styles/index.scssчерез@forward, спрячь одну переменную черезhide, подключи в приложении@use "styles" as ds. Критерий: скрытая переменная недоступна (ds.$internal— ошибка компиляции), публичные — доступны. - Собери PostCSS-цепочку:
postcss-preset-env(stage 2) +cssnano(только для prod) +.browserslistrcс> 0.5%, not dead. Напиши CSS сmargin-inline-startи вложенностью. Критерий: для таргетаdefaultsтранспиляция заменяет логические свойства на физические;npx browserslistпоказывает список браузеров. - Напиши кастомный PostCSS-плагин, который в продакшене выкидывает
outline: noneи логирует найденные места в консоль сборки. Критерий: в dev-бандле объявления на месте, в prod — удалены, в логе сборки есть пути файлов.
Что почитать
Заголовок раздела «Что почитать»- Sass: официальная документация —
@use,@forward, модулиsass:*и deprecation-политика — первоисточник. - Sass: @import is deprecated — официальный гайд миграции с
@importна@use. - PostCSS — архитектура плагинов, AST, API.
- postcss-preset-env — интерактивный список фич по stage: что уже можно писать нативно.
- Browserslist — синтаксис запросов, best practices,
npx browserslist. - Can I Use: CSS Nesting — поддержка нативной вложенности и её ограничения против SCSS.