Рендеринг: repaint, reflow/relayout и restyle
Original source: Rendering: repaint, reflow/relayout, restyle
Рендеринг: repaint, reflow/relayout и restyle
Обновление 2010 года: О, Web Performance Advent Calendar переехал.
17 декабря. Этот пост — часть эксперимента Performance Advent Calendar 2009. Следите за обновлениями: впереди новые статьи.
Неплохие пять слов на «R» в заголовке, правда? Давайте поговорим о рендеринге — этапе, который в Life of Page 2.0 наступает после водопада загрузки компонентов, а иногда и во время него.
Итак, как браузер выводит страницу на экран, если на входе у него есть кусок HTML, CSS и, возможно, JavaScript?
Процесс рендеринга
Разные браузеры работают по-разному, но следующая схема дает общее представление о том, что более или менее одинаково происходит в браузерах после того, как они загрузили код вашей страницы.

- Браузер разбирает исходный HTML-код, этот самый «tag soup», и строит DOM-дерево — структуру данных, где каждому HTML-тегу соответствует узел дерева, а фрагменты текста между тегами тоже представлены текстовыми узлами. Корневой узел DOM-дерева — это
documentElement(тег<html>). - Браузер разбирает CSS-код и пытается в нем разобраться с учетом возможной кучи хаков, а также разных
-moz,-webkitи прочих расширений, которых он не понимает и потому храбро игнорирует. Информация о стилях каскадируется: базовые правила находятся в таблицах стилей User Agent (стили браузера по умолчанию), затем могут быть пользовательские таблицы стилей, авторские таблицы стилей — внешние, импортированные, встроенные — и, наконец, стили, записанные прямо в атрибутахstyleHTML-тегов. - Затем начинается самое интересное — построение дерева рендеринга. Дерево рендеринга чем-то похоже на DOM-дерево, но не совпадает с ним один к одному. Оно знает о стилях, поэтому если вы скрываете
divчерезdisplay: none, в дереве рендеринга его не будет. То же самое касается других невидимых элементов, напримерheadи всего, что находится внутри него. С другой стороны, некоторые DOM-элементы могут быть представлены в дереве рендеринга несколькими узлами: например, текстовые узлы, где каждой строке внутри<p>нужен свой узел рендеринга. Узел дерева рендеринга называют frame или box (то есть CSS-боксом, согласно блочной модели). У каждого такого узла есть свойства CSS-бокса: ширина, высота, рамка, внешние отступы и так далее. - Когда дерево рендеринга построено, браузер может нарисовать (
paint) его узлы на экране.
Лес и деревья
Возьмем пример.
Исходный HTML:
<html>
<head>
<title>Beautiful page</title>
</head>
<body>
<p>
Once upon a time there was
a looong paragraph...
</p>
<div style="display: none">
Secret message
</div>
<div><img src="..." /></div>
...
</body>
</html>
DOM-дерево, представляющее этот HTML-документ, в целом содержит по одному узлу для каждого тега и по одному текстовому узлу для каждого фрагмента текста между узлами. Для простоты проигнорируем тот факт, что пробельные символы тоже становятся текстовыми узлами:
documentElement (html)
head
title
body
p
[text node]
div
[text node]
div
img
...
Дерево рендеринга будет визуальной частью DOM-дерева. В нем кое-чего не хватает — head и скрытого div, — зато появляются дополнительные узлы, они же frames, они же boxes, для строк текста.
root (RenderView)
body
p
line 1
line 2
line 3
...
div
img
...
Корневой узел дерева рендеринга — это frame, или box, который содержит все остальные элементы. Его можно представить как внутреннюю область окна браузера: ограниченное пространство, в котором может располагаться страница. Технически WebKit называет корневой узел RenderView; ему соответствует CSS-понятие initial containing block, то есть, по сути, прямоугольник viewport от верхнего края страницы (0, 0) до (window.innerWidth, window.innerHeight).
Чтобы понять, что именно и как именно показывать на экране, браузер рекурсивно проходит вниз по дереву рендеринга.
Repaint и reflow
Всегда существует как минимум одна начальная раскладка страницы вместе с отрисовкой — если, конечно, вы не предпочитаете пустые страницы :). После этого изменение входных данных, на основе которых было построено дерево рендеринга, может привести к одному или обоим следующим результатам:
- Части дерева рендеринга или все дерево целиком нужно будет заново проверить, а размеры узлов пересчитать. Это называется reflow, или layout, или layouting. Или «relayout» — это слово я придумал, чтобы в заголовке было больше «R», простите, виноват. Обратите внимание: как минимум один reflow есть всегда — это начальная раскладка страницы.
- Части экрана нужно будет обновить: либо из-за изменений геометрических свойств узла, либо из-за изменения стиля, например цвета фона. Такое обновление экрана называется repaint, или redraw.
Repaint и reflow могут быть дорогими операциями: они способны ухудшать пользовательский опыт и заставлять интерфейс выглядеть вялым.
Что вызывает reflow или repaint
Все, что меняет входную информацию, использованную для построения дерева рендеринга, может вызвать repaint или reflow. Например:
- добавление, удаление, обновление DOM-узлов;
- скрытие DOM-узла через
display: none(reflow и repaint) или черезvisibility: hidden(только repaint, потому что геометрия не меняется); - перемещение или анимация DOM-узла на странице;
- добавление таблицы стилей, изменение стилевых свойств;
- действие пользователя: изменение размера окна, изменение размера шрифта или — о нет, только не это! — прокрутка.
Посмотрим несколько примеров:
var bstyle = document.body.style; // кешируем
bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; // еще один reflow и repaint
bstyle.color = "blue"; // только repaint, размеры не изменились
bstyle.backgroundColor = "#fad"; // repaint
bstyle.fontSize = "2em"; // reflow, repaint
// новый DOM-элемент — reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));
Одни reflow могут быть дороже других. Подумайте о дереве рендеринга: если вы меняете узел где-то глубоко в дереве, который является прямым потомком body, скорее всего, вы не инвалидируете много других узлов. Но что будет, если вы анимируете и расширяете div в верхней части страницы, а он затем сдвигает вниз всю остальную страницу? Звучит дорого.
Браузеры умные
Поскольку reflow и repaint, связанные с изменениями дерева рендеринга, стоят дорого, браузеры стараются уменьшить негативный эффект. Одна из стратегий — просто не выполнять работу. По крайней мере, не прямо сейчас. Браузер создает очередь изменений, которые требуют ваши скрипты, и выполняет их пакетами. Так несколько изменений, каждое из которых требует reflow, объединяются, и в итоге вычисляется только один reflow. Браузеры могут накапливать изменения в очереди, а затем сбрасывать очередь после того, как пройдет определенное время или будет достигнуто определенное количество изменений.
Но иногда скрипт может помешать браузеру оптимизировать reflow: он вынуждает браузер сбросить очередь и выполнить все накопленные изменения. Это происходит, когда вы запрашиваете информацию о стилях, например:
offsetTop,offsetLeft,offsetWidth,offsetHeightscrollTop/Left/Width/HeightclientTop/Left/Width/HeightgetComputedStyle()илиcurrentStyleв IE
Все перечисленное, по сути, запрашивает стилевую информацию об узле. Каждый раз, когда вы это делаете, браузер обязан вернуть самое актуальное значение. Чтобы его получить, ему нужно применить все запланированные изменения, сбросить очередь, собраться с духом и выполнить reflow.
Например, плохая идея — быстро подряд устанавливать и читать стили, скажем, в цикле:
// так нельзя!
el.style.left = el.offsetLeft + 10 + "px";
Как минимизировать repaint и reflow
Стратегия снижения негативного влияния reflow/repaint на пользовательский опыт проста: нужно уменьшить количество reflow и repaint, а также реже запрашивать стилевую информацию, чтобы браузер мог оптимизировать reflow. Как это сделать?
Не меняйте отдельные стили по одному. Для здравомыслия и поддерживаемости лучше менять имена классов, а не стили. Но это предполагает статические стили. Если стили динамические, редактируйте свойство
cssText, а не трогайте элемент и его объектstyleради каждого мелкого изменения.// плохо var left = 10, top = 10; el.style.left = left + "px"; el.style.top = top + "px"; // лучше el.className += " theclassname"; // или, если top и left вычисляются динамически... // лучше el.style.cssText += "; left: " + left + "px; top: " + top + "px;";Группируйте DOM-изменения и выполняйте их «офлайн». «Офлайн» означает не в живом DOM-дереве. Можно:
- использовать
documentFragmentдля временных изменений; - клонировать узел, который вы собираетесь обновить, работать с копией, а затем заменить оригинал обновленным клоном;
- скрыть элемент через
display: none(1 reflow, repaint), внести 100 изменений, восстановить отображение (еще один reflow, repaint). Так вы обмениваете 2 reflow на потенциальную сотню.
- использовать
Не запрашивайте вычисленные стили чрезмерно часто. Если вам нужно работать с вычисленным значением, получите его один раз, сохраните в локальную переменную и работайте с локальной копией. Вернемся к плохому примеру выше:
// так нельзя! for(big; loop; here) { el.style.left = el.offsetLeft + 10 + "px"; el.style.top = el.offsetTop + 10 + "px"; } // лучше var left = el.offsetLeft, top = el.offsetTop esty = el.style; for(big; loop; here) { left += 10; top += 10; esty.left = left + "px"; esty.top = top + "px"; }В целом думайте о дереве рендеринга и о том, какая его часть потребует повторной проверки после вашего изменения. Например, абсолютное позиционирование делает элемент дочерним по отношению к
bodyв дереве рендеринга, поэтому при анимации он не затронет слишком много других узлов. Некоторые другие узлы могут оказаться в области, которую нужно перерисовать, когда вы размещаете свой элемент поверх них, но reflow им не потребуется.
Инструменты
Всего около года назад не было ничего, что давало бы хоть какую-то видимость происходящего в браузере с точки зрения рисования и рендеринга — по крайней мере, насколько мне известно; конечно, вполне возможно, что у Microsoft был какой-нибудь крутой инструмент разработчика, о котором никто не знал, зарытый где-то в MSDN :P. Теперь все иначе, и это очень, очень круто.
Сначала событие MozAfterPaint появилось в ночных сборках Firefox, и начали появляться вещи вроде этого расширения Кайла Шольца. mozAfterPaint — штука классная, но сообщает только о repaint.
DynaTrace Ajax и совсем недавно появившийся Google SpeedTracer — обратите внимание на два «trace» :) — просто отличные инструменты для исследования reflow и repaint: первый для IE, второй для WebKit.
Где-то в прошлом году Дуглас Крокфорд упомянул, что мы, вероятно, делаем в CSS какие-то очень глупые вещи, о которых сами не знаем. И я прекрасно понимаю, о чем он. Я некоторое время участвовал в проекте, где увеличение размера шрифта в браузере (в IE6) заставляло CPU подниматься до 100% и оставаться там 10-15 минут, прежде чем страница наконец перерисовывалась.
Что ж, теперь инструменты есть, и у нас больше нет оправданий для глупостей в CSS.
Разве что, раз уж мы говорим об инструментах... было бы здорово, если бы инструменты в духе Firebug показывали не только DOM-дерево, но и дерево рендеринга?
Последний пример
Давайте быстро посмотрим на инструменты и покажем разницу между restyle (изменение дерева рендеринга, которое не затрагивает геометрию) и reflow (который влияет на раскладку), вместе с repaint.
Сравним два способа сделать одно и то же. Сначала мы меняем несколько стилей, не трогая layout, и после каждого изменения проверяем стилевое свойство, совершенно не связанное с только что измененным.
bodystyle.color = 'red';
tmp = computed.backgroundColor;
bodystyle.color = 'white';
tmp = computed.backgroundImage;
bodystyle.color = 'green';
tmp = computed.backgroundAttachment;
Затем то же самое, но мы обращаемся к стилевым свойствам только после всех изменений:
bodystyle.color = 'yellow';
bodystyle.color = 'pink';
bodystyle.color = 'blue';
tmp = computed.backgroundColor;
tmp = computed.backgroundImage;
tmp = computed.backgroundAttachment;
В обоих случаях переменные определены так:
var bodystyle = document.body.style;
var computed;
if (document.body.currentStyle) {
computed = document.body.currentStyle;
} else {
computed = document.defaultView.getComputedStyle(document.body, '');
}
Теперь два примера изменения стилей будут выполняться по клику на документе. Тестовая страница здесь: restyle.html (кликните «dude»). Назовем это тестом restyle.
Второй тест почти такой же, как первый, но на этот раз мы еще меняем информацию о layout:
// обращаемся к стилям каждый раз
bodystyle.color = 'red';
bodystyle.padding = '1px';
tmp = computed.backgroundColor;
bodystyle.color = 'white';
bodystyle.padding = '2px';
tmp = computed.backgroundImage;
bodystyle.color = 'green';
bodystyle.padding = '3px';
tmp = computed.backgroundAttachment;
// обращаемся в конце
bodystyle.color = 'yellow';
bodystyle.padding = '4px';
bodystyle.color = 'pink';
bodystyle.padding = '5px';
bodystyle.color = 'blue';
bodystyle.padding = '6px';
tmp = computed.backgroundColor;
tmp = computed.backgroundImage;
tmp = computed.backgroundAttachment;
Этот тест меняет layout, так что назовем его «relayout test»; исходник здесь.
Вот какую визуализацию дает DynaTrace для теста restyle.

