DOM Performance

0 comments

dom performance

DOM операциите в браузера са едни от най-тежките. Някои от тях са доста бавни, а други – още повече. Големи SaaS услуги като Gmail например генерират огромен брой DOM елементи на страницата, а това допълнително утежнява уеб-базирания софтуер.

Едни от най-тежките операции в DOM-а са управлението на събития (event handling), промяна на изгледа (styling), създаването и унищожаването на елементи. Колкото по-често се налага да изпълняваме тези операции, и разбира се – върху колкото повече елементи – толкова по-бавно става нашето приложение.

Съществуват няколко малко известни техники, с които можем да постигнем нашите цели, но в пъти по-бързо в сравнение със стандартните и най-разпространени операции.

В тази статия разглеждаме няколко различни техники, които решават проблеми с производителността, свързани със създаването, стилизирането, управлението на събития (event handling) и унищожаването на много DOM елементи наведнъж.

Делегиране на събития (Event Delegation)

JavaScript ни предоставя изключителната възможност да реагираме на събития (events) – действия от страна на потребителя или средата (браузера), като по този начин можем да създадем система, която си взаимодейства с потребителя. Разбира се, колкото една система е по-сложна, от толкова повече елементи и следене на събития имаме нужда, а това създава проблем с производителността. Потребителят не обича да чака 2 секунди, след като е кликнал, за да се случи нещо.

Ползвали ли сте Google Spreadsheets? Ако не, то със сигурност сте ползвали Excel или Spreadsheet (Open-office). Та, Google Spreadsheets е уеб-базиран софтуер, подобен на изброените, само че работи в браузера, и представлява напълно функциониращ грид, в който можете да селектирате клетки, да им задавате стойности, и т.н. И щом е уеб-базиран, значи е много вероятно да е написан на JavaScript. И това в действителност е така .

Google Spreadsheets е базиран на събития (events). Да вземем нампример селекцията на клетка – клетката се “маркира” в някакъв цвят, когато кликнем върху нея. Имаме събитие (click), и отговор – софтуерът “разбира” коя клетка е била маркирана (кликната), и я оцветява. 

Screen Shot 2014-11-21 at 19.44.02

Стандартно “слушаме” за събития в DOM ето така:

var cell = document.querySelector('td');
cell.addEventListener( 'click', function ( event ) {
   // mark the cell in blue
   this.style.background = 'blue';
});

Когато става въпрос за няколко елемента, е лесно. Сега си представете следната ситуация: имате грид с 30 колони и 1000 реда. Това прави 30’000 клетки. Респективно означава 30’000 пъти извикване на addEventListener:

var cells   = document.querySelectorAll('td');
// create single event handler
var handler = function ( event ) {
   // mark the cell in blue
   this.style.background = 'blue';
};

for ( var i=0, l=cells.length; i<l; i += 1 ) {
   // add listener to every single cell...
   cells[i].addEventListener( 'click', handler );
}

Е да, можеше и да е по-зле. Можеше да създадем нова функция (handler) на всяка итерация, за всяка клетка в грида 🙂

Но 30’000 пъти извикване на addEventListener е много. Твърде много. А колко ли хубаво щеше да бъде да можем да се закачим към всички клетки и да следим кога са кликнати, с една-единствена операция?

Всъщност можем. Техниката се казва делегиране, или event delegation. Състои се в това да слушаме за събитието не всички елементи по отделно, а техен общ родителски елемент. В този случай контекстът this винаги реферира към родителския елемент, но наследникът, който е активирал събитието, има референция в event обекта:

// delegate click event to the table
var table = document.querySelector('table');
table.addEventListener( 'click', function ( event ) {
   // `this` is the whole <table>, but `event.target` is the cell that has been clicked
   event.target.style.background = 'blue';
});

Но да предположим, че вътре в клетката има вложени множество други елементи. В този случай нашият event.target няма да бъде клетката, а някой дъщерен елемент, и се налага да “претърсим” дървото нагоре, докато намерим родителския елемент-клетка:

