Перейти к содержимому

Препроцессоры глубоко: 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 (Sassy CSS) — надмножество CSS: любой валидный CSS — валидный SCSS. Расширение .scss. Именно его используют все современные проекты.
  • Indented Sass — старый синтаксис без фигурных скобок и точек с запятой, отступы значат. Расширение .sass. Почти не встречается, но встречается.

Компиляторы: Dart Sass (официальный, единственный развиваемый; sass на npm), старый LibSass (мертв с 2020) и node-sass (обёртка над LibSass — антивирус не поставишь, не ставь: не поддерживает новые фичи и глючит на новых Node).

Окно терминала
npm i -D sass
npx sass src/styles/main.scss dist/main.css
npx sass --watch src/styles:dist # вотч-режим для разработки

Переменная в Sass — $имя: значение;. Ключевое отличие от CSS Custom Properties: Sass-переменная исчезает при компиляции — её значение подставляется в каждое место использования как текст, и в браузер она не доезжает.

styles/tokens.scss
$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 при каждой сборке. Причины смерти:

  1. @import — просто текстовая вставка. Один и тот же файл можно импортировать дважды → двойной CSS в бандле.
  2. Глобальное пространство имён. Все переменные и миксины всех файлов — видны всем. В проекте на 200 файлов найти, откуда взялся $primary — квест.
  3. Конфликты имён при подключении двух библиотек.

Современный способ — модульная система Dart Sass:

// styles/_tokens.scss (партиал с токенами)
$brand: #6366f1;
$radius: 8px;
@mixin focus-ring {
outline: 2px solid $brand;
outline-offset: 2px;
}
styles/_buttons.scss
@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; можно скрывать приватные члены — то есть у дизайн-системы появляется настоящий публичный интерфейс.

Миксин — переиспользуемый блок объявлений с параметрами. В 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 даёт «наследование селекторов»: %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 — источник трудноотлаживаемых багов:

  1. Селекторные цепочки разъезжаются. @extend .button внутри медиа-запроса, а .button определён вне — Sass либо скопирует, либо выдаст нечитаемую цепочку .theme-dark .button, .theme-dark .icon-button, ... из сотен селекторов.
  2. Специфичность растёт непредсказуемо. Объединённые селекторы имеют специфичность самого «тяжёлого» участника — твой «простой» класс внезапно бьётся об #id где-то в другом файле.
  3. Нельзя экстендить внутри @media то, что снаружи — ошибка компиляции в очевидных местах и молчаливый бардак в неочевидных.

Вывод индустрии: не используй @extend с классами вообще. Максимум — placeholder’ы для служебных паттернов вроде visually-hidden, и то сегодня это проще дать готовой утилитой.

Честная таблица:

Задача Нужен ли Sass
Переменные и темизация Нет → CSS Custom Properties
Вложенность Нет → нативный CSS Nesting
Модули/партиалы, namespace Да (или CSS Layers + @import нативный, но это другое)
Миксины, @content, функции Да, нативных аналогов нет
Математика цветов, rem()-хелперы Да (или calc/color-mix в простых случаях)
Пишешь библиотеку компонентов (Bootstrap-подобную) Да — потребители получают чистый CSS
Работаешь в legacy с SCSS Да, и это дефолт
Новый pet-проект с нуля Скорее нет: Tailwind или нативный CSS

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 — фундаментально другой инструмент. Если 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% после минификации.

Все 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".

Реальный конфиг из 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):

postcss.config.js
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.
  1. Поставил node-sass в новый проект. Это мёртвый LibSass, не поддерживает @use, @forward, новые модули sass:* и глючит на Node 18+. Ставь sass (Dart Sass). Если видишь node-sass в package.json — мигрируй.
  2. @import в новом коде. Dart Sass печатает deprecation warning на каждый файл. Мигрируй на @use/@forward: namespace-конфликты решаются переименованием (as t), а не переписыванием половины проекта.
  3. Темизация на Sass-переменных там, где нужен рантайм. Пытаешься переключить dark mode через замену CSS-файла — получаешь мигание (FOUC) и невозможность менять тему из JS. Темы — на CSS Custom Properties, Sass-переменные — для статических значений.
  4. Копипаст миксинов в цикле/сотнях мест без контроля размера. Каждый @include — текстовая подстановка. Проект на @include respond-to(...) в 300 местах получает 300 копий медиа-запросов. Следи за итоговым CSS (sass --stats), крупные блоки дедуплицируй в классы.
  5. @extend между классами. Получаешь селекторные цепочки на весь экран, непредсказуемую специфичность и ошибки при экстенде через @media. Замена: placeholder только для служебных паттернов или просто дублирование через миксин.
  6. Удалил outline: none руками в одном месте, оставил в десяти. Решается stylelint-правилом или кастомным PostCSS-плагином (пример выше) — линтинг стилей должен быть частью CI.
  7. Browserslist не настроен → autoprefixer и preset-env работают «по умолчанию на всё». Префиксов многовато, транспиляции избыточно. Одна строчка .browserslistrc экономит килобайты.
  1. Чем Sass-переменные отличаются от CSS Custom Properties? Sass-переменные существуют только при компиляции: значение текстово подставляется в CSS, в браузер не доезжает, рантаймом не управляется. Custom Properties живут в браузере, наследуются через DOM, меняются из JS и медиа-запросов — поэтому темизация и dark mode строятся на них.
  2. Почему @import в Sass объявлен deprecated? Чем заменён? Текстовая вставка без дедупликации (двойной код при повторном импорте), глобальный namespace с коллизиями. Замена — модульная система: @use (подключение с namespace, выполняется один раз) и @forward (переэкспорт, barrel-файлы).
  3. @mixin против @function — в чём разница? Миксин возвращает блок CSS-объявлений и подставляется в место вызова (@include), умеет @content. Функция возвращает значение и используется внутри объявлений (font-size: rem(24px)).
  4. Что такое PostCSS и чем он отличается от препроцессора? Парсер CSS → AST → цепочка плагинов-трансформаторов. Не добавляет язык, а трансформирует стандартный CSS: preset-env (будущее → текущее), autoprefixer (префиксы по browserslist), cssnano (минификация). «Babel для CSS».
  5. Как устроен browserslist и зачем он нужен? Единый конфиг (.browserslistrc / package.json) с запросами вида > 0.5%, last 2 versions, not dead. Autoprefixer, preset-env, Babel и core-js читают его синхронно — меняешь в одном месте, меняется таргет всего стека.
  6. Проблемы @extend? Когда его допустимо использовать? Раздувает селекторные цепочки, специфичность становится непредсказуемой, экстенд через @media ошибочен. Допустим с placeholder’ами для маленьких служебных паттернов (visually-hidden); с реальными классами — не использовать.
  7. Нужен ли Sass в новом проекте в 2026? Обоснуй. Скорее нет, если нет legacy и библиотечной разработки: вложенность и переменные есть в нативном CSS, темизация — на custom properties. Да — если пишешь UI-библиотеку (потребители получают чистый CSS), нужны миксины/@content/функции, или проект уже на SCSS.
  8. Что делает cssnano и почему его включают только в продакшене? Минификация CSS: слияние селекторов и media query, сокращение значений, удаление дубликатов. В dev убирает читаемость и замедляет сборку — пользы ноль, поэтому задокументировано через NODE_ENV.
  1. Настрой проект: 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.
  2. Реализуй миксин hover через @content и @media (hover: hover) и примени его к кнопке. Проверь скомпилированный CSS: тело блока оказалось внутри @media (hover: hover) { .button:hover { ... } }. Критерий: на устройстве без hover (эмуляция в DevTools) стили не применяются.
  3. Напиши @function rem($px) и перепиши заголовки на rem(). Критерий: при $base: 16px значение rem(24) даёт 1.5rem в CSS.
  4. Создай barrel-файл styles/index.scss через @forward, спрячь одну переменную через hide, подключи в приложении @use "styles" as ds. Критерий: скрытая переменная недоступна (ds.$internal — ошибка компиляции), публичные — доступны.
  5. Собери PostCSS-цепочку: postcss-preset-env (stage 2) + cssnano (только для prod) + .browserslistrc с > 0.5%, not dead. Напиши CSS с margin-inline-start и вложенностью. Критерий: для таргета defaults транспиляция заменяет логические свойства на физические; npx browserslist показывает список браузеров.
  6. Напиши кастомный PostCSS-плагин, который в продакшене выкидывает outline: none и логирует найденные места в консоль сборки. Критерий: в dev-бандле объявления на месте, в prod — удалены, в логе сборки есть пути файлов.