Когда страница подвисает при скролле или анимация дёргается вместо плавного движения, причина почти всегда одна: браузер заново пересчитывает геометрию блоков там, где этого можно было избежать. Чтобы понять, откуда берётся это торможение, нужно разобраться, что происходит между моментом, когда сервер отдал HTML файл, и моментом, когда пользователь увидел готовую страницу. Путь от разметки до картинки на экране состоит из нескольких строгих этапов, и на каждом из них браузер выполняет конкретную работу, которую можно замерить, ускорить или сломать одной неаккуратной строкой кода.

Как браузер получает HTML и превращает его в объектную модель документа

Первый шаг начинается сразу после того, как браузер получил байты HTML документа по сети. Парсер построчно читает разметку и превращает её в дерево объектов, которое называется Document Object Model, сокращённо DOM. Это не копия текста файла, а полноценная структура в памяти, где каждый тег становится узлом со своими свойствами, методами и связями с соседними узлами. Именно поэтому JavaScript может обратиться к любому элементу страницы, изменить его атрибут или добавить новый дочерний узел без перезагрузки всего документа.

Парсинг HTML устроен построчно и последовательно. Если во время чтения разметки браузер встречает тег script без атрибута async или defer, он останавливает построение DOM, скачивает и выполняет скрипт, и только потом продолжает парсинг дальше. Это блокирующее поведение объясняет, почему тяжёлые синхронные скрипты в head замедляют первую отрисовку страницы. Такая же логика касается и стилей: пока браузер не получит и не разберёт все подключённые CSS файлы, он не сможет построить следующую структуру в конвейере рендеринга.

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

Из чего состоит DOM Tree и почему он является живой структурой а не статичным текстом

DOM Tree отражает вложенность тегов документа: элемент html является корнем, у него есть дочерние узлы head и body, у body есть свои дочерние элементы и так далее вплоть до листовых узлов вроде текста или изображений. У каждого узла в этом дереве есть тип, набор атрибутов, стилевые классы и ссылки на родителя, на предыдущего и следующего соседа. Такая структура даёт JavaScript возможность быстро находить нужный элемент через методы вроде querySelector или getElementById и изменять его на лету.

Важное свойство DOM в том, что он живой. Стоит вызвать метод appendChild, removeChild или изменить innerHTML, и дерево в памяти браузера меняется мгновенно, а вместе с ним помечаются недействительными и все зависимые структуры дальше по конвейеру. Разработчики фреймворков вроде React строят поверх этого свою виртуальную модель именно потому, что прямые точечные правки настоящего DOM обходятся дороже, чем сравнение двух облегчённых снимков структуры в памяти JavaScript. Когда виртуальный DOM после сравнения не находит изменений, реальный DOM браузера вообще не трогается, и вся последующая цепочка пересчётов не запускается.

Полезно понимать, что не каждый узел DOM в итоге окажется на экране. Метатеги, элементы script и link, а также любые узлы со стилем display none существуют в DOM, но не участвуют в визуальном представлении страницы. Это разделение станет ключевым на следующем шаге.

Как CSSOM соединяется с DOM Tree и образует Render Tree с видимыми элементами страницы

Когда DOM и CSSOM готовы, браузер объединяет их в третью структуру, которая называется Render Tree, или дерево рендеринга. Эта структура повторяет форму DOM, но включает только те узлы, которые реально будут нарисованы на экране, и для каждого узла хранит уже вычисленные итоговые стили: цвет, шрифт, фон, размеры блочной модели. Элементы с display none полностью исключаются из Render Tree, а элементы с visibility hidden в него попадают, просто остаются невидимыми, потому что они по-прежнему занимают место в раскладке страницы.

Это различие часто путают на практике: скрытие через display none убирает узел не только с экрана, но и из расчётов геометрии, а скрытие через visibility hidden оставляет пустое место там, где элемент был бы виден. Отсюда разная стоимость операций: переключение display none чаще требует полного пересчёта раскладки соседних блоков, а переключение visibility в большинстве современных браузеров можно обработать заметно дешевле, если оно не меняет размеры окружающих элементов.