По сути, страница загрузилась, затем я один раз кликнул, чтобы выполнить первый сценарий — запросы стилевой информации каждый раз, примерно на отметке 2 секунды, — а затем кликнул снова, чтобы выполнить второй сценарий — запросы стилей отложены до конца, примерно на отметке 4 секунды.
Инструмент показывает, как загрузилась страница, а логотип IE показывает onload. Затем курсор мыши находится над активностью рендеринга, последовавшей за кликом. Если приблизить интересующую область — как же это круто! — появляется более подробный вид:

Хорошо видно синюю полосу активности JavaScript и следующую за ней зеленую полосу активности рендеринга. Это простой пример, но все равно обратите внимание на длину полос: насколько больше времени уходит на рендеринг, чем на выполнение JavaScript. Часто в Ajax/Rich-приложениях узкое место — не JavaScript, а доступ к DOM, манипуляции с ним и рендеринг.
Теперь запускаем «relayout test» — тот, который меняет геометрию body. На этот раз посмотрим на представление «PurePaths». Это временная шкала плюс дополнительная информация о каждом элементе на ней. Я выделил первый клик: это активность JavaScript, которая создает запланированную задачу layout.

Снова приближаем интересную часть, и теперь видно, что помимо полосы «drawing» перед ней появилась новая — «calculating flow layout», потому что в этом тесте у нас был reflow в дополнение к repaint.

