offsetHeight или нечаянный спуск лавины reflow

DOM internals 2008-03-04 Dovbush Pavel

В заметке Владимира Токмакова, утверждается:

HTML-элемент в документе может быть скрыт с помощью JavaScript или CSS-свойства display. Логику, заложенную в CSS, воспроизводить в JavaScript сложно и не нужно. Проще запросить offsetHeight объекта (если 0 = элемент скрыт).

Проще-то оно, конечно, проще, вот только какой ценой?

Для проверки видимости элемента принято проверять значение стиля display или наличие класса hide. Когда мы пишем функцию скрытия/отображения сами, то знаем, какое значение стиля display у объекта по умолчанию, или какой класс какому состоянию соответствует. Однако универсальная (библиотечная) функция знать об этом не может.

Проведем тестирование скорости вычисления значений offsetHeight и style.display.

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

function fnOffset(el)
{
    return !!el.offsetHeight;
}

function fnStyle(el)
{
    return el.style.display=='none';
}

Где el – тестовый контейнер.

Проведя тест на тысяче итераций, видим, что доступ к offsetHeight всего в два раза медленнее, чем к style.display.

Теперь усложним тест – на каждой итерации будем добавлять в тестовый контейнер элемент SPAN.

Результаты профайлинга:
profile results

Теперь соотношение между тестируемыми совсем другое. Почему это произошло? Давайте проверим время, затрачиваемое на добавление тысячи элементов, без вызова тестовых функций – тест "clean". Проведем тестирование во всех браузерах, замеряя время следующим способом:

var time_start=new Date().getTime();
/* ... тест ... */
var time_stop=new Date().getTime();
var time_taken=time_stop-time_start;

Где time_taken – это время, затраченное на тест, в миллисекундах.

Данные тестов приведены в миллисекундах и взято среднее значение за 5 прогонов:
test results

Судя по результатам тестов доступ к offsetHeight медленнее в 50-150 раз.

Получается, что по оттдельности и offsetHeight и добавление элементов работает быстро, а вместе - очень медленно. Как же так?

 

Почему такой разрыв между тестируемыми? Немного теории.

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

  • начальный – первое отображение дерева;
  • инкрементный – возникает при изменениях в DOM;
  • изменение размеров;
  • изменение стилей;
  • "грязный" – объединение нескольких инкрементных reflow имеющих общего родителя.

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

При манипулировании DOM происходят инкрементные reflow, которые браузер откладывает до конца выполнения скрипта. Однако, исходя из определения reflow, "измерение" элемента вынудит браузер выполнить отложенные reflow.Т.к. возможно распространение снизу вверх выполняются все reflow, даже если измеряемый элемент принадлежит к неизменившейся ветви.

Reflow очень ресурсоемки и являются одной из причин замедления работы веб-приложений.

 

Если судить по тесту "clean", все браузеры хорошо справляются с кэшированием многочисленных reflow. Однако, запрашивая offsetHeight, мы "измеряем" элемент, что вынуждает браузер выполнить отложенные reflow. Таким образом, браузер делает тысячу reflow в одном случае и только один в другом.

Замечание: У Оперы reflow выполняется еще и по таймеру, что, однако, не мешает ей пройти тест быстрее остальных браузеров. Благодаря этому в Опере виден ход тестов – появляются добавляемые звездочки. Такое поведение оправдано, т.к. вызывает у пользователя ощущение большей скорости браузера.

 

Подведем итог. Что же показало тестирование? По меньшей мере, некорректно сравнивать универсальный (offsetHeight) и частный (style.display) случаи. Тестирование показало, что за универсальность надо платить.

А если все-таки хочется универсальности, то можно предложить другой подход – определение Computed Style – конечного стиля элемента (после всех CSS преобразований).

getStyle=function()
{
    var view=document.defaultView;

    if(view && view.getComputedStyle)
    return function getStyle(el,property)
    {
        return view.getComputedStyle(el,null)[property] || el.style[property];
    };

    return function getStyle(el,property)
    {
        return el.currentStyle && el.currentStyle[property] || el.style[property];
    };
}();

Проведем тестирование этого способа и сведем все результаты в таблицу.
test results 2

В IE и FF computed style вычисляется столь же быстро, как стиль самого элемента, а в Опере и Сафари – даже чуть дольше offsetHeight. В цитируемой статье явно указано, что вызов getComputedStyle также вызывает reflow, и причины отсутствия этого в IE и FF непонятны, хотя и радуют. UPD: рано радовались ;) Спасибо AKS за указание на то, что getComputedStyle в IE и FF возвращает некорректные результаты.

При поиске в интернете способов оптимизации вычисления computed style для Оперы и Сафари была найдена статья Computed vs Cascaded Style, в которой Erik Arvidsson рекомендует не пользоваться такими универсальными функциями (getStyle есть практически в каждой js-библиотеке), а реализовывать необходимую функциональность в каждом конкретном случае. Ведь если мы договоримся, что скрытые элементы должны иметь класс hide, то все сведется к определению наличия этого класса у элемента или его родителей.

 

Оптимизация: определение класса hide

Давайте подробнее остановимся на предложенном мной решении. Предлагаю следующую реализацию:

function isHidden(el)
{
    var p=el;
    var b=document.body;
    var re=/(^|\s)hide($|\s)/;
    while(p && p!=b && !re.test(p.className))
        p=p.parentNode;
    return !!p && p!=b;
}

Предполагается, что корневые элементы DOM скрывать не имеет смысла и поэтому проверки ведутся только до document.body.

Предложенное решение явно не спустит лавину reflow т.к. никаких вычислений и измерений не проводится. Однако немного смущает траверс до корня документа: что же будет при большой вложенности элементов? Давайте проверим. Тест isHidden проводится для вложенности 2 (document.body / test_div). А тест isHidden2 для вложенности 10 (document.body / div * 8 / test_div).

test results 3

Как показывают тесты, даже при большой вложенности падение скорости невелико. Таким образом, мы получили универсальное решение, которое быстрее доступа к offsetHeight в 30-100 раз.

Данная статья предназначена не столько для решения проблемы выяснения видимости элемента в общем случае, сколько для объяснения одного из наиболее часто встречающихся узких мест взаимодействия с DOM и детального разбора методов оптимизации. В ходе тестов я намеренно воспроизводил наихудший случай. В реальных ситуациях, такой прирост скорости получится только при использовании в анимации. Однако понимание причин и механизма reflow позволит писать более оптимальный код.

Ссылки:

  • Заметка Владимира Токмакова, которая послужила поводом написания этой статьи.
  • Статья Efficient JavaScript, в которой рассказываются способы оптимизации JavaScript, и в частности способы минимизации количества reflow.
  • Статья Notes on HTML reflow, в которой описываются нюансы реализации reflow в Gecko.
  • Статья Computed vs Cascaded Style в которой рассматриваются недостатки функций getStyle

Тесты: для всех браузеров, для профайлинга в FireBug.

PS. Спасибо AKS за коментарии, заставившие меня более четко сформулировать определение reflow.

Перепечатки: на хабре; на webo.in.

Обсуждение на хабре