Рендеринг (rendering): перерисовка (repaint), перекомпоновка (reflow/relayout), рестайл (restyle)

December 17th, 2009. Tagged: CSS, JavaScript, performance

2010 update:
Lo, the Web Performance Advent Calendar hath moved

Dec 17 This post is part of the 2009 performance advent calendar experiment. Stay tuned for the articles to come.

UPDATE: Ukraine translation here.

Не правда ли замечательны пять слов, начинающиеся на "R" в заголовке? Давайте поговорим о рендеринге - фазе, наступающей в Странице 2.0 после, а иногда и во время, потока скачиваемых компонентов.

Итак, как же браузеры отображают твою страницу, получая порцию HTML, CSS и возможно JavaScript?

Процесс рендеринга

Разные браузеры работают по-разному, но следующая диаграмма передает основную идею того, что происходит более менее одинаково во всех браузерах, когда они загрузили весь код для твоей страницы.

Rendering process in the browser

  • Браузер парсит исходный HTML-код (tag soup - набор тегов) и строит DOM дерево - отображение данных, где каждый HTML тэг соотносится с узлом в дереве, куски текста между тэгами также соотносятся со своим текстовым узлом в дереве. Корневой узел в DOM дереве - это documentElement (тэг )
  • Браузер парсит CSS код, переваривает кучу возможных хаков, а префиксы вроде -moz, -webkit и другие расширения, которые он не понимает, гордо игнорирует. Информацию о каскадах стилей: базовых правилах в таблицах стилей юзер-агента (браузерные стили по умолчанию), затем пользовательских таблицах стилей, авторских (например автора этой страницы) таблицах стилей - внешних, импортированных, внутристрочных, и наконец стилей, скопированных из атрибута style внутри HTML тэгов
  • Затем начинается интересная часть - создание рендерингового дерева. Рендеринговое дерево - это что-то типа DOM дерева, но не совсем. Оно знает о стилях, так что если ты прячешь div с помощью display: none, он не будет отображен в рендер-дереве. Так же и с видимыми элементами, вроде элемента head и всего, что внутри него. В то же время, могут встретиться DOM элементы, которые представлены более чем одним узлом в рендер-дереве - например текстовые узлы, где каждая строка в тэге

    требует собственный рендер-узел. Узел в рендер-дереве зовется фрэймом (frame), или боксом (box) (как и в CSS бокс, согласно бокс модели). Каждый из этих узлов имеет CSS бокс свойства - width, height, border, margin и т. д.

  • Когда наконец рендер-дерево построено, браузер может отрисовать узлы рендер-дерева на экране

Лес и деревья

Давайте приведу пример.

Исходный HTML код:

<html>
<head>
  <title>Beautiful pagetitle>
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'а, но имеются дополнительные узлы (они же фрэймы, они же боксы) для строк текста.

root (RenderView)
    body
        p
            line 1
	    line 2
	    line 3
	    ...
	    
	div
	    img
	    
	...

Корневой узел рендер-дерева - это фрэйм (он же бокс), который содержит все остальные элементы. Ты можешь представить себе это как внутренюю часть окна браузера, как будто бы это доверенная зона, в которой страница может расположиться. Технически, WebKit зовёт корневой элемент RenderView и соотносит с CSS начальным содержащим блоком, который является прямоугольником вьюпорта от верхнего левого угла страницы (0, 0) до (window.innerWidth, window.innerHeight)

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

Перерисовка и reflow

There's always at least one initial page layout together with a paint (unless, of course you prefer your pages blank :)). Поэтому изменение входящей информации, которая была использована для создания рендер-дерева может привести к следующему:

  1. части рендер-дерева (или всё дерево) должны быть перепроверены, а размеры нод пересчитаны. Это и есть reflow, или переразмещение, или переразметка. (или "relayout", слово, которое я придумал, чтобы в заголовке статьи было больше слов, начинающихся на "R", извините, мой косяк). Заметьте, что здесь как минимум один рефлоу - первоначальной разметки страницы
  2. части экрана должны быть обновлены, потому что сделаны изменения в геометрических свойствах нод, и потому что сделаны изменения в стилях, например изменение цвета фона. Обновление экрана называются перерисовкой (repaint) или перекраской.

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