Теперь протестируем ту же страницу в Chrome и посмотрим на результаты SpeedTracer.
Это первый тест restyle с приближением к интересной части — черт, кажется, я точно могу привыкнуть ко всем этим зумам :) — и общий обзор того, что произошло.

В целом есть клик и есть paint. Но при первом клике 50% времени также ушло на пересчет стилей. Почему? Потому что мы запрашивали стилевую информацию при каждом изменении.
Если раскрыть события и показать скрытые строки — серые строки SpeedTracer скрывал, потому что они не медленные, — можно увидеть, что именно произошло: после первого клика стили вычислялись три раза. После второго — только один.

Теперь запустим «relayout test». Общий список событий выглядит так же:

Но подробный вид показывает, что первый клик вызвал три reflow, потому что запрашивалась информация о вычисленных стилях, а второй клик вызвал только один reflow. Это просто отличная видимость происходящего.

Есть несколько небольших различий между инструментами: SpeedTracer не показал, когда задача layout была запланирована и добавлена в очередь, а DynaTrace показал. Зато DynaTrace не показал деталей различия между «restyle» и «reflow/layout», как это сделал SpeedTracer. Может быть, IE просто не различает эти два случая? DynaTrace также не показал три reflow вместо одного в тестах «изменить-и-сразу-считать» против «сначала-изменить-потом-считать»; возможно, так работает IE?
Многократный запуск этих простых примеров также подтверждает: для IE неважно, запрашиваете ли вы стилевую информацию по мере изменения стилей.
Вот еще несколько точек данных после достаточного числа повторов тестов:
- В Chrome отказ от обращения к вычисленным стилям во время изменения стилей работает в 2,5 раза быстрее, когда вы меняете стили (тест restyle), и в 4,42 раза быстрее, когда вы меняете стили и layout (тест relayout).
- В Firefox отказ от запросов вычисленных стилей дает ускорение в 1,87 раза в тесте restyle и в 1,64 раза в тесте relayout.
- В IE6 и IE8 это не имеет значения.
При этом во всех браузерах изменение только стилей занимает вдвое меньше времени, чем изменение стилей и layout. Теперь, когда я это написал, понимаю, что надо было сравнить изменение только стилей с изменением только layout. Исключение — IE6, где изменение layout в 4 раза дороже, чем изменение только стилей.
Напоследок
Большое спасибо, что добрались до конца этого длинного поста. Развлекайтесь с трейсерами и берегитесь reflow! Подытожим терминологию еще раз.
- render tree — визуальная часть DOM-дерева;
- узлы дерева рендеринга называются frames или boxes;
- пересчет частей дерева рендеринга называется reflow в Mozilla и, похоже, layout во всех остальных браузерах;
- обновление экрана результатами пересчитанного дерева рендеринга называется repaint, или redraw в IE/DynaTrace;
- SpeedTracer вводит понятие «style recalculation» — пересчет стилей без изменений геометрии — в противоположность «layout».
И еще немного материалов, если тема кажется вам увлекательной. Обратите внимание: эти тексты, особенно первые три, гораздо глубже и ближе к устройству браузера, тогда как здесь я пытался держаться ближе к разработчику.
- Mozilla: заметки о reflow
- Дэвид Барон из Mozilla: Google Tech Talk о внутреннем устройстве layout-движка для веб-разработчиков
- WebKit: основы рендеринга — серия из 6 постов
- Opera: repaints and reflows — часть статьи об эффективном JavaScript
- Dynatrace: поведение рендеринга в IE
Комментарии? Ищите меня в BlueSky, Mastodon, LinkedIn, Threads, Twitter.