O obserwatorach w JavaScript - okładka

O obserwatorach w JavaScript

Jeśli masz jakieś, choćby szczątkowe, doświadczenie z JavaScriptem, to zapewne zdarzyło Ci się skorzystać z listenerów. Owe listenery nasłuchują na żądane zdarzenie czy też zdarzenia, a po ich wystąpieniu wykonują jakąś zdefiniowaną akcję. Dla osób, które nie miały z tym jeszcze styczności dołączam przykładowy kod:


// HTML
<button id="foo">Foo</button>

// JavaScript
document.getElementById('foo').addEventListener( 'click', function() {
  console.log('You clicked me!');
} );

Mając taki kod, za każdym razem, gdy zostanie kliknięty przycisk z id równym foo, zostanie wykonany kod przekazany w callbacku. Oczywiście możemy nasłuchiwać nie tylko na kliknięcia, ale i na sporo innych zdarzeń. Pełną ich listę znajdziesz w materiałach dodatkowych, gdyż nie na tym skupia się ten artykuł.

Na bardzo podobnej zasadzie działają Web API dostarczane przez przeglądarki zwane observerami. Zanim jednak przejdę do omówienia ich będzie nam potrzebne nieco teorii. Jak widzisz cały czas kręcimy się wokół obserwowania czy też nasłuchiwania czegoś. Taka koncepcja w świecie programowania została opisana i zdefiniowana jako wzorzec projektowy obserwator.

Obserwator - chłopiec obserwujący miasto przez teleskop

O obserwatorze słów kilka

Jak sama nazwa wskazuje głównym elementem tego wzorca jest obserwator (observer) oraz obserwowany lub obserwowani (observable, observee). Może to brzmieć jak masło maślane, ale najprościej mówiąc: obserwator obserwuje obserwowanych i wykonuje pewną zadeklarowaną akcję w momencie wystąpienia konkretnych warunków. Zobrazujmy to przykładem:
Załóżmy, że na naszym osiedlu mieszka złośliwy sąsiad, który bardzo nie lubi, gdy przy pobliskim trzepaku dzieci grają w piłkę. Sąsiad jest obserwatorem, natomiast trzepak jest obserwowanym. W chwili, gdy dzieci zaczynają przy nim grać w piłkę obserwujący zauważa interesującą go zmianę w obiekcie obserwowanym i wykonuje akcję – w tym wypadku może to być próba przegonienia dzieci lub telefon na policję. Zależy jak bardzo złośliwy jest sąsiad 🙂

Przykłady zastosowania w życiu codziennym możemy mnożyć w nieskończoność. Innym przykładem z życia może być przesłanie powiadomienia o nowym filmie w serwisie YouTube. Użytkownik tworzy obserwatora, który obserwuje dany kanał, który staje się obserwowanym. Obserwujący wyśle zainteresowanemu użytkownikowi powiadomienie, czyli wykona zadeklarowaną akcję w momencie wystąpienia interesującego nas zdarzenia, czyli publikacja nowego filmu. Podobny mechanizm wysyłania notyfikacji możemy spotkać praktycznie w każdej aplikacji mobilnej.

Observery w JavaScript

Przy tworzeniu aplikacji przeglądarkowych do dyspozycji dostajemy kilka Web API związanych z obserwacją. W tym wpisie skupię się na kilku z nich:

  • MutationObserver
  • PerformanceObserver
  • ResizeObserver
  • IntersectionObserver

MutationObserver

Na pierwszy ogień weźmy MutationObservera. Jak nazwa wskazuje nasłuchuje on na mutacje, czyli zmiany w obserwowanym obiekcie. Aby nieco Ci rozjaśnić temat pokażę kod z przykładem użycia:


const observer = new MutationObserver( data => {
  console.log( 'mutation!', data )
} );
 

observer.observe( document.getElementById( 'foo' ), {
	attributes: true,
	characterData: true,
        childList: true,
        subtree: true, 
} );

Na pierwszy rzut oka nie wydaje się to być zbyt skomplikowane. Na samym początku tworzymy obserwatora, któremu deklarujemy co ma się wykonać, gdy zostanie zaobserwowane interesujące nas zdarzenie. Następnie za pomocą metody observe() dodajemy do puli obserwowanych obiektów nowy obiekt wraz z parametrami jakie nas interesują. Powyższy kod dodaje do puli obserwowanych obiektów element z id równym foo zaś interesujące nas mutacje to: zmiany wartości atrybutów, zmiany wartości data, dodawanie oraz usuwanie elementów potomnych oraz zmiany w elementach potomnych.