Что приводит к переразметке(reflow) и перерисовке(repaint)

Всё, что изменяет входящую информацию, использованную для создания рендер-дерева, может вызвать перерисовку и переразметку, например:

  • Добавление, удаление, создание DOM-узлов
  • Скрытие DOM-узлов с помощью display: none (reflow и repaint) или visibility: hidden (только repaint, так как нет геометрических изменений)
  • Перемещение, анимирование DOM-узлов на странице
  • Добавление таблиц стилей, настройка свойств стилей
  • Пользовательские действия типа изменения размеров окна, изменения размеров шрифта и (ОМГ, нет!) прокрутка страницы

Давай взглянем на несколько примеров:

var bstyle = document.body.style; // cache
 
bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; // another reflow and a repaint
 
bstyle.color = "blue"; // repaint only, no dimensions changed
bstyle.backgroundColor = "#fad"; // repaint
 
bstyle.fontSize = "2em"; // reflow, repaint
 
// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));

Одни рефлоу могут оказаться дороже, чем другие. Представьте рендер-дерево - если вы возитесь с узлом, который является прямым потомком элемента body, вы не влияете на множество других узлов. Но что если вы анимируете и раздвигаете див на макушке вашей страницы, который сдвигает вниз всю оставшуюся часть страницы? Звучит затратно.

Браузеры умны

Поскольку перекомпоновка (reflow) и перерисовка, связанные с изменениями в рендер-дереве, ресурсозатратны, браузеры стараются уменьшить негативные последствия. Один из вариантов - попросту отказываться работать. Или по меньшей мере не сейчас. Браузеры будут создавать очереди из изменений, делаемых твоими скриптами и выполнять их партиями. В этом случае несколько изменений, каждое из которых должно привести к перекомпоновке, будут объединены, и только одна перекомпоновка будет сделана. Браузеры могут добавлять изменения в очередь и в тот момент, когда количество изменений достигнет определенного значения, освобождать очередь и применять их все.

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

  1. offsetTop, offsetLeft, offsetWidth, offsetHeight
  2. scrollTop/Left/Width/Height
  3. clientTop/Left/Width/Height
  4. getComputedStyle(), or currentStyle in IE

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

Например, не лучшая идея устанавливать и получать данные о стилях в быстрой последовательности (в цикле), типа:

// no-no!
el.style.left = el.offsetLeft + 10 + "px";

Уменьшение количества перерисовок и перекомпоновок

Главный принцип снижения негативных эффектов от перекомпоновок/перерисовок, влияющих на удобство работы, это попросту вызывать меньше перекомпоновок и перерисовок, и реже запрашивать информацию о стилях, и тогда браузер сможет оптимизировать процессы перекомпоновки. Как же разобраться с этим?

  • Не изменяйте индивидуальные стили поодиночке. Самый адекватный и простой в дальнейшем сопровождении способ, изменять имена классов вместо конкретных значений стилей. Но это касается статичных стилей. Если стили динамичные, изменяй cssText свойство, вместо того, чтобы дёргать элемент и его свойство style при малейшем изменении.
    // bad
    var left = 10,
        top = 10;
    el.style.left = left + "px";
    el.style.top  = top  + "px";
     
    // better 
    el.className += " theclassname";
     
    // or when top and left are calculated dynamically...
     
    // better
    el.style.cssText += "; left: " + left + "px; top: " + top + "px;";
  • Группируйте изменения в DOMе и применяйте их "офлайн". Офлайн значит, что не в "живом" DOM-дереве. Ты можешь:
    • использовать documentFragment для хранения временных изменений,
    • клонировать узел, который собираешься изменить, поработать над копией, и затем заменить оригинал обнавленной копией
    • скрыть элемент с помощью display: none (одна перекомпоновка и перерисовка), сделать сто изменений, вернуть значение свойства display (еще одна перекомпоновка и перерисовка). Этот путь стоит две перекомпоновки вместо сотни
  • Не перебарщивайте с запросами подсчитанных стилей. Если вам нужно работать с подсчитанным значением, запросите его единожды, кэшируйте в локальной переменной и работайте с этой локальной копией. Пересмотрим пример, приведенный ранее:
    // no-no!
    for(big; loop; here) {
        el.style.left = el.offsetLeft + 10 + "px";
        el.style.top  = el.offsetTop  + 10 + "px";
    }
     
    // better
    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 в рендер-дереве, поэтому, если ты решишь анимировать этот элемент, изменения не затронут слишком большое количество других узлов. Некоторые из узлов могут оказаться в зоне, которую необходимо перерисовать, когда ты помещаешь свой элемент поверх них, но они не затребуют от браузера перекомпоновки.

Инструменты

Only about a year ago, there was nothing that can provide any visibility into what's going on in the browser in terms of painting and rendering (not that I am aware of, it's of course absolutely possible that MS had a wicked dev tool no one knew about, buried somewhere in MSDN :P). Сейчас всё изменилось, и это очень, очень круто.

Во-первых, событие MozAfterPaint появилось в Firefox Nightly билдах, так что появились штуки впроде этого расширения от Kyle Scholz. mozAfterPaint это круто, но поведает тебе только о перерисовках.

DynaTrace Ajax and most recently Google's SpeedTracer (notice two "trace"s :)) are just excellent tools for digging into reflows and repaints - the first is for IE, the second for WebKit.

В прошлом году Дуглас Крокфорд упомянул о том, что мы делаем очень глупые вещи в CSS, о которых и не догадываемся. И я определенно согласен с этим. Некоторое время я участвовал в проекте, где увеличение размера шрифта (в IE6) приводило к увеличению загрузки CPU до 100% длительностью до 10-15 минут, до тех пор, пока страница окончательно не перерисуется.

Что ж, теперь у нас есть инструменты, и нет больше никаких оправданий глупым вещам, которые мы творим в CSS.

Кроме, возможно, разговоров об инструментах..., не было бы это круто, если бы инструменты типа firebug, показывали рендер-дерево в дополнение к DOM-дереву?

Последний пример

Давайте быстренько взглянем на инструменты и продемонстрируем разницу между рестайлом (restyle) (изменения в рендер-дереве не влияющие на геометрию) и перекомпоновкой (reflow) (которая влияет на разметку), вместе с перерисовкой (repaint).

Давай сопоставим два способа, которыми можно сделать одно и то же. Во-первых, изменим некоторые стили (не трогая разметку) и после каждого изменения проверим свойство стиля, которое никак не связано с тем, которое мы только что изменили.

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"). Назовём это рестайл-тестом.

Второй тест будет таким же, как и первый, но на этот раз мы также будем изменять разметку:

// touch styles every time
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;
 
 
// touch at the end
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;

Этот тест изменяет разметку, так что назовём его "тестом переразметки", исходник здесь.

Вот что будет выведено в DynaTrace для рестайл-теста.

DynaTrace

Страница загрузилась, затем я один раз кликнул для срабатывания первого сценария (запросы информации о стилях каждый раз, на отсечке около 2-х секунд), затем кликнул еще раз для вызова второго сценария (запросы стилей, отложенные в конец, на отметке около 4-х секунд)

Инструмент демонстрирует, как загрузилась страница, а лого IE показывает событие onload. Курсор мыши указывает на момент рендеринга, вызванного кликом. Если увеличить масштаб в интересуемой области (как же круто!), получит более детальный вывод:

dynatrace

Можно отчетливо заметить синюю полосу отработавшего JS-кода, и затем зеленую полосу процесса рендеринга. Это простой пример, но всё равно сравните длину полос - на сколько дольше времени тратится на рендеринг, чем на выполнение скрипта. Зачастую в Ajax/Rich приложениях не JS является узким местом, а доступ и манипуляции с DOM-ом и рендеринг.

Окей, теперь запустим тест переразметки, который вносит изменения в геометрию body. На этот раз заглянем в раздел "PurePaths". Это временная шкала плюс чуть больше информации о каждом элементе на шкале. Я установил указатель на первый клик, который в скрипте вызывает отложенную разметку.

dynatrace

Опять же, приблизив интересующую область, ты можешь заметить, что теперь в дополнение к полосе прорисовки появилась еще одна - подсчет компоновки разметки (Calculating flow layout), потому что в этом тесте помимо перерисовки происходит перекомпоновка.

dynatrace

Теперь давайте протестируем эту же страницу в Chrome и посмотрем на результаты в SpeedTester.

This is the first "restyle" test zoomed into the interesting part (heck, I think I can definitely get cused to all that zooming :)) and this is an overview of what happened.

speedtracer

В общем, видно где клик, а где прорисовка. Но при первом клике 50% времени было потрацено на пересчитывание стилей. Почему? Что ж, потому что мы запрашивали информацию о стилях при каждом изменении.

Развернув события и отобразив скрытые строки (серые строки были скрыты спидтрейсером, потому что они не были медленными) мы видим, что именно произошло - после первого клика стили были пересчитаны трижды. После второго - только один раз.

speedtracer

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

speedtracer

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

speedtracer

Два небольших различия в этих инструментах - SpeedTracer не показывает когда задача была запланирована и добавлена в очередь, в отличие от DynaTrace. В свою очередь DynaTrace не различает рестайл и перекомпоновку/разметку, в отличие от SpeedTracer'а. Может просто IE не делает различия между ними? DynaTrace также не отобразил три перекомпоновки вместо однов в фазах теста изменить-и-сразу-спросить и изменить-потом-спросить, может потому что IE так работает?

Запуская эти простые примеры сотни раз, можно сделать вывод, что для IE нет разницы, запросишь ли ты информацию о стилях сразу или потом.

Вот еще немного выводов после повторения этих тестов:

  • Хром отработает в 2.5 раз быстрее, если не спрашивать его о рассчитанных стилях всякий раз, когда вносишь изменения (рестайл-тест), и в 4.42 раза быстрей, когда изменяешь стили и разметку(тест переразметки).
  • Firefox отработает в 1.87 раз быстрей, если воздержаться от запросов информации в рестайл-тесте и в 1.64 раза быстрей в тесте переразметки.
  • В IE6 и IE8 не имеет значения.

Во всех браузерах изменение стилей занимает половину времени, затрачиваемого на изменение стилей и разметки. (Мне стоило бы также сравнить изменение только стилей с изменением только разметки). В отличие от IE6, где изменение разметки в 4 раза затратней изменения только стилей.

Напутствие

Спасибо всем, кто осилил весь этот длинный пост. Развлекайтесь с трэйсерами и следите за этими reflow! В заключение, позвольте пройтись по терминологии еще раз.

  • рендер-дерево (рендеринговое дерево, render tree) - визуальная часть DOM-дерева
  • ноды (узлы) в рендер-дереве называются фрэймами или боксами
  • пересчитывание частей рендер-дерева называется перекомпоновкой (reflow) (в Mozilla), и разметкой(layout) в любом другом браузере
  • обновление экрана с результатами пересчета рендер-дерева называется перерисовкой(repaint) или перекраской(redraw) (в IE/DynaTrace)
  • SpeedTracer вводит понятие "пересчета стилей" (изменение стилей без изменения геометрии) вместо переразметки.

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

Tell your friends about this post: Facebook, Twitter, Google+

Sorry, comments disabled and hidden due to excessive spam. Working on restoring the existing comments...

Meanwhile, hit me up on twitter @stoyanstefanov