javascript

Хватит использовать JavaScript для решения задач CSS

  • среда, 28 января 2026 г. в 00:00:05
https://habr.com/ru/companies/timeweb/articles/983714/

Недостаток знаний часто подталкивает людей к чрезмерно сложным решениям, и рано или поздно это отражается на производительности.

Возьмем content-visibility: auto. Он дает тот же эффект, что и React-Window, но без единой строчки JS и без увеличения размера сборки. Аналогичная ситуация с современными единицами высоты окна (dvh, svh, lvh): наконец-то приведена в порядок "мобильная" высота, которую годами пытались компенсировать через window.innerHeight.

Обе технологии в 2024 году получили более 90% поддержки современных браузеров и полностью готовы для продакшна. Однако мы по привычке продолжаем решать такие задачи с помощью JS, просто потому, что CSS незаметно ушел вперед, пока мы спорили о React Server Components.

Эта статья поможет сократить этот разрыв. Мы рассмотрим бенчмарки, варианты перехода и честно разберемся, когда использование JS по-прежнему оправдано. Но для начала скажем очевидное: если вы используете useEffect или useState для устранения визуального бага, то, скорее всего, копаете не в том направлении.

❯ Проблема виртуализации в React

Разработчики React привыкли воспринимать библиотеки виртуализации — такие как react-window и react-virtualized — как универсальное решение для отображения списков. На первый взгляд логика действительно понятна: если пользователь одновременно видит всего 10 элементов, зачем рендерить все 1000? Виртуализация создает небольшое "окно" видимых элементов и размонтирует остальные по мере прокрутки.

Но дело не в том, что виртуализация — плохой подход. Проблема в том, что мы прибегаем к ней слишком рано и слишком часто. Сетка товаров на 200 позиций? react-window. Лента блога из 50 постов? react-virtualized.

Мы фактически выстроили вокруг производительности списков своеобразный карго-культ. Вместо того, чтобы проверить, справится ли браузер с задачей самостоятельно, мы сразу оборачиваем все в useMemo и useCallback и называем это "оптимизацией".

Вот как выглядит минимальная конфигурация react-virtualized:

import { List } from 'react-virtualized';
import { memo, useCallback } from 'react';

const ProductCard = memo(({ product, style }) => {
  return (
    <div style={style} className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <p>{product.description}</p>
    </div>
  );
});

function ProductGrid({ products }) {
  // Запоминаем функцию рендера строки, чтобы избежать лишних ререндеров
  const rowRenderer = useCallback(
    ({ index, key, style }) => {
      const product = products[index];
      return <ProductCard key={key} product={product} style={style} />;
    },
    [products]
  );

  return (
    <List
      width={800}
      height={600}
      rowCount={products.length}
      rowHeight={300}
      rowRenderer={rowRenderer}
    />
  );
}

Это решение работает отлично: около 50 строк кода, примерно, +15 KB к сборке и ручное определение высоты элементов и размеров контейнера. В целом — обычная история.

Но на этом разработчики React обычно не останавливаются. Мы привыкли гоняться за оптимизацией ререндеров и начинаем мемоизировать все подряд:

import { List } from 'react-virtualized';
import { memo, useCallback, useMemo } from 'react';

const ProductCard = memo(({ product, style }) => {
  return (
    <div style={style} className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <p>{product.description}</p>
    </div>
  );
});

function ProductGrid({ products }) {
  const rowCount = products.length;

  // Запоминаем функцию рендера строки, чтобы избежать лишних ререндеров
  const rowRenderer = useCallback(
    ({ index, key, style }) => {
      const product = products[index];
      return <ProductCard key={key} product={product} style={style} />;
    },
    [products]
  );

  // Сохраняем результат расчета высоты строки
  const rowHeight = useMemo(() => 300, []);

  return (
    <List
      width={800}
      height={600}
      rowCount={rowCount}
      rowHeight={rowHeight}
      rowRenderer={rowRenderer}
    />
  );
}

Посмотрите на этот код: useMemo(() => 300, []). Мы "запоминаем" константу. Оборачиваем компонент в memo(), чтобы избежать ререндеров, которых, скорее всего, итак не будет. Добавляем useCallback() для функции, которую react-window оптимизирует внутри.

Мы делаем все это не потому, что реально столкнулись с проблемой, а потому, что думаем: "так надо". Пока мы убирали гипотетические ререндеры, в CSS появилось нативное решение.

Оно называется content-visibility. Это свойство указывает браузеру пропускать рендеринг невидимого контента. Та же идея, что и у виртуализации, только браузер делает всю работу за нас — без JS, без сложных расчетов прокрутки, без настройки высоты элементов.

