Frontend-разработка CSS с нуля: каскад, специфичность, наследование, единицы измерения
0%

CSS с нуля: каскад, специфичность, наследование, единицы измерения

CSS с нуля: каскад, специфичность, наследование, единицы измерения

Типичная траектория знакомства с CSS: «тут же всего три свойства» → «почему оно не применяется?!» → !important → «почему теперь вообще ничего не применяется?!». Причина в том, что CSS учат как справочник свойств, а устроен он как система разрешения конфликтов. Свойств сотни, держать их в голове не нужно — есть MDN. А правил разрешения конфликтов и стадий вычисления значения всего несколько, и они объясняют почти всю «магию».

Эта статья — про фундамент: как объявление превращается в число на экране. Раскладку (flexbox, grid, позиционирование) разбирает Вёрстка, организацию файлов и методологии — Архитектура стилей; здесь механика, на которой стоит и то и другое.

1. CSS — декларативный решатель ограничений

В Canvas вы рисуете императивно: поставь курсор сюда, залей прямоугольник. В CSS вы не рисуете — вы объявляете ограничения: «у заголовков размер 1.5em», «карточка не шире 40rem», «между элементами 1rem». Движок собирает из всех объявлений систему и решает её для конкретного окна, шрифта, языка и настроек пользователя. Отсюда два следствия.

  1. Конфликты — норма, а не ошибка. Стили браузера, пользователя, дизайн-системы, библиотеки компонентов и страницы одновременно говорят про font-size. Каскад — детерминированный алгоритм выбора победителя; без него веб не работал бы вовсе.
  2. Вы почти никогда не задаёте пиксели. Вы задаёте отношения (em, %, fr, clamp()), пиксели вычисляет браузер. Чем меньше «магических чисел», тем лучше макет переживает чужой шрифт, длинное немецкое слово и зум 200%.

Как из HTML и CSS вообще получается картинка — в статье Как работает браузер; здесь только этап «вычислить стили».

2. Конвейер значения: шесть стадий одного числа

У свойства не одно значение, а цепочка. CSS Cascade 5 называет стадии так:

  • Declared — все объявления, чей селектор совпал с элементом, из всех источников; Cascaded — победитель по правилам каскада (раздел 3), максимум один на свойство.
  • Specified — если победителя нет: наследуемое свойство берёт значение родителя, ненаследуемое — initial из спецификации.
  • Computed — абсолютизация без знания раскладки: 1.5em24px, bold700, относительный URL → абсолютный. Именно computed-значение наследуется детям, поэтому размеры не растут экспоненциально по дереву.
  • Used — то, что считается только после layout (width: 50%384px, flex: 1 → доля места), а Actual — округление под пиксельную сетку: 33.333px при DPR 2 станет 33.5px.

getComputedStyle() возвращает resolved value: computed для большинства свойств, used для геометрии.

const cs = getComputedStyle(document.querySelector('.card'));
cs.fontSize;                    // "24px"  — computed, em уже раскрыт
cs.width;                       // "384px" — used, проценты уже посчитаны
cs.getPropertyValue('--gap');   // " 1rem" — кастомные свойства НЕ абсолютизируются

3. Каскад: кто побеждает и в каком порядке

Каскад сортирует конфликтующие объявления по цепочке критериев; следующий критерий смотрят только при ничьей на предыдущем.

Порядок происхождения и важности в каскаде CSS

Полный порядок сортировки (CSS Cascade Level 5, §6.10):

  1. Происхождение и важность: transition!important браузера → !important пользователя → !important автора → @keyframes → обычные автора → обычные пользователя → обычные браузера.
  2. Контекст инкапсуляции. Обычные объявления из shadow-дерева сильнее внешних; для !important — наоборот, внешний документ побеждает.
  3. Инлайновый style — сильнее любого обычного правила того же происхождения.
  4. Слой (@layer). Позже объявленный слой сильнее; правила вне слоёв сильнее всех слоёв.
  5. Специфичность (раздел 4).
  6. Порядок в исходнике — кто ниже, тот и прав.

Обратите внимание: слой сравнивается раньше специфичности. Правило .bg-brand из слоя utilities победит #page .sidebar a.btn из components, хотя у второго специфичность выше на порядок. В этом и ценность слоёв — они выключают гонку специфичности.

/* Порядок объявления слоёв задаёт приоритет — один раз, в самом верху бандла */
@layer reset, base, components, utilities;