// delegate click event to the table
var table = document.querySelector('table');
table.addEventListener( 'click', function ( event ) {
   // `this` is the whole <table>, but `event.target` is the 
   // element that has been clicked
   // find out the <td> element
   var cell = event.target;

   while ( cell && cell.tagName != 'TD' && cell != this ) {
      cell = cell.parentNode;
   }
   cell.style.background = 'blue';
});

Делегирането на събитие е много евтино, особено ако имаме страшно много елементи, които искаме да следим. Има и един “страничен” (положителен) ефект – всички елементи, които създадем допълнително в родителския елемент, автоматично “делегират” събитието до родителя.

Вижте резултатите от performance тестовете в jsperf.com – event handling на всеки елемент поотделно vs event delegation. Разликата е драстична!

Разбира се, делегирането не е подходящо за всички видове събития.

Стилизиране

Google Spreadsheets се доближава по още един feature до подобните desktop приложения – задаване на височина на редовете. Стандартно задаваме височина на елемент със CSS свойството height:

document.querySelector('tr').style.height = ‘75px';

Но отново, едно е да го направим за няколко елемента, съвсем друго е за хиляди. Десетки хиляди.

JavaScript ни позволява да създаваме и манипулираме стилове динамично чрез style sheets. Съществува Stylesheet API, в който можем да добавяме и премахваме отделни селектори. Най-лесно обаче става, ако използваме <style> елемент и пишем CSS, посредтвом innerHTML свойството:

var style = document.head.appendChild( document.createElement('style') );
style.innerHTML = 'tr { height: 75px }’;

Така всичките ни редове вече са толкова високи, колкото искаме, и то с една единствена проста операция! Можем да създадем <style> елемента в началото на нашето приложение, и след това само да му подменяме съдържанието с innerHTML. Жестоко! Вижте резултатите от performance тестовете в jsperf.com – стилизиране на всеки елемент поотделно vs използване на style sheet.

Създаване на нови елементи

Безспорно най-тежките DOM операции са свързани със създаването на елементи. document.createElement е най-тромав. Създаване на DOM посредством innerHTML свойството може да не е по одобрените от Консорциума стандарти, но пък е по-бързо от createElement. Клонирането на елементи също е по-бързо в сравнение с това да създадем нови елементи с createElement.

Ето малко тестове за сравнение – създаваме таблица с 20 реда и 10 колони; във всяка клетка имаме <div>; вътре в контейнера имаме <img> и <input>, както и уникален текст. Тестваме във Chrome, Firefox, IE8. Резултатите – createElement е най-бавен във всички браузери; cloneNode е най-бърз в IE8 и Firefox, a в Хром най-бързо създаваме грид с innerHTML.

Screen Shot 2014-11-20 at 22.36.11

Съществува обаче и много по-бърз метод за скоростно създаване на огромно количество DOM елементи, който отново е свързан с клониране. Разликата е, че използваме фрагменти (DocumentFragment) с цел да намалим броя на операциите по клониране и присвояване (appendChild) колкото се може повече.

Какво представлява DocumentFragment

DocumentFragment е псевдо елемент, който наследява прототипа на HTMLElement. Можем да го клонираме, можем да му присвояваме други елементи или фрагменти, и така също да го вмъкваме в други елементи. Когато го вмъкваме (appendChild( fragment )), обаче, физически елемент не се вмъква, а само неговите наследници. Затова казваме, че DocumentFragment е псевдо-елемент.

John Resig доста добре е обяснил фрагментите в своята статия, и защо клонирането на фрагменти може драстично да увеличи производителността. Идеята е да клонираме няколко елемента във фрагмент, след което да клонираме толкова пъти фрагмента, колкото елементи са ни необходими, като всеки път оригиналният фрагмент присвоява своето копие (дефакто присвоява неговите физически наследници). Накрая, с една-единствена операция (appendChild или insertBefore) присвояваме целия фрагмент като дете на физически елемент.

cloneMultiple е метод, който написах, използвайки операции с клониране на фрагменти и следния алгоритъм: желания брой елементи делим на 2 докато получим нечетно число; тогава вадим едно (за да получим четно) и продължаваме да делим на две. И така, докато достигнем до просто число 2 или 3. След което, в обратния ред, започваме с клонирането – клонираме елемента 2 или 3 пъти и вкарваме клонингите във фрагмент; клонираме Х пъти фрагмента, като всеки път клонинга го инжектираме към същия фрагмент; където е необходимо, добавяме един физически елемент. Накрая получаваме DocumentFragment с брой копия на елемента, равен на броя, който искаме.

Пример – искаме да клонираме един елемент 1000 пъти. Да разбием операциите:

  1. делим 3 пъти на 2 (1000 = 500 * 2; 500 = 250 * 2; 250 = 125 * 2), х = 3
  2. от полученото число 125 вадим 1, за да получим четно число
  3. делим 2 пъти на 2 (124 = 62 * 2; 62 = 31 * 2), х += 2
  4. от полученото число вадим 1, за да получим четно число
  5. делим още веднъж, х += 1
  6. вадим 1
  7. делим още 1 път
  8. вадим 1
  9. делим още веднъж

Или с други думи, 1000 = ((((3 * 2 + 1) * 2 + 1) * 2 + 1) * 2 * 2 + 1) * 2 * 2 * 2

Сега, по обратния ред:

  1. Създаваме фрагмента var fragment = document.createDocumentFragment()
  2. Клонираме 3 пъти елемента и го присвояваме към фрагмента fragment.appendChild( element.cloneNode( true ) ) // 3 times
  3. Клонираме фрагмента fragment.appendChild( fragment.cloneNode( true ) )
  4. Клонираме още веднъж елемента и го присвояваме към фрагмента fragment.appendChild( element.cloneNode( true ) )
  5. Клонираме фрагмента fragment.appendChild( fragment.cloneNode( true ) )
  6. Клонираме още веднъж елемента и го присвояваме към фрагмента fragment.appendChild( element.cloneNode( true ) )
  7. Клонираме фрагмента fragment.appendChild( fragment.cloneNode( true ) )
  8. Клонираме още веднъж елемента и го присвояваме към фрагмента fragment.appendChild( element.cloneNode( true ) )
  9. Клонираме фрагмента още 2 пъти fragment.appendChild( fragment.cloneNode( true ) )
  10. Клонираме още веднъж елемента и го присвояваме към фрагмента fragment.appendChild( element.cloneNode( true ) )
  11. Клонираме фрагмента още 3 пъти fragment.appendChild( fragment.cloneNode( true ) )

Хубавото на този подход е, броят на елементите, които искаме да клонираме, нараства прогресивно на броят на операциите! За 1000 елемента например извършваме само 31 операции (cloneNode, appendChild)! За 2000 те са само 33!

Поради невъзможност да тествам метода в jsperf.com, направих тестовете на ръка, отново в Chrome, Firefox, IE8. Сравних времето, необходимо на cloneMultiple, с това, необходимо на cloneNode. Кодът:

Screen Shot 2014-11-18 at 20.20.33

Резултатите – IE8:

Screen Shot 2014-11-18 at 20.41.12

Хром:

Screen Shot 2014-11-18 at 20.21.22

Firefox:

Screen Shot 2014-11-18 at 20.19.16

Ясно се вижда, че почти на всички тестове и във всички браузери, разликата е почти двойна, в полза на cloneMultiple.

cloneMultiple е много полезен, ако се налага да създадем огромен брой еднакви елементи. Ако примерно искаме да нарисуваме грид с динамично съдържание, например, няма да ни свърши работа. Или поне не изцяло. Все пак, бихме могли да нарисуваме самата решетка с cloneMultiple (което ще представлява може би 70-90% от всички DOM елементи), а за данните да използваме innerHTML например.

Унищожаване на елементи

Унищожаваме елементи с element.parentNode.removeChild( element ), или parent.innerHTML = “. Само че колкото повече елементи има в parent, толкова повече innerHTML е по-бавен, защото ги унищожава един по един. Проектирайте системата си така, че когато ви се наложи да унищожите голям брой елементи, всичките те да имат един и същи родител, в него да няма нищо друго освен елементи за унищожение, и да унищожите родителя. Все пак говорим само за една-единствена операция.

Share This:

Коментари ( 0 )

    Вашият коментар?

    Вашият е-мейл адрес няма да бъде публикуван. Задължителните полета са отбелязани с *
    Моля, използвайте кирилица!

    *