Вопрос не в том, работает ли виртуализация. Она работает. Вопрос в том, действительно ли она нужна в конкретных случаях. Большинство React-приложений работают со списками в сотни элементов, а не десятки тысяч. Для таких случаев content-visibility дает около 90% аналогичного эффекта при минимальной сложности.

❯ Что на самом деле делает content-visibility

Свойство content-visibility имеет три значения: visible, hidden и auto. Для производительности важно только auto.

Если применить content-visibility: auto к элементу, браузер пропускает расчет компоновки, стилей и рендеринг для этого элемента до тех пор, пока он не окажется близко к области просмотра. Ключевое слово здесь — "близко": браузер начинает рендерить элемент немного раньше, чем он появится на экране, чтобы прокрутка оставалась плавной. Как только элемент снова выходит из зоны видимости, браузер приостанавливает все эти вычисления.

Браузер итак знает, что видно на экране. Он уже использует API пересечения с областью просмотра и умеет оптимизировать прокрутку. content-visibility: auto просто дает браузеру разрешение пропускать лишние расчеты.

Используя content-visibility на той же сетке товаров, мы получаем следующее:

function ProductGrid({ products }) {
  return (
    <div className="product-grid">
      {products.map(product => (
        <div key={product.id} className="product-card">
          <img src={product.image} alt={product.name} />
          <h3>{product.name}</h3>
          <p>{product.price}</p>
          <p>{product.description}</p>
        </div>
      ))}
    </div>
  );
}

CSS:

.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 300px;
}

Две строчки CSS! Свойство contain-intrinsic-size сообщает браузеру, сколько места нужно зарезервировать для невидимого контента. Без него браузер считает, что эти элементы имеют нулевую высоту, и это "ломает" прокрутку. С ним же прокрутка остается плавной, потому что браузер примерно знает размер элемента, даже если он еще не отрендерен.

И это далеко не единственный случай, когда CSS берет на себя задачи, которые раньше решались через JS. Еще один важный пример — адаптивная верстка на основе размеров контейнера (запросы к контейнеру - container queries).

❯ Проблема запросов к контейнеру

Адаптивная верстка научила нас писать медиазапросы, ориентируясь на ширину экрана. Это работает до тех пор, пока компонент не окажется в боковом меню (сайдбаре). Компонент карточки товара должен менять макет в зависимости от ширины контейнера, а не экрана. К��рточка шириной 300px в сайдбаре должна выглядеть иначе, чем карточка той же ширины в основной части страницы, даже если ширина окна одинаковая.

Раньше разработчики сразу обращались к JS. Мы использовали ResizeObserver, отслеживали ширину контейнеров, переключали классы на разных контрольных точках (breakpoints) и обновляли компоновку при каждом изменении размера. Любой компонент, которому нужно было реагировать на размер контейнера, заканчивал тем, что JS измерял его ширину и присваивал нужные стили:

function updateCardLayout() {
  const cards = document.querySelectorAll('.card');
  cards.forEach(card => {
    const width = card.offsetWidth;
    if (width < 300) {
      card.classList.add('card--small');
    } else if (width < 500) {
      card.classList.add('card--medium');
    } else {
      card.classList.add('card--large');
    }
  });
}

const resizeObserver = new ResizeObserver(updateCardLayout);
document.querySelectorAll('.card').forEach(card => {
  resizeObserver.observe(card);
});

Более 20 строк JS для решения того, что на самом деле должно решаться с помощью CSS. Мы измеряем элементы DOM, управляем наблюдателями, добавляем обработчики событий и поддерживаем состояние классов. При этом браузер итак знает ширину контейнера.

CSS-запросы к контейнеру появились во всех основных браузерах в 2023 году. Они позволяют определять стили в зависимости от размера родительского контейнера, а не окна браузера:

.card-container {
  container-type: inline-size;
}

@container (min-width: 300px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
}

@container (min-width: 500px) {
  .card {
    grid-template-columns: 1fr 1fr;
  }
}

Три блока CSS — и браузер пересчитывает контейнерные запросы так же, как медиазапросы, вне основного потока. Компонент карточки автоматически реагирует на изменение ширины родительского контейнера.

Свойство container-type: inline-size сообщает браузеру, что перед ним контейнер, ширину которого могут запрашивать дочерние элементы. Правила @container работают так же, как @media, только проверяются размеры контейнера, а не всего окна.

Поддержка браузеров в 2025 году превышает 90%: Chrome 105+, Safari 16+, Firefox 110+. Если вы по-прежнему используете ResizeObserver для адаптивного поведения компонентов, значит, напрасно усложняете себе жизнь.

❯ Проблема анимации при прокрутке

Анимации, которые запускаются, когда элемент появляется в области видимости, всегда считались задачей для JS. Нужно, чтобы что-то плавно проявлялось при прокрутке — создаем экземпляр IntersectionObserver, отслеживаем видимость эл��мента, добавляем класс для запуска CSS-анимации, а затем снимаем наблюдатель, чтобы избежать утечек памяти:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('fade-in');
      observer.unobserve(entry.target);
    }
  });
});