Render Tree строится один раз при первой загрузке страницы, а затем обновляется частично при каждом изменении DOM или CSSOM. Именно на этом дереве браузер и выполняет два следующих затратных этапа, из-за которых интерфейсы начинают тормозить.

Что происходит на этапе Layout когда браузер вычисляет размеры и положение каждого блока

Следующий этап называется Layout, а в старой терминологии, которая до сих пор используется в статьях и в консоли разработчика, его называют Reflow. На этом шаге браузер проходит по Render Tree и для каждого видимого узла вычисляет точные координаты и размеры: где находится левый верхний угол блока, какова его ширина и высота с учётом отступов, границ и содержимого дочерних элементов. Это геометрическая задача, и решается она не изолированно для одного элемента, а с учётом всего документа целиком, потому что размеры блочных элементов часто зависят от размеров родителя, соседей и содержимого.

Именно поэтому Layout считается одной из самых дорогих операций в рендеринге. Изменение ширины родительского контейнера способно запустить пересчёт геометрии для всех его потомков, для соседних элементов, которые обтекают этот контейнер, и даже для элементов выше по дереву, если их размер зависит от содержимого. На сложной странице с тысячами узлов один полный Reflow способен занять от нескольких десятков до сотни миллисекунд основного потока браузера, а бюджет на кадр при частоте 60 герц составляет всего около 16 миллисекунд. Если Layout не укладывается в этот бюджет, анимация или скролл визуально дёргаются, это явление разработчики называют джанком.

К свойствам, которые при изменении почти всегда вызывают полноценный Layout, относятся width, height, padding, margin, border, top, left, position, float и display. Изменение любого из них помечает раскладку недействительной, и браузер обязан пересчитать её заново перед следующей отрисовкой кадра.

Чем Paint отличается от Layout и как браузер закрашивает пиксели на экране

После того как геометрия всех элементов известна, наступает этап Paint, который также называют Repaint. Здесь браузер берёт готовую раскладку и заполняет каждый пиксель внутри границ элементов конкретными визуальными данными: цветом фона, текстом, тенями, изображениями, границами и радиусами скругления. Это уже не геометрическая, а художественная задача в буквальном смысле слова, и по стоимости она обычно дешевле Layout, потому что не требует пересчёта позиций соседних элементов.

Repaint запускается в двух случаях: либо после завершения Layout, если поменялась геометрия, либо самостоятельно, если поменялось только визуальное свойство без изменения размеров и позиции. Ко второй категории относятся color, background color, box shadow, border radius, outline и visibility. Изменение цвета текста кнопки не требует пересчёта её ширины и высоты, поэтому браузер сразу переходит к перекраске пикселей, минуя этап Layout.

Отдельно стоит группа свойств, которая почти не трогает ни Layout, ни Paint напрямую: transform и opacity. Современные браузерные движки способны обработать анимацию этих двух свойств на отдельном потоке композитора, задействуя ресурсы видеокарты, вообще не возвращаясь в основной поток JavaScript. Именно поэтому рекомендация для плавных анимаций звучит одинаково во всех руководствах по производительности: анимировать transform и opacity вместо width, height, top или left, потому что первые два обходятся почти бесплатно, а вторые запускают полную цепочку Layout и Paint на каждый кадр.

Какие действия в коде провоцируют дорогой reflow и что такое layout thrashing

Отдельная проблема возникает, когда JavaScript код в цикле сначала читает геометрическое свойство элемента, а затем сразу его меняет, и повторяет это для следующего элемента. Свойства offsetWidth, offsetHeight, offsetTop, clientWidth, clientHeight, scrollHeight, а также методы getBoundingClientRect и getComputedStyle требуют от браузера актуальных данных о раскладке. Если после предыдущей записи в DOM раскладка была помечена недействительной, чтение любого из этих свойств заставляет браузер немедленно и синхронно пересчитать Layout, не дожидаясь конца кадра. Это называется принудительным синхронным layout, или forced synchronous layout.

