Anton Reymer: Najważniejsze aspekty pracy przeglądarki okiem programisty (cz. 2)
W pierwszej części artykułu opisałem główne aspekty pracy przeglądarki, które interesują programistów. W tej części opowiem o istotnych zdarzeniach takich jak „repaint” and „reflow” i podstawach pętli zdarzeń („event loop”).
Repaint i reflow
Istnieje zazwyczaj przynajmniej jeden początkowy layout strony (równolegle z „paint”), jeśli strona nie jest pozbawiona zawartości. Zdarzenia typu repaint i reflow występują w następujących okolicznościach:
Części drzewa renderowania muszą być zrewalidowane, to znaczy zmieniła się szerokość, wysokość albo współrzędne konkretnego węzła. W tym przypadku mamy doczynienia z reflow.
Części ekranu wymagają aktualizacji, albo ze względu na wartości geometryczne węzła, albo ze względu na zmiany wyglądu – takie jak zmiana koloru tła. Taka aktualizacja to właśnie repaint.
Reflow zazwyczaj pociąga za sobą repaint, ale nie działa to w odwrotnym kierunku – repaint może być wywołany niezależnie.
Co wywołuje repaint/reflow?
1. Dodanie, usunięcie albo aktualizacja węzła DOM – dlatego, że te akcje zmieniają informacje wejściowe używane do konstrukcji drzewa renderowania i drzewo wymaga ponownej walidacji.
Funkcje:
insertAdjacentHTML(),appendChild(), insertBefore(), removeChild(),replaceChild(),remove(),
append()/prepend(),after()/before(),replaceWith()
Zmiana właściwości węzła DOM:
innerHTML,innerText, width, height, offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop/Left/Width/Height,
clientTop/Left/Width/Height
Żądanie właściwości węzła DOM (bez żadnych zmian):
(offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop/Left/Width/Height, clientTop/Left/Width/Height
Reflow może być wywołane nie tylko w przypadku wywołania funkcji, czy zmian w węzłach dom, ale także wtedy, kiedy mamy do czynienia z prostymi żądaniami dotyczącymi prostych właściwości, a w szczególności właściwości offsetowych. W dalszej części tekstu wyjaśnię, dlaczego potrzebujemy w tym przypadku layoutu.
2. Ukrywanie węzła DOM za pomocą display: none (repaint i reflow) albo visibility: hidden (tylko repaint, nie ma tu mowy o zmianach w geometrii)
3. Przesuwanie lub animacja węzła DOM na stronie
Współrzędne węzła animowanego w drzewie renderowania zmieniają się, dlatego możemy też zaobserwować zmiany wielkości innych węzłów.
4. Dodawanie/zmiana CSS (prawo, lewo, góra, dół, szerokość i wysokość)
Zmiana właściwości CSS również wywołuje reflow.
5. Czynność użytkownika taka, jak zmiana rozmiaru okna, zmiana wielkości czcionki, przewijanie, albo przeciąganie i upuszczanie elementów.
6. Inne:
- Przewijanie JS – przewijanie zawartości za pomocą skryptu i jego właściwości
scrollByLines(), scrollByPages(), scrollHeight, scrollIntoView(), scrollIntoViewIfNeeded(), scrollLeft, scrollTop, scrollWidth
- Metody globalne i zdarzenia dla obiektów w oknach
getComputedStyle(), scrollBy(), scrollTo(), scrollX, scroll
- Praca z SVG
Przykład:
Przykład skryptu pokazuje, że kiedy zmieniamy właściwość padding, rozmiar boxu również ulega zmianie. W związku z tym reflow i repaint zostaną w tym przypadku wywołane. Jeśli wprowadzimy zmiany w obramowaniu, uzyska on nową szerokość wywołując reflow i repaint jeszcze raz. Ale jeśli zmienimy tylko kolor obramowania, tło albo tekst, wywołamy jedynie repaint. Zmiany wielkości czcionki wywołują oba zdarzenia.
Przeglądarki łączą różne zmiany wymagające reflow i repaint i wykonują je w ramch jednego przetwarzania. Zdarza się jednak, że skrypt może uniemożliwić przeglądarce optymalizację tych zdarzeń i są wykonane jednocześnie.
Informacja na temat stylów:
- offsetTop, offsetLeft, offsetWidth, offsetHeight
- scrollTop/Left/Width/Height
- clientTop/Left/Width/Height
- getComputedStyle(), or currentStyle in IE
Za każdym razem, kiedy żądamy informacji na temat konkretnego węzła, przeglądarka musi podać najnowsze wartości. Aby było to możliwe, wszystkie zaplanowane zmiany muszą zostać zastosowane, dopiero w grę wchodzi reflow i repaint.
Minimalizacja reflow i repaint – porady
I. Nie zmieniaj indywidualnego stylu elementu w skrypcie – zamiast tego zmień nazwy klas i w ten sposób zmień jego wygląd albo edytuj cssText.
Metody:
Możesz zmienić wygląd elementu albo jego pozycję zmieniając nazwę klasy, nie style. To lepsza opcja niż wprowadzanie zmian w stylach, dlatego że wywołujesz reflow i repaint tylko raz. W pierwszym przykładzie zmiana zdarzenia „left” wywoła reflow i repaint, a kolejne reflow i repaint odpalą się, kiedy zmienimy zdarzenie „top”. Ale jeśli „left” i „top” byłyby właściwościami CSS w określonej klasie, dodanie tej klasy wywoła tylko jeden reflow i repaint zamiast dwóch.
Taka sama zasada aplikuje się do właściwości cssText. Jeśli zamienimy wszystkie niezbędne właściwości tekstu dynamicznymi, będziemy mogli mówić o tylko jednym reflow i repaint.
II. Kolejkuj zmiany w DOM i nie wprowadzaj ich „na żywo”
Możesz to zrobić na kilka sposobów:
- Używając documentFragment, żeby zoptymalizować pracę z węzłami DOM.
- Sklonować węzeł, który zamierzasz aktualizować i zamienić go z oryginałem – w tym przypadku reflow i repaint również będą zoptymalizowane.
- Użyć display: none (1 reflow, repaint), dodać 100 zmian, zaktualizować zmiany (kolejne reflow, repaint). W tym momencie zamieniasz potencjalnie 100 X repaint na 2.
III. Nie żądaj przetworzonych stylów w nadmiarze. W takich wypadkach wywołujesz w przeglądarce reflow i repaint.
Spójrzmy na przykład optymalizacji kodu. Jeśli zamierzasz pracować z wyliczonych wartości, użyj jej raz, dodaj do pamięci podręcznej lokalnych zmiennych (jeśli mamy do czynienia z określoną pętlą, lepiej pracować ze zmienną niż dodawać właściwości za każdym razem)
IV. Pomyśl o drzewie renderowania i spróbuj zrozumieć, jaka jego część wymaga rewalidacji po wprowadzeniu twojej zmiany.
W tym miejscu chciałbym szczegółowo wyjaśnić, dlaczego to tak ważne. Jak możemy zauważyć reflow mogą różnić się między sobą i podczas gdy jeden reflow może być całkiem opłacalny i łatwy w przeprowadzeniu, inny może być zdecydowanie trudniejszy. Pochylmy się nad przykładem.
Mamy tutaj do czynienia z fragmentem kodu html. Załóżmy, że chcemy dodać węzeł DOM do elementu #1.
Co się stanie?
Po pierwsze wymiary elementu #1 muszą zostać zrewalidowane, ponieważ wymiary nowego DOM nie są znane. Powinniśmy zwrócić uwagę na to,czy istnieje potrzeba zmiany szerokości, itd. Zmiana wymiarów węzła #1 wywołuje reflow elementu #2, który następnie wywołuje reflow w węzłach #3, #4 i #5. Ta niewielka zmiana w jednym z węzłów okazuje się więc powiązana z całym szeregiem procesów. Ale wszystko to ulegnie zmianie, jeśli umieścimy nasz węzeł, na przykład, po czwartym węźle DOM.
W tym przypadku tylko węzeł #5 będzie rewalidowany i layout przebiegnie o wiele szybciej.
Kolejnym dobrym przykładem jest animacja. Załóżmy, że chcemy animoważ węzeł #1 tak, by przesunął się o 200 px w prawo. Jeśli „position” ma wartość „static”, to cześć standardowego procesu i – w związku z tym – wpływa to na wiele innych węzłów.
Zmiana współrzędnych wywoła reflow dla wszystkich elementów typu parent. Ale jeśli położenie elementu jest absolutne, sytuacja wygląda nieco inaczej. W takiej sytuacji zmiana współrzędnych wywoła reflow tylko dla niektórych „rodziców”. Jeśli – przykładowo – pozycja węzła #5 jest relatywna i reflow węzła #1 dotyczy wędzła #5, zmiana współrzędnych dla węzła #1 wywoła jedynie rewalidację piątego węzła DOM.
Pętla zdarzeń (event loop)
Przyjrzyjmy się teraz zdarzeniom asynchronicznym i pętli zdarzeń – to temat, który może wydawać się mylący.
Czym jest event loop? To nieskończona pętla, która jest implementowana przez silnik JS (V8, JavaScriptCore, itd.) używana najczęściej do prawidłowego wykonania skryptu i zdarzeń asynchronicznych. Nie powinniśmy mylić silnika JS z silnikiem przeglądarki takim jak WebKit czy Gecko. Nie powinniśmy również utożsamiać pętli zdarzeń z procesem działania przeglądarki.
Mamy do czynienia z trzema typami zdarzeń asynchronicznych po stronie klienta:
1. setTimeout
setTimeout(function timerFn(){
console.log('timerFn');
},100)
2. Ajax
$.ajax({
url: 'someUrl',
success: function ajaxFn(data) {
console.log('ajaxFn')
}
});
3. Custom action.
$('something').on('click',
function clickFn(){
console.log('clickFn')
}
)
Wszystkie te asynchroniczne zdarzenia są związane z wywoływaniem zwrotnym (callback), mechanizmem zapewniającym ich właściwe działanie. Spójrzmy na niewielki fragment kodu. Mamy callback ClickFn(), który odpala się, kiedy klikamy określony element. Załóżmy, ze duża porcja kodu działa na określonym stosie zawołań w jednym czasie i ClickFn zostaje zakolejkowany.
Przypuśćmy, że dostajemy odpowiedź serwera i odpowiedni callback sjaxFn pojawia się w kolejce. Potem włącza się timer i jego wywoływanie zwrotne również znajduje się w kolejce. Poza tym, programista powinien rozumień, że ustawia wartość opóźnienia dla timera, która oznacza ilość milisekund poprzedzających wykonanie. Robmy to dlatego, że przeglądarka musi poradzić sobie ze wszystkimi callbackami, które w tym momencie znajdują się w kolejce. Jeśli – na przykład – jedna sekunda jest potrzebna do wykonania każdego callbacku clickFN i ajaxFn, callback timera nie będzie wykonany w ciągu 100 milisekund, tak jak zostało to określone w skrypcie, ale po upływie 102. Taka niedokładność może jednak być zignorowana.
Mamy więc kolejkę:
Po wykonaniu naszego kodu w stosie zawołań, przeglądarka może odpalić pierwszy zakolejkowany callback. Pierwszym w kolejce jest clickFn – kiedy zostaje wykonany, wypada z kolejki. Następnie przeglądarka odpala callback ajaxFn, a zaraz po nim timerFn. Kolejka pustoszeje – to uproszczony schemat pętli zdarzeń.
Dzięki kolejce, callbacki nie są wykonywane równolegle, ale sekwencyjnie – jeden po drugim. Skoro mogą one odnosić się do tych samych zmiennych i tych samych węzłów DOM, egzekucja asynchroniczna (albo równoległa) mogłaby spowodować konflikty. W tym przypadku przeglądarka miałaby problemy z właściwym działaniem.
Kolejny interesujący fakt to to, że JavaScript nie blokuje języka, nawet jeśli pozwala na zdarzenia synchroniczne. Kiedy wykonywany jest AJAX, kod, który następuje po nim również zostanie wykonany i nie będzie oczekiwał na odpowiedź serwera. Oczywiście istnieją wyjątki takie jak zdarzenia typu alert albo synchroniczny JAJX, ale jak wiemy, nie jest to najlepszą praktyką.
To samo można powiedzieć o wątkach - JavaScript działa jednowątkowo - w interakcji z DOM. Ujmując rzecz ogólnie, nie ma sensu tworzyć wielu równoległych wątków - może to doprowadzić do nieporozumień i sprawić, że praca z DOM będzie niepotrzebnie trudniejsza. Ale jest sposób na zorganizowanie równoległych wątków w ramach skryptu - użycie Web Worker - technologii związanej ze standardem HTML5. Ma ona jedno ograniczenie - Web Worker nie współpracuje bezpośrednio z DOM i współdziała z głównym wątkiem za pomocą przesyłu wiadomości.