document.querySelectorAll('.animate-on-scroll').forEach(el => {
  observer.observe(el);
});
.fade-in {
  animation: fadeIn 0.5s ease-in forwards;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

Это работает. Такой подход стал стандартом с тех пор, как в 2019 году появился IntersectionObserver. Почти каждый параллакс-эффект, плавное появление карточек и любая анимация, запускающаяся при прокрутке, строятся именно на этом шаблоне.

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

Анимации CSS, управляемые прокруткой (CSS scroll-driven animations) позволяют привязывать анимации к прогрессу прокрутки напрямую следующим образом:

@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-on-scroll {
  animation: fade-in linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 30%;
}

Свойство animation-timeline: view() привязывает прогресс анимации к тому, насколько элемент виден в области просмотра. А animation-range определяет, на каком этапе прокрутки анимация начинается и заканчивается. Все остальное берет на себя браузер.

При этом, анимация выполняется в потоке компоновки, а не в основном потоке. Колбэки IntersectionObserver работают в основном потоке. Если JS занят рендерингом React-компонентов или обработкой данных, вызовы IntersectionObserver будут обрабатываться с задержкой. Анимации, управляемые прокруткой, работают плавно, потому что им не приходится делить время выполнения с JS.

Поддержка браузеров стала удовлетворительной в 2024 году: Chrome 115+ (август 2023), Safari 18+ (сентябрь 2024). Firefox поддерживает технологию в экспериментальном режиме. Сейчас охват уже превышает 75%, поэтому анимации, управляемые прокруткой, можно спокойно использовать как прогрессивное улучшение, а для старых браузеров оставить IntersectionObserver в качестве резерва.

Главное преимущество — производительность. Анимации, управляемые прокруткой, декларативны. Мы просто описываем, какую анимацию запускать и когда. Браузер сам оптимизирует выполнение. В случае с IntersectionObserver приходится императивно управлять состоянием, добавлять классы и надеяться, что колбэк сработает вовремя.

❯ Когда все же стоит использовать JS

CSS не всегда универсальное решение. Есть ситуации, когда JS действительно подходит лучше, и игнорировать это было бы нечестно.

Случаи использования JS для виртуализации:

  • По-настоящему большие или "бесконечные" списки из 1000+ элементов. content-visibility загружает весь список в DOM, даже если отдельные элементы не рендерятся. При большом количестве элементов может возникнуть проблем�� с памятью. Виртуализация же создает DOM-узлы только для видимых элементов, что значительно снижает расход памяти.

  • Список с переменной или непредсказуемой высотой элементов, которая меняется после рендеринга. Для корректной работы content-visibility необходим contain-intrinsic-size. Но если элементы динамически растут или уменьшаются — из-за пользовательского взаимодействия или подгружаемого контента — заранее вычислить размеры трудно. Библиотеки виртуализации справляются с этим благодаря встроенным API измерения размеров.

  • Требуется точное управление элементами списка и положением прокрутки. Например, если нужно мгновенно перейти к строке 5000 в таблице данных или восстановить точное положение прокрутки после перезагрузки страницы. Виртуализация предоставляет API для таких сценариев. content-visibility не дает такого уровня контроля.

Случаи использования JS для управления компоновкой:

  • Логика зависит от точных измерений. Контейнерные запросы позволяют CSS адаптировать макет под размер контейнера, но если требуется, например, точно определить ширину контейнера в 247px, приходится использовать ResizeObserver или getBoundingClientRect().

  • Макет слишком динамичный для CSS. Если создается дашборд с перетаскиваемыми панелями, изменяемыми колонками и правилами компоновки, зависящими от состояния и вычислений, это уже зона ответственности JS.

Случаи использования JS для анимации:

  • Требуется запускать функции в определенные моменты анимации. Анимации, управляемые прокруткой, не вызывают событий начала или завершения анимации. Если анимация запускает загрузку данных или обновляет состояние приложения, по-прежнему нужны IntersectionObserver или обработчики события прокрутки.

❯ Заключение

Когда использовать CSS, а когда — JS?

  1. Сначала проверьте, может ли CSS справиться с задачей напрямую. Если да — используйте CSS.

  2. Если нет — попробуйте подход прогрессивного улучшения: сначала современный CSS, а в качестве запасного варианта — JS.

  3. JS стоит использовать только тогда, когда CSS действительно не справляется.

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

Разберитесь, что реально требуется интерфейсу. Замерьте производительность. Затем выберите самый простой инструмент, который решает задачу. В большинстве случаев это будет CSS.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Перед оплатой в разделе «Бонусы и промокоды» в панели управления активируйте промокод и получите кэшбэк на баланс.