{ "index": 210, "slug": "editorial-2022-03-practice-browser-rendering", "title": "Медленный рендер: как связать DOM-изменение с причиной в кадре", "excerpt": "Если интерфейс дёргается после одного действия, не называйте виновником весь браузер. Зафиксируйте DOM-изменение, отделите JavaScript от работы конвейера отрисовки и проверьте гипотезу одной записью Performance panel.", "contentHtml": "

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

\n

Разбор начинается с более узкого вопроса: какое действие сделал пользователь, какой DOM-результат должен появиться и какая работа попала в тот же участок записи? Сначала фиксируем код и состояние до/после, затем смотрим trace (запись работы браузера) в Performance panel. Слова style, layout, paint и composite помогают задать вопрос к записи, но не образуют универсальный порядок для всех браузеров.

\n

Симптом и граница задачи

\n

Возьмём кнопку save-card, которая после успешного ответа должна получить класс card is-ready. До клика её состояние — card is-pending. Сеть в этом примере не измеряется: считаем, что ответ уже получен и исследуем только обработчик, DOM-изменение и последующую работу браузера.

\n

Граница расследования состоит из одного клика, одного target (элемента-цели), одного свойства и двух значений. Формулировка «страница тормозит» ничего не связывает с кодом. Формулировка «после клика на save-card свойство className меняется с card is-pending на card is-ready» уже допускает повторную проверку.

\n
Учебная схема: клик меняет className карточки, после чего показаны условные вопросы о style, layout, paint и composite; synthetic units отделены от реального trace
Схема задаёт порядок вопросов к одному DOM-изменению. Synthetic units — условные единицы рисунка, а не миллисекунды и не запись Performance panel.
\n

Механизм: от JavaScript к обновлению экрана

\n

JavaScript меняет состояние документа. Когда обработчик присваивает новый класс, браузеру может понадобиться пересчитать применимые стили и layout — геометрию элементов. Затем движок обновляет визуальное представление; конкретная реализация может объединить, отложить или пропустить часть работы. Поэтому приложение не получает контракт «после каждой записи будут ровно пять событий».

\n

Для разговора с коллегой удобна рабочая карта: mutation — изменение DOM; style — пересчёт применимых стилей; layout — вычисление геометрии; paint — подготовка визуальных пикселей; composite — сборка слоёв. Это не обязательные имена событий в trace. Если запись показывает другой набор или порядок, описываем то, что действительно наблюдаем, и сверяем его с версией браузера.

\n

Отдельный кандидат на причину — чтение геометрии сразу после записи. Например, после изменения класса код читает offsetWidth. Это свойство возвращает layout-ширину элемента, а в Chrome чтение после инвалидирования стилей может потребовать немедленного layout. Такая гипотеза проверяется trace, а не самим присутствием свойства в коде: если стили не изменились или элемент не участвует в нужной геометрии, вывод будет другим.

\n

Минимальный воспроизводимый пример

\n

Ниже приведён самодостаточный фрагмент для страницы, где кнопка является карточкой. Сначала поместите разметку в HTML, затем выполните JavaScript после появления кнопки в документе. Строка с offsetWidth оставлена намеренно: она позволяет проверить гипотезу о чтении геометрии после записи. В рабочем коде не добавляйте такое чтение без причины. После первого клика состояние станет card is-ready; для повторного прогона обновите страницу.

\n
<button id='save-card' class='card is-pending' type='button'>Сохранить</button>\n\n// JavaScript\nconst card = document.querySelector('#save-card');\n\nfunction setCardState(from, to) {\n  if (!card || card.className !== from) {\n    return false;\n  }\n\n  performance.mark('card-state-before');\n  card.className = to;\n  const width = card.offsetWidth;\n  console.log('measured width:', width);\n  performance.mark('card-state-after');\n  performance.measure(\n    'card-state-change',\n    'card-state-before',\n    'card-state-after'\n  );\n  return true;\n}\n\ncard.addEventListener('click', () => {\n  setCardState('card is-pending', 'card is-ready');\n});
\n

Функциональный результат здесь проверяем отдельно: после клика в Elements должно быть class='card is-ready'. Метки User Timing показывают длительность собственного участка JavaScript и появляются в Performance panel. Они не измеряют весь layout или paint, поэтому запись должна включать сам клик и выбранный диапазон Main track.

\n

Симптом → причина → проверка → действие

\n
Матрица первого расследования
СимптомРабочая гипотезаПроверкаДействие
После клика интерфейс отвечает рывкомВ один вывод смешаны обработчик, layout и соседние событияЗаписать один клик и выделить его interactionСузить trace до действия и одного target
Длинный участок есть, но владелец неясенНе зафиксированы DOM before и afterПроверить класс в Elements и место записи в кодеСначала восстановить контракт, потом искать причину
После чтения геометрии появился LayoutЗапись класса инвалидировала стили, а чтение потребовало размерСопоставить вызов offsetWidth с выбранным событиемРазнести запись и чтение либо убрать ненужное чтение; подтвердить повторной записью
После CSS-правки стало «быстрее»Исчез обязательный визуальный результатСверить класс, геометрию и screenshotНе принимать сокращённый trace без функциональной проверки
В записи нет новой работыЗначения from и to совпали либо выбран не тот элементПроверить no-op и чистое исходное состояниеНе искать layout там, где не было новой mutation
Стадии trace отличаются от схемыУчебные labels приняли за API браузераСверить документацию версии и фактические событияОписать наблюдаемую работу без принудительного переименования
\n

Порядок проверки в Performance panel

\n
  1. Опишите симптом как действие и видимый результат: «после клика на save-card интерфейс отвечает неровно, класс должен стать card is-ready».
  2. Зафиксируйте target, свойство, DOM before и DOM after. Отдельно опишите ожидаемое состояние при ошибке и no-op, когда переход не должен происходить.
  3. Подготовьте страницу: одинаковые данные, viewport, браузер, cache и настройки throttling. Уберите лишние клики и фоновые действия, но запишите условия запуска.
  4. Откройте Performance panel, включите запись runtime и выполните один клик. Остановите запись сразу после появления ожидаемого DOM-результата.
  5. Найдите interaction и узкий диапазон вокруг неё. В Main track проверьте обработчик, User Timing marks и связанные события, а не только самый длинный прямоугольник.
  6. Выберите одну гипотезу. Для geometry read это конкретный вызов offsetWidth, а не общее утверждение «рендер дорогой».
  7. Внесите одну обратимую правку: уберите чтение, разделите записи и чтения или измените один selector. Сохраните ожидаемый DOM-result и способ rollback.
  8. Повторите тот же сценарий в той же среде. Сравните выбранные диапазоны, DOM-result и отрицательный путь. Если trace не подтверждает гипотезу, верните правку и выберите следующий вопрос.
\n

Если гипотеза не подтверждается

\n

Если DOM после клика не соответствует контракту, остановитесь: вы исследуете не тот путь. Если метка JavaScript есть, а ожидаемого layout или paint в выбранном диапазоне нет, это не доказывает, что рендер «бесплатный». Работа могла быть объединена или перенесена. Если длинный участок лежит вне выбранной interaction, не приписывайте его кнопке без связи с кодом.

\n

Не отключайте проверку стилей, не удаляйте обязательный визуальный результат и не добавляйте таймер как доказательство. requestAnimationFrame может помочь привязать учебный шаг к очередной возможности отрисовки, но сам callback не объясняет причину задержки. PerformanceObserver наблюдает поддерживаемые performance entries; он не раскрывает весь внутренний pipeline и не заменяет trace DevTools.

\n

Ограничения измерения

\n

Запись относится к конкретной среде. Версия браузера и DevTools, устройство, viewport, throttling, cache, шрифты, расширения, размер DOM и фоновые задачи меняют длительность и состав работы. Chrome DevTools показывает собственное представление trace; Firefox и Safari могут группировать или называть события иначе. Один локальный прогон не описывает все устройства и не является полевым измерением пользовательского опыта.

\n

Метки на рисунке и числа в учебной модели не являются миллисекундами, CPU time, FPS, LCP, INP или production-данными. Не переносите результат «стало быстрее» из одного trace на весь продукт. Улучшение принимается только вместе с сохранённым функциональным результатом и повторной записью на нужном сценарии.

\n

Критерий готовности

\n

Диагностика готова, когда другой инженер может повторить один сценарий в указанной среде, увидеть DOM before и after, найти обработчик и открыть связанный диапазон trace. Для правки записаны одна гипотеза, ожидаемый эффект и rollback. Успешная ветка сохраняет card is-ready, ошибка и no-op проверяются отдельно. Если хотя бы одного артефакта нет, итогом остаётся новая гипотеза, а не заявление «рендер исправлен».

\n

Проверяемые источники

\n" }