Lazy Loading w czystym JavaScript
Zastanawiałeś się kiedyś w jaki sposób możesz poprawić wydajność strony internetowej? Szybkość jej ładowania stanowi jeden z najważniejszych czynników w pozycjonowaniu. Warto więc zadbać o odpowiednie rozwiązania usprawniające ogólną wydajność strony już na etapie kodowania. W tym artykule chciałbym przybliżyć temat Lazy Loadingu za pomocą IntersectionObservera
, a przy okazji pokazać kilka innych sposobów przyspieszenia ładowania oraz poprawy ogólnego odbioru strony przez użytkownika końcowego.
Spis treści
Czym jest Lazy Loading?
Biorąc pod uwagę czystą definicję – jest to opóźnienie załadowania, informacji określanych jako ‘nieistotne’, czyli takich jak wideo, obrazki lub po prostu czyste dane, na stronę internetową. Za nieistotne możemy przyjąć tylko te informacje, które mogą i powinny być załadowane, jedynie, kiedy są potrzebne.
Posłużmy się tutaj prostym przykładem – nasz użytkownik nie zawsze musi przewinąć stronę do miejsca, gdzie umieściliśmy sekcję z galerią lub odtwarzaczem wideo, więc nie ma sensu, aby pobierać te dane podczas pierwszego renderowania strony (Initial Render). Podczas sprawdzania stron przez audyty te nieistotne pliki są też określane jako elementy ‘poza ekranem’ (offscreen assets/images):
Jestem pewny, że przeczesując zakamarki internetu (zarówno te dobre, jak i te złe) na pewno natknęliście się na swojej drodze na jakieś zastosowanie Lazy Loadingu. Jak to mogło wyglądać?
- Po przewinięciu na koniec listy z artykułami, pokazała się ikona
loadera
, a po chwili na stronę załadowały się kolejne artykuły. - Docierając do sekcji z galerią, w pierwszej chwili obrazy były niewyraźne lub rozmyte, a po chwili wyświetlały się już w normalnej jakości.
- Sekcja, która zdawała się być jedynie statycznym obrazem, po przewinięciu strony, okazywała się być filmem odtwarzanym w tle.
Myślę, że po tym wstępie powinniście już mniej więcej wiedzieć na czym polega Lazy Loading. Przejdźmy teraz do tej ciekawszej części, czyli tego jak i dlaczego powinienem go stosować w swoim kodzie!
Lazy loading – do czego mi to potrzebne?
Zacznijmy od tego, że Lazy Loading może w znaczący sposób przyczynić się do obniżenia czasu potrzebnego do pierwszego załadowania się strony i momentu, w którym stanie się w pełni interaktywna (become interactive). Dzieje się tak poprzez ograniczenie ilości zapytań i danych, które są pobierane przez naszą aplikację. Ucięcie nawet kilku sekund z ‘pierwszej inicjalizacji’ strony może mieć znaczący wpływ na wzrost wskaźników konwersji użytkowników i na to, jak nasza strona będzie przez nich postrzegana.
Na podstawie wyników badań (zapewne przez Amerykańskich naukowców ^^) stwierdzono, że nawet jedno sekundowe opóźnienie podczas ładowania strony może skutkować obniżeniem o 11% wyświetleń strony i spadkiem satysfakcji odwiedzających aż o 16%! Szybki czas ładowania i ograniczenie ilości pobieranych danych są szczególnie istotne dla użytkowników urządzeń mobilnych, którzy odpowiadają za ponad połowę ruchu w sieci.
Pomijanie wyświetlania zbędnych danych przekłada się dla nich bezpośrednio na korzyści materialne, zwłaszcza dla osób z limitowanymi planami dostępu do internetu. Zyskają na tym także same urządzenia mobilne poprzez ograniczenie zużycia baterii czy mocy obliczeniowej procesora!
Teraz możesz sobie myśleć: “Ok wszystko fajnie, ale jak mogę zastosować Lazy Loading w swoim kodzie?”. Pozwólcie, że podrzucę kilka pomysłów na to jak ja rozwiązałem ten problem (na podstawie strony internetowej Merixstudio).
Praktyczne zastosowanie
Wychodzę z założenia, że czytając ten artykuł posiadasz już wiedzę na temat JavaScript i podstawowych konceptów z nim związanych. Jeśli nie to lepiej przed przejściem dalej zapoznaj się z podstawami.
Przypomnę temat tego artykułu – Lazy Loading w czystym JavaScript. Najprostszym podejściem do tego tematu wydaje się sięgnięcie po sprawdzoną, gotową bibliotekę. Poniżej krótka lista z dostępnymi opcjami (oczywiście opcji jest o wiele więcej, ale nie taka jest istota tego artykułu):
- lazyload – bardzo ‘lekka’ biblioteka wspierająca nawet archaiczne przeglądarki, takie jak IE9. Do zastosowania również w przypadku użycia któregoś z popularnych frameworków (React, Angular lub Vue.js),
- lazysizes – biblioteka przyjazna SEO, wydajna i z dużą liczbą dodatkowych pluginów, poszerzających podstawowe funkcjonalności,
- yall.js – kompatybilna z IE11 i wszystkimi nowoczesnymi przeglądarkami; lekka – waży jedynie 1.64 kB.
Spokojnie, jeśli nie jesteś tu po to, żeby korzystać z gotowych rozwiązań mamy jeszcze do przedyskutowania dwie opcje.
Obsługa Eventów (Event Handlers)
Rozwiązaniem najbardziej pewnym i kompatybilnym, jeśli chodzi o większość przeglądarek, nawet tych archaicznych, jest zastosowanie do Lazy Loadingu obsługi eventów (event handlers). Żeby pokazać ten przykład posłużę się fragmentem kodu od Google Developers. Pozwólcie, że przeprowadzę was krok po kroku po tym, co dzieje się w kodzie.
Po pierwsze, po załadowaniu naszego DOM’u tworzymy tablicę stworzoną ze wszystkich obrazków z przypisaną klasą o nazwie lazy
. Co ciekawe, obrazki zamiast podstawowej wartości src
, w której określamy ścieżkę dostępu do pliku, mają przypisaną wartość data-src
, a co za tym idzie, nie są wyświetlane przy pierwszym załadowaniu strony (Initial Page Load).
Następnie, po tym jak użytkownik przewinie stronę do miejsca, w którym dany obraz powinien się wyświetlić, pusta jak dotąd właściwość src
zostaje uzupełniona danymi z data-src
i obraz jest renderowany na stronie. W tym samym czasie z obrazka usuwana jest klasa .lazy
, a sam element jest usuwany z tablicy o nazwie lazyImages
. Ten proces jest kontynuowany, aż do momentu, w którym zostaną załadowane wszystkie obrazy i nasza tablica będzie pusta. Wtedy są również usuwane/czyszczone obsługi eventów (scroll
, resize
, onorientationchange
).
document.addEventListener("DOMContentLoaded", function() { let lazyImages = [].slice.call(document.querySelectorAll("img.lazy")); let active = false; const lazyLoad = function() { if (active === false) { active = true; setTimeout(function() { lazyImages.forEach(function(lazyImage) { if ((lazyImage.getBoundingClientRect().top <= window.innerHeight && lazyImage.getBoundingClientRect().bottom >= 0) && getComputedStyle(lazyImage).display !== "none") { lazyImage.src = lazyImage.dataset.src; lazyImage.srcset = lazyImage.dataset.srcset; lazyImage.classList.remove("lazy"); lazyImages = lazyImages.filter(function(image) { return image !== lazyImage; }); if (lazyImages.length === 0) { document.removeEventListener("scroll", lazyLoad); window.removeEventListener("resize", lazyLoad); window.removeEventListener("orientationchange", lazyLoad); } } }); active = false; }, 200); } }; document.addEventListener("scroll", lazyLoad); window.addEventListener("resize", lazyLoad); window.addEventListener("orientationchange", lazyLoad); });
Przykład, którym posłużyłem się wyżej, może powodować pewne problemy z wydajnością – generalnie podpinanie się pod obsługę eventów przewijania (scroll
) do obiektu okna (window
) nie jest najlepszym pomysłem, ponieważ ten event jest odpalany zbyt często. Pomimo zastosowania ograniczeń (throttle
) dla tej funkcji jest ona wywoływana co 200 milisekund, bez względu na to czy nasz Lazy Loadowany obraz znajduje się w tym momencie w oknie podglądu czy nie. I w ten sposób dotarliśmy do, w mojej opinii, najlepszego obecnie dostępnego rozwiązania, czyli IntersectionObserver.
IntersectionObserver API
IntersectionObserver API to bardzo przydatne narzędzie, które w prostych słowach pozwala na obserwowanie elementów, przecinających się z oknem podglądu (viewport
) i, jeśli do tego dojdzie, zostaje wywołana funkcja zwrotna (callback function). To rozwiązanie jest wspierane przez większość współczesnych przeglądarek. W przypadku, gdy walczysz w pracy z dinozaurami (np. uwielbianym przez wszystkich IE11), polecam zastosować tę łatkę (polyfill
). Do stworzenia ‘obserwatora’ musimy wywołać jego konstruktor i przekazać jako pierwszy argument funkcję (callback function
), która będzie wywoływana w przypadku przekroczenia punktu (threshold
) przecięcia oraz jako drugi argument obiektu z opcjami, żeby doprecyzować zachowanie naszego obserwatora
.
Naszego Obserwatora możemy nazwać dowolnie:
const assetsObserver = new IntersectionObserver(callback, options);` Opcje również mogą być nazwane dowolnie – jak na razie zostawmy je po prostu jako ‘options’. ` const options = { root: ‘’, rootMargin: ‘’, threshold: ‘’ };
Do czego dokładnie używane są te opcje? Nie rozpisując się i nie zagłębiając w techniczne opisy, po szczegóły odsyłam do dokumentacji MDN. Postaram się jedynie w kilku zdaniach opisać najważniejsze spostrzeżenia odnośnie każdej z opcji, a dalszy kod powinien rozwiać już wszelkie wątpliwości:
- Root: w większości przypadków nie będziesz musiał przejmować się tą właściwością. Jeśli nie zostanie określona lub przypiszemy do niej 0, odwołuje się do viewportu, żeby sprawdzić czy dany element jest widoczny,
- rootMargin: przyjmuje właściwości podobnie jak
margin
w css – na przykład ‘0px 0px 200px 0px’. Jedyna różnica, na którą warto zwrócić uwagę, nawet w przypadku, gdy któraś z wartości jest równa 0, jest taka, że musimy określić jednostkę (zarówno % lub px). Podany zestaw wartości oznacza, że rozszerzamy górną część elementu źródłowego o 200px. Domyślnie te wartości są ustawione na 0, - Threshold: przyjmuje jako argument tablicę lub pojedynczą liczbę. Domyślnie ustawione na 0, co oznacza, że nasz callback function odpali się w przypadku, gdy nawet 1px naszego obserwowanego elementu będzie widoczny w viewporcie. Analogicznie ustawienie treshold na 1 oznacza, że funkcja zostanie wywołana jedynie w przypadku, gdy cały nasz obiekt znajdzie się w polu widzenia.
Wybór odpowiednich elementów
Przed przejściem do dalszych części kodowania musimy najpierw określić, które z elementów na naszej stronie nadają się do Lazy Loadingu. Na pierwszy rzut oka najlepszymi kandydatami są oczywiście obrazy, zarówno używane jako tło w css’ie, jak i inline w HTML’u. Weźmy pod uwagę również pliki wideo, żeby jeszcze bardziej ograniczyć ilość danych pobieranych przy pierwszym ładowaniu strony. Do napisania tego artykułu jako przykładem posłużę się podstroną About Us
.
Przewijając na sam dół tej strony możecie poznać całe Merixstudio! Na tę chwilę zatrudniamy już ponad 100 osób, więc możecie sobie wyobrazić ilość danych i czas, potrzebny do pobrania i wyświetlenia wszystkich tych osób. Nie pozostaje nam więc nic innego niż tylko użyć Lazy Loadingu!
Html markup
Zacznijmy od czegoś prostego – dla obrazów wystarczy podmienić atrybut ‘src’ na dowolny atrybut data np. data-src
:
<img src=’my/images/myAwesomePicture.png’ alt=’my awesome picture’>
Zamień na:
<img data-src=’my/images/myAwesomePicture.png’ alt=’my awesome picture’>
Dalej nie będziemy musieli już zajmować się HTML. Tak, to wszystko co musieliśmy tutaj zrobić! Teraz wróćmy do miejsca, w którym skończyliśmy z IntersectionObserver w JavaScript.
Zabawa z JavaScript
W rozbudowanej aplikacji na pewno będziemy chcieli używać naszej funkcji Lazy Loadu w wielu miejscach, przekazując różne parametry w zależności od potrzeb, więc dobrym podejściem będzie zdefiniowanie jej jako klasy:
export class LazyLoad { constructor(element, options) { this.options = { selector: ['data-src'], rootMargin: ‘0px 0px 550px', threshold: 0, ...options, }; this.element = element; this.resources = this.element.querySelectorAll('[data-src]'); this.init(); } }
Zacznijmy od konstruktora. Przekazujemy do niego element, który będzie zawierał w sobie wszystkie nasze komponenty, których ładowanie chcemy opóźnić. Jako drugi argument będzie z kolei uwzględniać dodatkowe opcje możliwe do zmiany za każdym razem, gdy wywołujemy naszą klasę. Z tego powodu stosujemy tutaj spread operator
. Od razu ustalamy kilka podstawowych wartości, takich jak nasz selektor z HTML’a i rootMargin
, poszerzający wymiar naszego okna z góry o 550px.
W tym miejscu definiujemy nową zmienną o nazwie resources
, do której przypisujemy wszystkie elementy zawierające nasz selektor data-src
. Następnie możemy przejść do zainicjalizowania głównej funkcjonalności:
init() { const assetsObserver = new IntersectionObserver((entries, observer) => { //co dalej? }, this.options); }
W tej linijce tworzymy nowy IntersectionObserver z naszą callback function pod postacią funkcji strzałkowej z ES6 i jako drugi argument przekazujemy opcje określone w konstruktorze. Funkcja zwrotna również przyjmuje dwa argumenty entries
i assetsObserver
, ponieważ chcemy się odwołać do tej samej funkcji nieco później.
W środku, na przekazanych entries
, chcemy użyć pętli forEach
. Na razie użyjmy jedynie console.log(entry)
, dzięki czemu zobaczymy, jakie właściwości posiada pojedyncze entry
. Aby ten kod zadziałał, musimy zacząć obserwować nasze resources
. Tutaj również możemy użyć pętli forEach
.
init() { const assetsObserver = new IntersectionObserver((entries, assetsObserver) => { entries.forEach(entry => { console.log(entry); }); }, this.options); this.resources.forEach( resource => assetsObserver.observe(resource)); }
Zapiszmy zmiany i zobaczmy, jakie informacje dostaliśmy w konsoli.
Jak widzicie, dostajemy całkiem sporo informacji na temat naszego entry
. Aby dokończyć naszą pracę, powinniśmy się skupić na dwóch wartościach, a mianowicie isIntersecting
i target
. Wówczas mamy już wszystko czego potrzebujemy, usuńmy tylko console.log’a i napiszmy właściwą callback function!
init() { const assetsObserver = new IntersectionObserver((entries, assetsObserver) => { entries.filter(entry => entry.isIntersecting).forEach(entry => { this._lazyLoadAsset(entry.target); }); }, this.options); this.resources.forEach( resource => assetsObserver.observe(resource); }
Oczywiście nie chcemy wykonywać żadnych akcji, w przypadku, kiedy nasze entry.isIntersecting
ma wartość false. Aby odrzucić wszystkie błędne entry
, stosujemy funkcję filter. W przypadku, gdy warunek entry.isIntersecting
zostaje spełniony, wyciągamy z danego entry
wartość z atrybutu data-src
określonego w HTML i zwracamy ją przypisaną do zwyczajowego atrybutu src
i w ten sposób wyświetlamy dany obraz na stronie. Dobrą praktyką będzie przeniesienie tej funkcjonalności poza naszego observera
do osobnej funkcji.
_lazyLoadAsset(asset) { const src = asset.getAttribute(this.options.selector); if(!src) { return; } asset.src = src; }
Tutaj dodajemy mały warunek if
, żeby mieć absolutną pewność, że nasza ścieżka istnieje. Mogłoby się wydawać, że to już wszystko, ale prawie zapomnieliśmy o jednej bardzo istotnej linijce w naszym kodzie! Domyślasz się o co chodzi? Tak, dokładnie po tym, jak nasze obrazy zostaną załadowane na stronę, nie ma sensu, żeby je dalej obserwować! Złóżmy teraz wszystkie elementy kodu w jedną całość:
export class LazyLoad { constructor(element, options) { this.options = { selector: ['data-src'], rootMargin: '550px 0px', threshold: 0.01, ...options, }; this.element = element; this.resources = this.element.querySelectorAll('[data-src]'); this.bindEvents(); this.init(); } bindEvents() { this._lazyLoadAsset = this._lazyLoadAsset.bind(this); } init() { const assetsObserver = new IntersectionObserver((entries, assetsObserver) => { entries.filter(entry => entry.isIntersecting).forEach(entry => { this._lazyLoadAsset(entry.target); assetsObserver.unobserve(entry.target); }); }, this.options); this.resources.forEach(resource => { assetsObserver.observe(resource); }); } _lazyLoadAsset(asset) { const src = asset.getAttribute(this.options.selector); if (!src) { return; } asset.src = src; } }
Co zyskaliśmy?
Chyba najlepszym sposobem na pokazanie tego, co osiągnęliśmy poprzez zastosowanie IntersectionObserver, będzie pokazanie danych z uruchomienia podstrony About Us
bez oraz z Lazy Loadingiem.
Wygląda obiecująco prawda? Zaoszczędziliśmy masę czasu i zasobów dodając tylko kilka linijek kodu do naszej strony. Mam nadzieję, że ten artykuł pomoże też w poprawieniu wydajności waszych aplikacji. Udanego kodowania!
Zdjęcie główne artykułu pochodzi z unsplash.com.