Проблема усиливается, если чтение и запись чередуются внутри цикла по множеству элементов. Каждая итерация заново инвалидирует раскладку и заново заставляет браузер её пересчитывать, хотя вся работа могла бы выполниться один раз. Такое поведение называется layout thrashing, дословно "битьё раскладки", и оно способно превратить операцию, которая должна была занять доли миллисекунды, в задержку на десятки и сотни миллисекунд при работе со списком из сотни и более узлов.

Вот типичный пример проблемного кода на JavaScript, где чтение offsetHeight внутри цикла forEach вызывает reflow на каждой итерации:

const items = document.querySelectorAll('.card');
items.forEach(item => {
  const height = item.offsetHeight; // читает актуальную раскладку, форсирует Layout
  item.style.height = (height + 10) + 'px'; // сразу инвалидирует раскладку
});

Правильный подход разделяет фазу чтения и фазу записи, чтобы браузер выполнил вычисление геометрии только один раз для всех элементов сразу, а затем один раз применил все изменения:

const items = [...document.querySelectorAll('.card')];
const heights = items.map(item => item.offsetHeight); // все чтения подряд
items.forEach((item, i) => {
  item.style.height = (heights[i] + 10) + 'px'; // все записи подряд
});

Такое разделение читай сначала, потом пиши является базовым правилом при работе с геометрией DOM в цикле, и инструменты вроде Chrome DevTools умеют напрямую указывать на строку кода, которая вызвала принудительный reflow, если открыть вкладку Performance и записать профиль во время взаимодействия со страницей.

Как оптимизировать reflow и repaint в реальных проектах через batching чтения и записи

Практическая оптимизация рендеринга строится на нескольких приёмах, и они хорошо работают вместе. Стоит собрать основные из них по порядку применения:

  1. Группировать чтения геометрических свойств отдельно от записей, чтобы браузер не пересчитывал раскладку на каждой итерации цикла;
  2. Оборачивать визуальные обновления в requestAnimationFrame, чтобы браузер выполнял их в начале следующего кадра, а не посреди выполнения произвольного скрипта;
  3. Анимировать transform и opacity вместо width, height, top и left, чтобы задействовать поток композитора и видеокарту вместо основного потока;
  4. Использовать свойство will-change для элементов, которые заведомо будут анимироваться, чтобы браузер заранее вынес их в отдельный композитный слой;
  5. Применять CSS свойство content-visibility для длинных списков и офскрин контента, чтобы браузер пропускал раскладку и покраску невидимых блоков;
  6. Проверять аудит Forced Reflow в Lighthouse и во вкладке Insights инструментов разработчика, чтобы находить конкретные строки кода, которые вызывают синхронный пересчёт;
  7. Минимизировать число вложенных DOM узлов и глубину CSS селекторов, потому что чем сложнее дерево, тем дороже обходится каждый полный Layout.

Отдельного внимания заслуживает этап Compositing, который следует сразу после Paint. Современные браузерные движки не рисуют страницу единым куском, а разбивают её на несколько слоёв, каждый из которых можно перерисовывать и перемещать независимо. Эта работа выполняется на отдельном потоке композитора, параллельно с основным потоком JavaScript, и именно поэтому свойства transform и opacity считаются практически бесплатными для анимации: браузеру достаточно сдвинуть уже готовый слой, не запуская заново ни Layout, ни Paint. Посмотреть список слоёв конкретной страницы можно во вкладке Layers панели разработчика, доступной через меню дополнительных инструментов.

Понимание всей цепочки от разбора HTML до финального изображения на экране даёт разработчику конкретный инструмент диагностики: если интерфейс тормозит, вопрос звучит не абстрактно "почему медленно", а предметно, на каком именно шаге конвейера теряется время, в Layout, в Paint или в лишних пересчётах, вызванных чтением геометрии в неподходящий момент. Профилирование через вкладку Performance браузерных инструментов разработчика показывает эти этапы в виде отдельных полос на временной шкале, и по их длине сразу видно, какой шаг конвейера стал узким местом конкретной страницы.