Mówiąc o atrybutach mamy na myśli zmiany takie jak, dodanie czy usunięcie klasy lub id w elemencie, zmiany atrybutu href w linku czy wartości checked w checkboxie. Oczywiście to tylko kilka przykładów – można ich podać znacznie więcej. Nie dotyczy to jednak atrybutów data-[wartość] – za to odpowiedzialna jest opcja characterData. Kolejna opcja to childList, który obserwuje dodawanie i usuwanie elementów potomnych. Ostatnim parametrem jest subtree, który propaguje obserwację na elementy potomne. W tym miejscu koniecznie należy zaznaczyć o używaniu tych parametrów z rozwagą. Obserwując na przykład element body z powyższą konfiguracją będziemy de facto obserwowali każdą zmianę w każdym elemencie na stronie, co na pewno nie wpłynie pozytywnie na wydajność naszej aplikacji. Pamiętajmy zatem, aby korzystać z tego rozwiązania rozważnie. Oprócz powyższych opcji konfiguracji do dyspozycji mamy jeszcze:

  • attributeOldValue – wartość Boolean – pozwala nam na przechwycenie poprzedniej wartości atrybutu,
  • characterDataOldValue – wartość Boolean – pozwala nam na przechwycenie poprzedniej wartości atrybutu data,
  • attributeFilterArray z interesującymi nas atrybutami – pozwala filtrować na jakie atrybuty chcemy nasłuchiwać. Nie ma sensu nasłuchiwać na wszystkie atrybuty, w momencie, gdy interesuje nas na przykład tylko zmiana klasy elementu.

Nie zapomnijmy też o wyłączeniu obserwatora, gdy już go nie potrzebujemy. Posłuży nam do tego observer.disconnect().

Wydajność - PerformanceObserver - metafora silnika

PerformanceObserver

Wspomniałem już co nieco o wydajności przy MutationObsereverze. Do mierzenia wydajności posłużyć nam może PerformanceObserver. Tematowi wydajności przyjrzałem się bliżej w jednym ze swoich wpisów – Mierzenie wydajności aplikacji WWW. Jeśli jeszcze go nie przeczytałeś to zachęcam do zapoznania się.

Myślę, że w tym przypadku również dobrze będzie zacząć od kawałka kodu:


function calc() {
    let sum = 0;
    performance.measure( 'sum' );
    for( let i = 0; i <= 50000; i++ ) { 
        sum += i 
    } 
    performance.measure( 'sum' );
} 

const observer = new PerformanceObserver( list => {
    const entries = list.getEntries();
    console.log( entries[ 1 ].duration - entries[ 0 ].duration )
} );

observer.observe( { entryTypes: [ 'measure' ] } );

document.getElementById( 'foo' ).addEventListener( 'click', function() {
    calc();
} );

Tutaj jak widzimy również tworzymy nową instancję obserwatora i używamy metody observe(), także za wiele się nie zmieniło.  Jako argument instancji obserwatora podajemy co ma się wykonać, natomiast w konfiguracji podajemy zdarzenia jakie chcemy obserwować. W naszym wypadku nasłuchujemy na zdarzenie measure. Powyższy kod powinien nam pokazać ile czasu (w milisekundach) zajmie dodanie do siebie liczb od 0 do 50000. Oczywiście do entryTypes możemy przekazać inne wartości niż tylko measure – ich pełną listę wraz z opisami znajdziesz na MDN.

ResizeObserver

Przy zapoznawaniu się z ResizeObserverem miałem wątpliwości co do jego użyteczności. Przecież do dyspozycji mamy zdarzenie resize, które nasłuchuje na zmianę wymiarów okna lub dokumentu.  To w czym ResizeObserever wygrywa z tradycyjnym podejściem opartym na dodaniu listenera jest możliwość nasłuchiwania na zmianę wymiarów elementów, nie tylko window i document.

Dotyczy to także zmian powodowanych przez na przykład dodanie lub usunięcie elementów z obserwowanego elementu. Co więcej, nie jesteśmy zmuszeni do szukania wymiarów elementu po jego właściwościach, gdyż mamy do nich dostęp bezpośrednio w callbacku.


const observer = new ResizeObserver( entries => {
  for ( let entry of entries ) {
    console.log( entry );
  }
} );

observer.observe( document.querySelector( '.container' ) );

Użycie tego observera jest banalne, nie różni się niczym od poprzednich przypadków. Podczas tworzenia observera deklarujemy akcję do wykonania, a w metodzie observe() podajemy interesujący nas element i to w zasadzie tyle.

IntersectionObserver

Ostatnim z obserwatorów jaki weźmiemy na tapet będzie IntersectionObserver. Stosujemy go w momencie, gdy potrzebujemy obserwować, czy dany element jest obecny na ekranie czy też nie. Znajdzie to zastosowanie głównie w przypadku stron mobilnych, oraz stron z dużą ilością treści. Genialnie to się sprawdzi przy implementacji chociażby lazy loadingu, czy infinite scrollingu. Kod w zasadzie nie będzie się znacząco różnił od poprzedniego przykładu, ale dla jasności i tak go tu umieszczę:


const observer = new IntersectionObserver( entries => {
    for ( let entry of entries ) {
        console.log( entry );
    }
} );

observer.observe( document.querySelector( '.gallery' ) );

Co ciekawe, w entry mamy dostęp do właściwości intersectionRatio, która mówi nam jaki procent elementu jest widoczny na ekranie.

Aby przestać obserwować dany element, należy użyć observer.unobserve( element ). Zaś jeśli całkowicie chcemy wyłączyć observera to posłuży nam do tego observer.disconnect().

I to by było na tyle

Standardowo zachęcam do pozostawienia komentarza i zajrzenia do źródeł – znajdziesz tam więcej szczegółów i przykładów. Jeśli dowiedziałeś/aś się czegoś nowego, to udostępnij ten wpis na swoich social mediach! 🙂

Źródła i materiały dodatkowe: