Webrium
Подписаться

Что современный CSS убрал из вашего JS

Почему JavaScript-наблюдатели за внешним видом опаздывают на кадр, какие механизмы CSS решают эти задачи внутри конвейера рендеринга и где скрипты остаются обязательными

Два стыка: CSS встроен в конвейер рендеринга и совпадает с кадром, JS-наблюдатель стоит снаружи и разошёлся — опаздывает на кадрCSS в конвейереJS опаздывает

Откройте любой поживший фронтендовый компонент и посчитайте, сколько в нём JavaScript’а, который занимается не логикой, а внешним видом. Слушатель скролла, ужимающий хедер. ResizeObserver, перекрашивающий карточки при смене ширины родителя. Обработчик клика, вешающий класс на карточку с отмеченной радиокнопкой. Функция, которая меряет textarea и растягивает её под текст. Модальный менеджер: фокус-трап, ESC, блокировка скролла, война с z-index.

У всего этого есть ответ в современном CSS, и это не «ещё одна фича для любителей новинок». Ответ работает лучше не потому, что он короче, а потому что живёт в другом месте конвейера рендеринга. Дальше — механика: почему JS-версии этих вещей ломаются именно так, что можно удалить и где проходит граница, за которой JS по-прежнему обязателен.

Наблюдатель всегда опаздывает

Кадр в браузере собирается в фиксированном порядке: события → задача JS → пересчёт стилей → layout → отрисовка → композиция. JS-обработчик стоит в начале конвейера и смотрит на его результаты — геометрию, размеры, видимость — снаружи. Ему приходится читать то, что конвейер ещё не вычислил, и писать то, что обесценит вычисленное. Дальше — два дефекта, которые вы узнаете по симптомам.

Первый — принудительный синхронный layout. Обработчик скролла читает getBoundingClientRect или offsetHeight; если с прошлого кадра что-то менялось, браузер обязан пересчитать layout немедленно, прямо внутри обработчика. Затем обработчик пишет стили — и обесценивает собственные измерения. Читал, писал, читал: каждый цикл — оплаченный layout, который не попадает в текущий кадр, а ложится в следующий.

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

Отдельный подвид — matchMedia, попытка JS синхронизироваться с медиазапросами. Брейкпоинты оказываются записаны в двух местах, в CSS и в JS-константах, и дрейф между ними не ловит ни один линтер — каждый файл по отдельности корректен.

У CSS-ответов нет зазора, потому что нет наблюдателя. Контейнерные запросы вычисляются в том же проходе пересчёта стилей, в котором движок и так определяет размер контейнера: вопрос «карточка шире порога?» задаёт система, у которой ответ уже есть. Скролл-таймлайны — вообще не события: прогресс анимации — функция смещения прокрутки, движок сэмплирует её там, где рисует кадр, в идеале не трогая главный поток. Никто не читает снаружи и не догоняет задним числом.

Что именно переехало

Паттерн, который убирает :has(), знаком каждому: чекбокс меняется, обработчик ставит класс на родительскую карточку, стили реагируют на класс. Здесь два носителя одного состояния — свойство checked и класс-отражение. Каждая дорожка, по которой состояние может измениться — мышь, клавиатура, form.reset(), программная установка, ре-рендер фреймворка, — обязана помнить о синхронизации. Забыли одну — рассинхрон. С :has() отражение не нужно: стиль — чистая функция от DOM-состояния. Синхронизировать нечего, забывать нечего, гидрировать нечего.

.field:has(input:user-invalid) .hint { display: block; }

Контейнерные запросы убивают петлю ResizeObserver целиком: компонент спрашивает не о вьюпорте, а о собственном родителе, и честно ответить может только движок. JS-версии пришлось бы наблюдать за родителем вручную — со всеми гонками.

Скролл-реакции переезжают на скролл-таймлайны:

.site-header {
  block-size: var(--header-full);
  animation: shrink linear both;
  animation-timeline: scroll(root);
}
@keyframes shrink {
  to { block-size: var(--header-collapsed); }
}

Прогресс привязан к прокрутке документа, слушателей нет. Тот же механизм с view() вместо scroll() закрывает «появление при доскролле»: секции проявляются по мере входа в вьюпорт, IntersectionObserver не нужен. Оговорка: если хочется остаться на композиторе, анимируйте transform, а не высоту.

Ритуал появления из display: none держался долго, потому что display — дискретное свойство и через него переходы не идут. Код выглядел как цепочка синхронизаций: снять display: none, прочитать offsetHeight, чтобы форсировать пересчёт, поставить класс, дождаться transitionend, убрать класс. Каждое звено — место, где всё ломается: ресайз посреди анимации, изменение контента, гонка с другим эффектом. Теперь у словаря есть недостающее слово:

.toast {
  opacity: 0;
  transition: opacity .2s, display .2s allow-discrete;
}
.toast:popover-open {
  opacity: 1;
  @starting-style { opacity: 0; }
}

@starting-style описывает состояние «до первого кадра», allow-discrete говорит, в каком конце перехода переключать display, а сам display меняет механизм popover — без ручных классов.

Высота auto. Анимация «до auto» не работала, потому что auto — не число и интерполировать нечего. JS мерил scrollHeight, ставил пиксели, анимировал, на transitionend возвращал auto. interpolate-size и calc-size() позволяют интерполировать само ключевое слово: измерение переезжает внутрь layout, где оно всегда свежее.

Служебное, одной строкой: авторост textarea — field-sizing: content.

Модальности — popover и dialog. Самописный модал — война со страницей: z-index против чужих стекинг-контекстов, блокировка скролла через overflow, фокус-трап с ручной математикой Tab и Shift+Tab, восстановление фокуса, ESC. Атрибут закрывает всё это не потому, что «готовое удобнее», а потому что верхний слой, фокус и блокировка скролла принадлежат браузеру: страница не выиграет z-index у собственных стекинг-контекстов, сколько ни повышай. Фокус — не удобство, а доступность, а доступность — инженерная задача, а не галочка; у нас о ней есть отдельный материал.

Задача Было в JS Стало в CSS
Стиль по состоянию потомка слушатель + класс на родителе :has()
Реакция на размер контейнера ResizeObserver + инлайн-стили @container
Реакция на скролл scroll + rAF animation-timeline: scroll() / view()
Появление из display: none чтение offsetHeight, двойной rAF @starting-style + allow-discrete
Анимация до auto scrollHeight + transitionend interpolate-size, calc-size()
Авторост textarea измерение на каждый ввод field-sizing: content
Модал, тултип менеджер: фокус, ESC, скролл-лок popover, dialog

Мы уже писали, что размер бандла перестал быть главной метрикой. Здесь метрика ещё проще: код, которого нет, не надо грузить, парсить и гидрировать; он работает до загрузки JS и не падает вместе с ним. Это продолжение того же движения, о котором мы говорили в материале про серверные компоненты: меньше клиентского кода — больше работы, которую платформа делает сама.

Тупик: ускорить слушатель

Первое, что пробуют с дёргающимся скролл-обработчиком, — ускорить его: пассивный слушатель, троттлинг, debounce, батчинг в rAF, кэш измерений. Тупик объясняется по механизмам, а не по вкусу.

Пассивный слушатель снимает только налог «не скроллить, пока обработчик не отработал». Но дёрганье создавал не этот налог: обработчик всё так же читает layout и пишет стили, каждая итерация платит за пересчёт.

Debounce и троттлинг — осознанная сделка со свежестью: интерфейс какое-то время показывает заведомо неверное состояние. На ресайзе это видимая поломка вёрстки в окне ожидания, на скролле — перелёт хедера.

Батчинг в rAF выравнивает записи по кадру, но чтения всё равно форсируют синхронный layout, если с прошлой записи что-то менялось. В лучшем случае — то же опоздание на кадр, зато с лишней бухгалтерией синхронизации, которую теперь надо поддерживать.

Кэш измерений ломается первым же догрузившимся шрифтом, декодировавшейся картинкой или соседним компонентом, который что-то записал в DOM.

Общий знаменатель: стоимость цикла не в теле обработчика, а в его существовании. Наблюдатель снаружи, который патчит то, за чем наблюдает, настройками до нуля не выравнивается — можно только уменьшить дёрганье. Побочная ветка того же тупика — полифиллы: полифилл контейнерных запросов — это тот же ResizeObserver с шильдиком CSS, грузится и наблюдатель, и стили поверх него.

Работает не ускорение, а удаление: у движка ответ уже есть в том проходе, где он нужен.

Чего CSS не видит

Граница проходит по входу поведения. Вход — состояние рендера: какой размер, где прокрутка, что отмечено в DOM? Это CSS. Вход — смысл действия или данные снаружи DOM? Это JS: отправка формы, аналитика, многошаговые потоки с побочными эффектами, координация между окнами. Всё, что должно «делать», а не «выглядеть».

И честная оговорка про сам CSS: у него нет канала ошибок. Неизвестное свойство молча игнорируется — ни исключения, ни лога. Единственная обработка ошибок — @supports и деградация. Это одновременно сила: прогрессивное улучшение — родной режим CSS, а не надстройка. И дисциплина: фолбэк закладывают до того, как свойство не сработало.

Позиция редакции

Наша позиция: поведение, вход которого — состояние рендера, по умолчанию принадлежит CSS. Не потому, что так чище, а потому что владелец данных и их потребитель оказываются в одном проходе конвейера. Бремя доказательства — на стороне JS: сначала объясните, почему конвейер рендеринга не может владеть этим поведением, потом пишите хук.

Мы неправы, если матрица поддержки проекта перечисленного не включает и не включит: долгоживущие вебвью, закрытые окружения с зафиксированными браузерами. Тогда единственная честная архитектура — JS-слой наблюдателей поверх старого CSS, и «CSS по умолчанию» становится роскошью. Второе условие: если вход поведения в принципе не наблюдаем из DOM, спор не про CSS — он и не начинался.

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

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Сетка ячеек с заполненной частью изображает вес сборки, а рядом стоит отдельный счётчик другой величины — путь до первого действия: одно число больше не определпуть до действия
Усиление

Размер бандла больше не главная метрика

Облегчить бандл — ещё не значит ускорить приложение: война с общим весом сборки ведёт в тупик. Почему байт не равен байту и что замерять вместо общего числа.

Цепочка шагов аудита доступности: автоматические проверки разметки лежат внутри границы, а последний шаг — поведение по Tab с реальными барьерами — выходит за евидит автоматбарьер в поведении
Практика

Доступность как инженерная задача, а не как галочка

Барьеры живут в поведении, и чек-лист их не ловит: пройдите сайт без мыши, опирайтесь на нативные контракты, наведите CSS-дисциплину — и удержите результат тестами.

Поле разделено швом «use client»: элементы-пропсы пересекают границу между сервером и клиентом и становятся сетевым контрактомсерверклиент
Предыстория

Серверные компоненты: год в продакшене

Прежде чем разбираться, что CSS забрал у JS, полезно знать, как серверные компоненты пересылают дерево по проводам и почему проп через границу становится частью сетевого контракта.

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

выбрать · Enter открыть · Esc закрыть