@layer components {
  #page .sidebar a.btn { background: #1f2937; }   /* 0,3,1 — но слой младше */
}
@layer utilities {
  .bg-brand { background: #2563eb; }              /* 0,1,0 — и всё равно побеждает */
}
/* Вне слоёв — сильнее любого слоя, поэтому сюда ничего не кладут «случайно» */
.debug-outline { outline: 2px solid magenta; }

Тонкости: !important переворачивает порядок слоёв (важное из reset победит важное из utilities) и делает незаслоённые стили слабее заслоённых; слой можно объявить пустым и наполнять по частям в любом порядке импорта — @import url(vendor.css) layer(vendor);. Допустимые применения !important: пользовательские таблицы стилей и расширения; utility-классы, если слоёв нет; переопределение виджета, который пишет инлайн; временный дебаг. Всё остальное — симптом непостроенной архитектуры: как только !important появляется с обеих сторон, гонка переезжает этажом выше.

4. Специфичность: вектор из трёх разрядов

Специфичность селектора — тройка (A, B, C), которая сравнивается лексикографически, как версии пакетов. Переносов между разрядами нет: одиннадцать классов не догоняют один id.

Специфичность как вектор из трёх разрядов

Составляющая селектора Разряд Пример
#id A #header → 1,0,0
.class, [attr], :hover, :focus-visible B .card[data-open]:focus → 0,3,0
тег, ::before, ::first-line C ul li::marker → 0,0,3
*, >, +, ~, пробел, :where() всегда 0

Что забывают чаще всего:

  • :is(), :has(), :not() берут специфичность самого сильного аргумента: :not(#x) — это 1,0,0, хотя визуально там «отрицание».
  • :where() всегда 0,0,0 — официальный инструмент «отдать стиль без боя», идеален для базовых стилей и библиотек: прикладной код переопределит одним классом.
  • Нативная вложенность & ведёт себя как :is(...) от родительских селекторов, то есть вложенность специфичность повышает — ровно та же ловушка, что когда-то в Sass.
  • [id="header"] весит как класс (0,1,0), а не как id — иногда это удобный способ снизить вес. Инлайновый style в вектор вообще не входит: он выигрывает на более раннем шаге сортировки.
@layer base {
  /* Библиотечный приём: базовые стили с нулевой специфичностью */
  :where(a[href]) { color: var(--link, #2563eb); text-underline-offset: 0.15em; }
  :where(ul, ol)[class] { list-style: none; padding-inline-start: 0; }
}
.footer a { color: currentColor; }   /* перебивается самым простым селектором */

Обратный приём — «удвоение класса» .btn.btn (0,2,0) вместо !important — костыль, но иногда единственный способ перебить чужую библиотеку. Здоровая же стратегия: держать специфичность плоской (один класс на компонент), а конфликты разруливать слоями. Про методологии — в Архитектуре стилей.

5. Как браузер это считает

Представляйте себе не «поиск по селекторам», а инвертированный индекс: правила сгруппированы по правому крайнему компоненту селектора (id, класс, тег), и для элемента достаются только подходящие корзины. Сопоставление идёт справа налево — так дешевле отбрасывать кандидатов.

  • Сложность селектора почти никогда не бутылочное горлышко. Дороже — объём инвалидации: сколько элементов придётся пересчитать после изменения.
  • Смена класса на <body> или переменной на :root инвалидирует весь документ; смена класса на карточке — только её поддерево. Это разница между 40 мс и 0.4 мс на recalc style. По той же причине :has() мощный, но опасный: body:has(.modal-open) стоит заметно, .card:has(> img) — почти ничего.

Измерять: DevTools → Performance с галочкой Selector stats, запись взаимодействия, блок Recalculate Style → вкладка Selector Stats — видно, сколько раз каждый селектор матчился и сколько это заняло. Неиспользуемый CSS — панель Coverage (Ctrl+Shift+PCoverage).

6. Наследование: что течёт вниз по дереву

Наследуются в основном типографские и «текстовые» свойства — там, где наследование интуитивно.

Наследуются Не наследуются
color, font-*, line-height, letter-spacing background-*, border-*, box-shadow
text-align, text-indent, white-space, word-break display, position, width, height
visibility, cursor, direction, list-style-* margin, padding, overflow, z-index
hyphens, quotes, text-transform, caret-color transform, opacity, filter, contain

Ключевая тонкость: унаследованное значение слабее любого объявления, включая стиль браузера. Отсюда вечная пара сюрпризов:

.article { color: #333; }                  /* у a есть UA-объявление color, наследование не сработает */
.article a { color: inherit; }             /* объявление бьёт UA-объявление */
button, input, select, textarea { font: inherit; color: inherit; }  /* формы — то же самое */

/* Пять универсальных значений, которые есть у любого свойства */
.x { color: inherit; }       /* значение родителя, даже для ненаследуемых свойств */
.x { color: initial; }       /* initial из СПЕЦИФИКАЦИИ, а не «как было у браузера» */
.x { color: unset; }         /* inherit для наследуемых, initial для остальных */
.x { color: revert; }        /* откат к предыдущему происхождению: пользователь/браузер */
.x { color: revert-layer; }  /* откат к значению из предыдущего слоя */

.link-button { all: unset; cursor: pointer; color: var(--link); }  /* полный сброс кнопки */

Разница initial и revert — частая путаница: display: initial даст inline (initial-значение по спецификации), а не block для <div>; «как у браузера» возвращает только display: revert. А revert-layer незаменим при интеграции чужой дизайн-системы: в своём слое точечно отменяете собственное переопределение и падаете обратно на вендорское.

7. Кастомные свойства: не переменные препроцессора

--foo — полноценное CSS-свойство с произвольным именем и типом «поток токенов»; отсюда особенности.

:root {
  --space: 1rem;
  --brand: oklch(0.6 0.18 255);
  --surface: light-dark(#ffffff, #16181d);   /* требует color-scheme: light dark */
}
.card {
  padding: var(--space);
  gap: calc(var(--space) / 2);       /* производная считается в момент подстановки */
  border-radius: var(--radius, 8px); /* fallback после запятой, может быть любым */
}
  1. Наследуются. Переопределив --space на .compact, вы поменяете отступы во всём поддереве — это делает их идеальным механизмом тем и вариантов компонента.
  2. Ленивые. Значение хранится как токены и разбирается только при подстановке: --x: 10 px спокойно доживёт до продакшена и рванёт при использовании.
  3. Ошибка подстановки = IACVT (invalid at computed-value time): свойство становится унаследованным или initial, а не откатывается к предыдущему объявлению. Классическая ловушка:
.card { padding: 12px; }                      /* «запасной вариант», который не сработает */
.card { --pad: 12; padding: var(--pad); }     /* итог: padding = 0, а не 12px */
  1. Нельзя использовать в условиях @media, в именах свойств и селекторах — только в значениях; для условий по значению переменной есть @container style(--theme: dark).
  2. @property даёт им тип: значение валидируется, есть initial-value, можно запретить наследование и — главное — анимировать:
@property --angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
.spinner {
  background: conic-gradient(from var(--angle), #2563eb, transparent);
  transition: --angle 400ms linear;   /* без @property анимация невозможна */
}
.spinner:hover { --angle: 360deg; }

Про цену: изменение переменной на :root пересчитывает стиль всем, кто её читает. Правило — объявляйте её на самом узком элементе, которому она нужна, а частые обновления (drag, scroll) пишите из JS в конкретный узел:

document.documentElement.style.setProperty('--x', `${e.clientX}px`); // плохо: весь документ
card.style.setProperty('--x', `${e.clientX - rect.left}px`);         // хорошо: только карточка

8. Единицы измерения: во что верить

Единиц много, но выбор сводится к вопросу «от чего это должно зависеть».

Абсолютные. px в CSS — не физический пиксель, а эталонный: угловая единица, привязанная к 1/96 дюйма на расстоянии вытянутой руки. Соотношения зафиксированы (1in = 96px, 1pt = 1/72in), а на устройстве один CSS-пиксель занимает 2, 3 или 2.75 физических — это devicePixelRatio. Единицы cm, mm, pt осмысленны только в @media print.

Шрифтовые.

  • em — размер шрифта самого элемента, но внутри font-size берётся размер родителя. Отсюда компаундирование: вложенные списки с font-size: 0.9em уменьшаются по экспоненте.
  • rem — размер шрифта корня, то есть по умолчанию настройка пользователя (обычно 16px, у слабовидящих 20–24px). Не пишите html { font-size: 16px } — это молча ломает настройку. Приём html { font-size: 62.5% } пропорциональность сохраняет, но требует, чтобы дальше всё было в rem.
  • ch — ширина продвижения символа «0», cap — высота заглавной, ex — высота строчной «x», ic — ширина CJK-глифа; lh/rlh — высота строки элемента и корня. max-width: 68ch держит комфортную длину строки при любой гарнитуре, lh выравнивает иконки по ритму текста без магических чисел.

Вьюпортные: vw, vh, vmin, vmax, логические vi/vb. Две ловушки. Первая: 100vw включает ширину скроллбара на десктопе — отсюда классический горизонтальный скролл, безопаснее 100% или 100dvw. Вторая: 100vh на мобильных считался от «большого» вьюпорта без адресной строки; сейчас есть svh (панели видны), lvh (панели скрыты) и dvh (меняется на лету) — для «экран целиком» берите 100dvh, для гарантированно видимой области 100svh.

Проценты считаются от разного, и это надо знать наизусть:

Свойство База процента
width, margin-*, padding-* (включая -top/-bottom!) ширина содержащего блока
height высота содержащего блока, и только если она определена
line-height собственный font-size; вычисляется сразу и наследуется как длина
transform: translate() размер самого элемента
background-position разница размеров контейнера и картинки

Из-за строки про padding работал старый трюк с соотношением сторон (padding-top: 56.25%) — сегодня это aspect-ratio: 16 / 9. Из-за строки про line-height действует твёрдое правило: задавайте его без единиц1.5 наследуется как коэффициент и пересчитывается от шрифта ребёнка, а 150% — как готовые пиксели, и это ломает крупные заголовки.

Функции calc(), min(), max(), clamp() превращают единицы в выражения:

.layout { width: calc(100% - 2rem); }   /* вокруг + и - обязательны пробелы */
.container {
  width: min(72rem, 90%);               /* не шире 72rem, но с полями 5% */
  padding-inline: max(1rem, 4vw);       /* нижняя граница отступа */
}
/* флюидная шкала: минимум, предпочтительное выражение, максимум */
:root { --step-3: clamp(2rem, 1.4rem + 3vw, 3.5rem); }
h1 { font-size: var(--step-3); }

Критично для доступности: в среднем слагаемом clamp() обязана быть rem-часть. Чистый font-size: 4vw не реагирует ни на зум, ни на настройку размера шрифта — прямое нарушение WCAG 1.4.4 (текст масштабируется до 200%). Согласованную шкалу удобно сгенерировать на utopia.fyi. И заведите привычку к логическим свойствам (margin-inline, padding-block) — они сами переворачиваются для RTL-языков.

9. Почему «тормозит» из-за CSS и как это измерить

CSS влияет на все три Core Web Vitals — и обычно не там, где ищут.

LCP. Внешний CSS блокирует рендер: пока таблица не загружена и не разобрана, дерева отрисовки нет. Лишний @import внутри CSS добавляет полный round-trip, потому что про вложенный файл браузер узнаёт только после разбора внешнего. Лечение — инлайнить критическое и грузить остальное неблокирующе:

<style>/* критический CSS первого экрана, ~10–14 КБ */</style>
<link rel="stylesheet" href="/assets/rest.css" media="print" onload="this.media='all'">
<noscript><link rel="stylesheet" href="/assets/rest.css"></noscript>

CLS. Сдвиги дают картинки без width/height или aspect-ratio, веб-шрифты с метриками, не совпадающими с фолбэком, и баннеры, вставляемые в поток:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: swap;
  size-adjust: 107%;      /* подгон под метрики системного фолбэка */
  ascent-override: 90%;
}
.hero { aspect-ratio: 16 / 9; }

INP. Медленный отклик часто упирается не в JS, а в recalc style и layout после смены класса на корне. Помогают узкая зона инвалидации (раздел 5), contain: layout style на независимых блоках и content-visibility: auto для длинных списков за пределами экрана. Порядок измерения:

npx vite build && ls -lh dist/assets/*.css     # бюджет: сколько CSS уезжает пользователю
npx lighthouse https://example.com --only-categories=performance --view   # render-blocking
npm i web-vitals                               # полевые данные с реальных пользователей
import { onLCP, onCLS, onINP } from 'web-vitals';
// attribution подскажет виновника: для LCP — элемент, для CLS — источник сдвига
const send = (m) => navigator.sendBeacon('/rum', JSON.stringify({ name: m.name, value: m.value }));
onLCP(send); onCLS(send); onINP(send);

Дальше — DevTools → Performance → полосы Recalculate Style и Layout: если recalc style стабильно больше 5–8 мс на взаимодействие, ищите широкие селекторы, переменные на :root и классы на <body>. Бюджеты — в Производительности фронтенда, попадание CSS в бандл и чанки — в Сборке фронтенда.

10. Отладка: «почему мой стиль не применился»

Девяносто процентов вопросов закрываются одним проходом по дереву решений.

  • Styles — перечёркнутое объявление означает проигранный каскад, жёлтый треугольник — невалидное значение, кнопка :hov форсирует :hover/:focus-visible/:active.
  • Computed с галочкой Show all — итоговые значения, стрелка к правилу-победителю и пометка о наследовании; Elements → Layout — оверлеи grid и flex для вёрстки.
CSS.supports('width', '1cqi');   // поддержка единицы; в CSS то же самое даёт @supports
el.matches('.card:has(> img)');  // совпадает ли селектор с элементом

11. Типичные ошибки

  1. Гонка специфичности. Каждое исправление добавляет селектор слева, через год — #app .page .sidebar ul li a.link.is-active. Лечение: слои и один класс на компонент.
  2. !important как обычный инструмент — работает ровно до второго !important.
  3. px для шрифта и ритма игнорирует настройку пользователя: текст и отступы — в rem.
  4. line-height с единицами наследуется как длина и ломает заголовки.
  5. Забытый box-sizing: width: 100% плюс padding даёт переполнение — как 100vh на мобильных и 100vw со скроллбаром, только по другой оси.
  6. Компаундирование em: .menu li { font-size: 0.9em } — и третий уровень уже нечитаем.
  7. Переменные только на :root — компонент не настраивается, а каждое изменение инвалидирует документ. Рядом стоит расчёт на «фолбэк-объявление» перед var(): IACVT обнуляет свойство.
  8. Стили, привязанные к структуре DOM (div > div > span) вместо семантики и состояний. Про правильные «крючки» — в Семантическом HTML.

Минимальный современный базовый слой закрывает половину списка:

@layer reset, base, components, utilities;
@layer reset {
  *, *::before, *::after { box-sizing: border-box; margin: 0; }
  html { -webkit-text-size-adjust: 100%; }
  body { min-block-size: 100dvh; line-height: 1.5; }
  img, picture, video, canvas, svg { display: block; max-inline-size: 100%; }
  input, button, textarea, select { font: inherit; color: inherit; }
  p, h1, h2, h3, h4 { overflow-wrap: break-word; }
  :where(h1, h2, h3, h4) { text-wrap: balance; line-height: 1.2; }
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
      animation-duration: 0.01ms !important; transition-duration: 0.01ms !important;
    }
  }
}

Блок prefers-reduced-motion — не косметика: для части пользователей анимации вызывают физическое недомогание. Эту и другие настройки (prefers-contrast, forced-colors) разбирает трек «Доступность».

12. Мини-итог

  • У значения шесть стадий; наследуется computed, а видите вы обычно used.
  • Каскад сортирует по цепочке: происхождение и важность → контекст → инлайн → слой → специфичность → порядок. Слой раньше специфичности — в этом весь смысл @layer.
  • Специфичность — вектор (A, B, C), сравнение лексикографическое, переносов между разрядами нет: :where() обнуляет, :is()/:not()/:has() берут максимум из аргументов.
  • Унаследованное значение слабее любого объявления, включая стиль браузера — отсюда font: inherit. Кастомные свойства наследуются, вычисляются лениво и при ошибке дают IACVT, а не откат.
  • Единицу выбирают по вопросу «от чего это зависит»: rem — от пользователя, em/ch — от шрифта контекста, dvh/cqi — от контейнера, px — ни от чего. А цена CSS — это размер таблицы стилей и ширина зоны инвалидации, а не «сложные селекторы».

Источники

Что дальше

Мы разобрали, как получается значение. Следующий шаг — как из значений получается раскладка: почему flex сжимает элементы, откуда берётся свободное место в grid и чем контейнерные запросы лучше медиазапросов в компонентном коде.

Вёрстка: flexbox, grid, позиционирование, адаптивность и контейнерные запросы

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов