Статьи Архив статей

Перевод: Мациевский Николай aka sunnybear
Опубликована: 31 декабря 2009

Рендеринг: отрисовка, перерасчет дерева / макета, стилизация

Примечание: перевод заметки Rendering: repaint, reflow/relayout, restyle от Stoyan Stefanov. Освещается очень много прикладных аспектов отрисовки страниц в браузерах и построения дерева потока документа. Примечания далее курсивом. Термин reflow далее переводится как «перерасчет дерева отрисовка», чтобы отделить его от термина repaint (фактическая перерисовка страницы).

Итак, что происходит в браузере, когда он отрисовывает HTML-страницу на экране? Как он обрабатывает выданную ему кашу из HTML-, CSS- и (возможно) JavaScript-кусков?

Процесс отрисовки

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

  • Браузер анализирует HTML-код страницы (кашу из тегов) и строит некоторое DOM-дерево — представление данных, в котором каждому HTML-тегу соответствует свой узел, а текстовые куски между тегами соответствуют своим текстовым узлам. Корневым узлом такого дерева является documentElement (тег <html>). Затраты на время построения DOM-дерева исследовались в этих статьях.
  • Браузер анализирует CSS-код, пытается понять все заложенным в нем хаки и корректно распознать все известные ему приемы, среди которых могут быть -moz, -webkit и другие расширения, которые он может не понимать и должен игнорировать. Информация по стилям строится каскадным образом: сначала идут правила по умолчанию из самого браузера (более подробно их размер исследовался в этой статье), затем идут пользовательские стили, затем авторские (автора страницы) — внешние, импортированные, внутренние, а уже потом — все стили, прописанные в атрибутах style HTML-тегов.
  • После этого наступает самая интересная часть: браузер строит дерево отрисовки. Дерево отрисовки в какой-то мере связано с DOM-деревом, но не соответствует ему полностью. Дерево отрисовки знает о текущих стилях (поэтому если вы спрячете div при помощи display: none, то он в это дерево не попадет, это не совсем верно, как видно из следующей статьи, но принцип тот). То же самое для других невидимых элементов: например, для тега head и всех вложенных элементов. С другой стороны в этом дереве DOM-узлы могут быть представлены в виде нескольких узлов, например, если каждая строка <p> требует своего узла для отрисовки. Узел в таком дереве называется кадр (frame) или блок (box) (так же как и CSS-блок, соответствующий блочной модели). У каждого из этих блоков есть блочные CSS-свойства: ширина, высота, граница, поля, и т.д (таким образом, даже строчные (inline) элементы будут представлены в дереве отрисовки отдельными блоками).
  • После того как готово дерево отрисовки, его можно наконец и отрисовать (paint, draw) на экране.

За деревьями не видно леса

Давайте рассмотрим следующий пример.

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

<html>
<head>
  <title>Beautiful page</title>
</head>
<body>
  <p>
    Когда-то давно-давно здесь был
    длинный-длинный параграф...
  </p>
  <div>
    Скрытое сообщение
  </div>
  <div><img src="..." /></div>
  ...
</body>
</html>

DOM-дерево, которое соответствует данному HTML-документу, в основном, содержит по одному узлу на каждый тег и по одному узлу на каждый кусок текста между тегами (для простоты можно игнорировать тот факт, что пробелы тоже являются текстовыми узлами ):

documentElement (html)
    head
        title
    body
        p
            [текстовый узел]
        div
            [текстовый узел]
        div
            img
        ...

Это дерево отрисовки является видимой частью DOM-дерева. В нем отсутствует ряд вещей — например, заголовок страницы и скрытые элементы — но есть и дополнительные узлы (или кадры, или блоки) для отображения строк текста.

корень (RenderView)
    body
        p
            строка 1
            строка 2
            строка 3
            ...
        div
            img
        ...

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

Давайте дальше посмотрим, как именно отображение элементов на экране вызывает рекурсивный просмотр дерева перерисовки. Здесь и далее термин (re)flow перевдится как перерасчет дерева перерисовки.

Перерисовка и перерасчет дерева отрисовки

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

  1. Потребуется пересчитать некоторые части дерева перерисовки (или даже все дерево). Этот процесс называется перерасчет дерева отрисовки (reflow) или перерасчет макета документа (re-layout). Заметьте, что всегда есть хотя 1 такой перерасчет: при первичном отображении страницы.
  2. Некоторые части экрана нужно обновить, либо потому что изменились геометрические свойства узлов, либо какие-либо их стили (например, цвет фона). Этот процесс называется перерисовка (repaint, redraw).

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

Что вызывает перерасчет или перерисовку

Все, что каким-либо образом изменяет информацию, используемую для построения дерева отрисовки (что, естественно, приводит к необходимости пересоздания такого дерева), может привести к перерасчету или перерисовке. Например:

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

Давайте рассмотрим пару примеров:

var bstyle = document.body.style; // кэшируем
bstyle.padding = "20px"; // перерасчет + перерисовка
bstyle.border = "10px solid red"; // еще один перерасчет + перерисовка
bstyle.color = "blue"; // только перерисовка, размеры не изменились
bstyle.backgroundColor = "#fad"; // перерисовка
bstyle.fontSize = "2em"; // перерасчет + перерисовка
// новый DOM-элемент = перерасчет + перерисовка
document.body.appendChild(document.createTextNode('dude!'));

Некоторые перерасчеты дерева отрисовки могут быть более «тяжелыми». Стоит рассматривать эти операции в терминах дерева отрисовки: если изменения касаются только узла, который является прямым потомком тела документа (body), то вполне вероятно, что другие узлы не будут затронуты. Но что произойдет, если вы начнете анимировать и увеличить блок вверху страницы, что будет смещать всю остальную страницу вниз? Видимо, это будет весьма затратно.

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

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

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

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

Все вышеприведенные варианты запрашивают информацию о стиле DOM-узла у браузера. Этот запрос заставляет браузер выполнить все отложенные операции, чтобы выдать актуальную информацию. Все это приводит к выполнению перерасчета (и видимо, перерисовки).

Например, плохой идеей будет устанавливать стили, запрашивая при этом информацию о них в одном и том же месте (например, внутри цикла):

// нет, только не это!
el.style.left = el.offsetLeft + 10 + "px";

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

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

  • Не изменяйте индивидуальные стилевые свойства одно за другим. Для масштабируемости кода и увеличения скорости реакции интерфейса нужно менять имена классов, а не сами стили. Но это предполагает статические классы. Если же стили полностью динамические (например, «всплытие» элементов), то необходимо редактировать свойство 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 перерасчет + перерисовка), затем применяйте все изменения, затем восстанавливайте display (еще один перерасчте + перерисовка). Тки образом можно сэкономить сотни потенциальных перерасчетов дерева отрисовки.
  • Не запрашивайте фактические стили чересчур часто. Если вы работаете с вычисленными значениями, то запросите его единожды, закэшируйте в локальную переменную и работайте с локальной копией. Применительно к отрицательному примеру, приведенному выше:
    // нет, только не это!
    for(здесь; длинный; цикл) {
        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(здесь; длинный; цикл) {
        left += 10;
        top  += 10;
        esty.left = left + "px";
        esty.top  = top  + "px";
    }
  • Каждый раз перед проведением изменений рассчитывайте, какой эффект они окажут на дерево отрисовки (можно прикидывать по количеству DOM-узлов). Например, использование абсолютного позиционирования превращает элемент в дочерний для тела всего дерев отрисовки, поэтому он не затронет такого количества узлов, например, при анимации. Некоторые элементы (находящиеся глубже по дереву отрисовки) могут быть в области видимости этого элемента, но они потребуют только перерисовки, а не перерасчета дерева.

Инструменты

Примерно год назад не было ничего, что обеспечивало бы информацией о перерасчете и перерисовке страницы в браузере (это может быть и не так: я подозреваю, что в MS были свои внутренние средства для разработчиков, но о них никто не знал, ибо они были похоронены в глубине MSDN :P). Теперь ситуация изменилась, и очень сильно.

Во-первых, появилось событие MozAfterPaint в ночных сборках Firefox, поэтому стали возможны такие плагины, как, например, этот (от Kyle Scholz), который использует это событие. mozAfterPaint — это очень прикольно, но оно сообщает только момент перерисовки (а не перерасчета дерева отрисовки, что является боле затратным).

Далее. DynaTrace Ajax и более поздний SpeedTracer от Google (заметьте: оба трейсят (trace) :)) являются отличными инструментами для отслеживания перерасчетов и перерисовок, первый предназначен для IE, второй — для WebKit.

Однажды в прошлом году Douglas Crockford заметил, что мы, возможно, делаем в CSS очень глупые вещи, но сами о них не знаем. И теперь есть, что возразить на это. Я немного участвовал в проекте на той стадии, что мы разбирались со следующей проблемой: незначительное увеличение пользователем шрифта (in IE6) загружало CPU на 100% в течение 10-15 минут, прежде чем страница снова появлялась на экране.

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

Ну, за исключением, может быть, одного: было бы, наверное, круто, если бы существовал инструмент, который бы показывал дерево отрисовки в дополнение к 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"). Давайте назовем ее тестом изменения стилей (restyle test).

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

// каждый раз запрашиваем стили
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;

Этот случай давайте назовем тестом изменения макета (relayout test), исходник здесь.

Вот что выдает DynaTrace для первого теста (стили).

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

Этот инструмент показывает, в какой момент закончилась загрузка страницы (это обозначает логотип IE). Затем курсор мыши находится на области, отвечающей за обработку клика. Увеличиваем масштаб (круто!), и вот более детальная картина:

Тут уже очень хорошо видно, сколько времени ушло на JavaScript (синяя полоска) и сколько — на отрисовку (зеленая полоска). Это у нас еще простой пример, а как отличаются полоски по длине? Наколько дороже для браузера обходится отрисовка, чем исполнение JavaScript? Обычно в Ajax- и Rich-приложениях JavaScript не является узким местом, тормозит доступ к DOM-элементам и часть, отвечающая за отрисовку страницы.

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

Снова увеличиваем масштаб и видим, что дополнительно к полоске «отрисовка» у нас появилась еще одна — «перерасчет дерева отрисовки», потому что в этом тесте мы его сознательно создаем в дополнение к перерисовке.

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

Это первый тест по изменению стилей с увеличенным масштабом и обзором происходящего.

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

Увеличиваем события и видим в деталях, что происходит на самом деле: после первого клика стили пересчитываются 3 раза. После второго — только один раз.

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

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

Незначительное отличие между этими инструментами заключается в том, что SpeedTracer не показывает тот момент, когда задача по изменению макета запланирована и добавлена в очередь, а DynaTrace показывает. И поэтому DynaTrace не отображает разницу между изменением стилей и изменением макета, как SpeedTracer. Может быть, просто сам IE не делает между ними разницы? Также DynaTrace не показал три перерасчета дерева отрисовки в первом случае второго теста (когда мы запрашиваем информацию о стилях после каждого изменения макета). Может быть, это так IE работает?

Ниже приведены дополнительные данные касательно этих двух тестов после запуска их достаточное количества раз.

  • В Chrome отказ от запроса вычисленных стилей при их изменении примерно в 2,5 раза быстрее (тест на изменение стилей) и 4,42 раза быстрее, если вы меняете и стили и макет (тест на изменение макеты).
  • В Firefox — в 1,87 и 1,64 раза быстрее, соответственно.

По всем браузерам изменение стилей примерно в два раза быстрее, чем изменение стилей и макета страницы. За исключением IE6, где разницы составляет 4 раза.

Заключение

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

  • Дерево отрисовки — визуальная часть DOM-дерева.
  • Узлы в дереве отрисовки называют кадры или блоки.
  • Изменение дерева отрисовки называется перерасчетом (reflow) в Mozilla и макетом (layout) во всех остальных браузерах, по-видимому.
  • Обновление на экране результатов перерасчета дерева отрисовки называется перерисовкой (repaint, redraw) (в IE/DynaTrace).
  • В SpeedTracer вводится понятие «перерасчета стилей» (стили, не изменяющие геометрию) против «макета».

И немного ссылок на прощание. Стоит отметить, первые три из приведенных материалов объясняют ситуацию более глубоко с точки зрения браузера, а не с точки зрения разработчика (как это попытался сделать я).

Читать дальше

Все комментарии (habrahabr.ru)