Asynch JS – potęga $.Deferred

Jednym z najważniejszych aspektów tworzenia płynnych i elastycznych aplikacji HTML5 jest synchronizacja między wszystkimi jej częściami, takimi jak pobieranie i przetwarzanie danych, animacje i elementy interfejsu użytkownika.

Główna różnica między środowiskiem stacjonarnym a natywnym polega na tym, że przeglądarki nie przyznają dostępu do modelu podziału na wątki i zapewniają 1 wątek na wszystkie dane, które mają dostęp do interfejsu (DOM). Oznacza to, że cała logika aplikacji, która uzyskuje dostęp do elementów interfejsu i je modyfikuje, odbywa się zawsze w tym samym wątku. Ważne jest więc zachowanie jak najmniejszej wydajności wszystkich jednostek roboczych aplikacji i maksymalne wykorzystanie wszystkich asynchronicznych możliwości przeglądarki.

Asynchroniczne interfejsy API przeglądarek

Na szczęście przeglądarki udostępniają szereg asynchronicznych interfejsów API, np. powszechnie używane interfejsy API XHR (XMLHttpRequest, „AJAX”), a także instancje robocze IndexedDB, SQLite i HTML5 Web oraz interfejsy API HTML5 GeoLocation. Nawet niektóre działania związane z DOM są udostępniane asynchronicznie, np. animacje CSS3 za pomocą zdarzeń przejściaEnd.

Sposób, w jaki przeglądarki udostępniają logikę aplikacji asynchronicznej, polega na zdarzeniach lub wywołaniach zwrotnych.
W asynchronicznych interfejsach API opartych na zdarzeniach programiści rejestrują moduł obsługi zdarzeń dla danego obiektu (np. elementu HTML lub innych obiektów DOM), a potem wywołują działanie. Przeglądarka wykona działanie zwykle w innym wątku i w razie potrzeby wywoła zdarzenie w wątku głównym.

Na przykład kod korzystający z interfejsu XHR API, asynchronicznego interfejsu API opartego na zdarzeniach, wyglądałby następująco:

// Create the XHR object to do GET to /data resource  
var xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
alert("We got data: " + xhr.response);
}
},false)

// perform the work
xhr.send();

Zdarzenie przejścia CSS3 to inny przykład asynchronicznego interfejsu API opartego na zdarzeniach.

// get the html element with id 'flyingCar'  
var flyingCarElem = document.getElementById("flyingCar");

// register an event handler 
// ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) 
flyingCarElem.addEventListener("transitionEnd",function(){
// will be called when the transition has finished.
alert("The car arrived");
});

// add the CSS3 class that will trigger the animation
// Note: some browers delegate some transitions to the GPU , but 
//       developer does not and should not have to care about it.
flyingCarElemen.classList.add('makeItFly') 

Inne interfejsy API przeglądarek, takie jak SQLite i geolokalizacja HTML5, są oparte na wywołaniach zwrotnych, co oznacza, że programista przekazuje funkcję jako argument, który zostanie wywołany przez odpowiednią implementację z odpowiednią rozdzielczością.

Na przykład w przypadku geolokalizacji HTML5 kod wygląda tak:

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

W tym przypadku po prostu wywołujemy metodę i przekazujemy funkcję, która zostanie wywołana z żądanym wynikiem. Dzięki temu przeglądarka może wdrażać tę funkcję synchronicznie lub asynchronicznie oraz przekazywać deweloperowi pojedynczy interfejs API bez względu na szczegóły implementacji.

Przygotowywanie aplikacji do asynchronicznych

Oprócz wbudowanych przez przeglądarkę asynchronicznych interfejsów API dobrze dobrane aplikacje powinny także udostępniać interfejsy API niskiego poziomu w sposób asynchroniczny, zwłaszcza gdy wykonują różne operacje wejścia-wyjścia lub przetwarzania o dużym stopniu intensywności obliczeniowej. Na przykład interfejsy API służące do pobierania danych powinny być asynchroniczne i NIE powinny wyglądać tak:

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

Ten projekt interfejsu API wymaga blokowania metody getData(), co powoduje zablokowanie interfejsu użytkownika do czasu pobrania danych. Jeśli dane są lokalne w kontekście JavaScriptu, może to nie być problemem, ale jeśli trzeba je pobrać z sieci, a nawet lokalnie do SQLite lub magazynu indeksu, może to mieć ogromny wpływ na wygodę użytkowników.

Właściwy projekt to proaktywne przygotowanie wszystkich interfejsów API aplikacji, które mogą zająć trochę czasu, i asynchronicznych od początku, ponieważ wsteczne przekształcanie synchronicznego kodu aplikacji w asynchroniczny może być trudnym zadaniem.

Na przykład uproszczony interfejs API getData() wyglądałby następująco:

getData(function(data){
alert("We got data: " + data);
});

Zaletą tego podejścia jest to, że od samego początku wymusza to asynchroniczny kod interfejsu aplikacji i pozwala bazowym interfejsom API zdecydować, czy na późniejszym etapie muszą być asynchroniczne, czy nie.

Zwróć uwagę, że nie wszystkie interfejsy API aplikacji wymagają lub powinny być asynchroniczne. Zgodnie z zasadą każdy interfejs API, który wykonuje dowolny rodzaj wejścia-wyjścia lub intensywne przetwarzanie (co może trwać dłużej niż 15 ms), powinien być udostępniany asynchronicznie od początku, nawet jeśli pierwsza implementacja jest synchroniczna.

Obsługa błędów

Jedną z korzyści programowania asynchronicznego jest to, że tradycyjny sposób obsługi porażek w przeglądarce już nie działa, ponieważ błędy zwykle mają miejsce w innym wątku. W związku z tym osoba dzwoniąca musi mieć uporządkowany sposób powiadamiania rozmówcy o problemach podczas przetwarzania.

W przypadku asynchronicznego interfejsu API opartego na zdarzeniach często jest to realizowane przez kod aplikacji wysyłający zapytanie do zdarzenia lub obiektu podczas jego odbierania. W przypadku asynchronicznych interfejsów API opartych na wywołaniach zwrotnych sprawdzoną metodą jest zastosowanie drugiego argumentu, który przyjmuje funkcję, która jest wywoływana w przypadku awarii z odpowiednimi informacjami o błędzie jako argumentem.

Wywołanie getData wygląda w ten sposób:

// getData(successFunc,failFunc);  
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});

Połączenie z $.Deferred

Jednym z ograniczeń powyższego wywołania zwrotnego jest to, że zapisywanie nawet umiarkowanie zaawansowanych logiki synchronizacji może być naprawdę kłopotliwe.

Jeśli np. musisz poczekać na wykonanie dwóch asynchronicznego interfejsu API, zanim wykonasz trzeci, jego złożoność szybko może wzrosnąć.

// first do the get data.   
getData(function(data){
// then get the location
getLocation(function(location){
alert("we got data: " + data + " and location: " + location);
},function(ex){
alert("getLocation failed: "  + ex);
});
},function(ex){
alert("getData failed: " + ex);
});

Sprawy się komplikują, gdy aplikacja musi wykonywać to samo wywołanie z różnych części aplikacji, ponieważ każde wywołanie musi wykonywać te wywołania wieloetapowe lub aplikacja musi zaimplementować własny mechanizm buforowania.

Na szczęście jest stosunkowo stary wzorzec o nazwie Promises (podobny do Future w Javie) oraz solidnej i nowoczesnej implementacji w rdzeniu jQuery o nazwie $.Deferred, która stanowi proste i zaawansowane rozwiązanie do programowania asynchronicznego.

Dla uproszczenia wzorzec Promises definiuje, że asynchroniczny interfejs API zwraca obiekt Promise, który jest w pewnym sensie „obietnicą, że wynik zostanie rozpoznany przy użyciu odpowiednich danych”. Aby uzyskać rozwiązanie, element wywołujący otrzymuje obiekt Promise i wywołuje metodę done(successFunc(data)), która informuje obiekt Promise o wywołaniu tego argumentu „successFunc”.

Przykładowe wywołanie getData powyżej wygląda tak:

// get the promise object for this API  
var dataPromise = getData();

// register a function to get called when the data is resolved
dataPromise.done(function(data){
alert("We got data: " + data);
});

// register the failure function
dataPromise.fail(function(ex){
alert("oops, some problem occured: " + ex);
});

// Note: we can have as many dataPromise.done(...) as we want. 
dataPromise.done(function(data){
alert("We asked it twice, we get it twice: " + data);
});

W tym przypadku najpierw pobieramy obiekt dataPromise, a potem wywołujemy metodę .done, by zarejestrować funkcję, którą mamy wywoływać z powrotem po rozwiązaniu danych. Aby obsłużyć ewentualną awarię, możemy też wywołać metodę .fail. Pamiętaj, że możesz mieć dowolną liczbę wywołań .done lub .fail, ponieważ bazowa implementacja Promise (kod jQuery) będzie obsługiwać rejestrację i wywołania zwrotne.

Przy takim wzorcu można stosunkowo łatwo wdrożyć bardziej zaawansowany kod synchronizacji, a jQuery udostępnia już najczęściej spotykany kod, taki jak $.when.

Na przykład zagnieżdżone powyżej wywołanie zwrotne getData/getLocation wygląda tak:

// assuming both getData and getLocation return their respective Promise
var combinedPromise = $.when(getData(), getLocation())

// function will be called when both getData and getLocation resolve
combinePromise.done(function(data,location){
alert("We got data: " + dataResult + " and location: " + location);
});  

Piękno polega na tym, że jQuery.Deferred ułatwia programistom wdrożenie funkcji asynchronicznej. Pole getData może wyglądać np. tak:

function getData(){
// 1) create the jQuery Deferred object that will be used
var deferred = $.Deferred();

// ---- AJAX Call ---- //
XMLHttpRequest xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
    // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...)
    deferred.resolve(xhr.response);
}else{
    // 3.2) REJECT the DEFERRED (this will trigger all the fail()...)
    deferred.reject("HTTP error: " + xhr.status);
}
},false) 

// perform the work
xhr.send();
// Note: could and should have used jQuery.ajax. 
// Note: jQuery.ajax return Promise, but it is always a good idea to wrap it
//       with application semantic in another Deferred/Promise  
// ---- /AJAX Call ---- //

// 2) return the promise of this deferred
return deferred.promise();
}

Po wywołaniu metody getData() najpierw tworzy się nowy obiekt jQuery.Deferred obiekt (1), a następnie zwraca obietnicę (2), dzięki czemu element wywołujący może zarejestrować swoje działania wykonane i niepowodzenia. Następnie, gdy wywołanie XHR wróci, rozwiązuje problem odroczonego (3.1) albo je odrzuca (3.2). Wykonanie instrukcji deferred.resolve będzie powodowało wywołanie wszystkich funkcji complete(...) i innych funkcji obiecujących (np. wówczas i pipeta), a wywołanie funkcji deferred.reject powoduje wywołanie wszystkich funkcji error().

Przykłady zastosowania

Oto kilka dobrych przypadków użycia funkcji Odroczona:

Dostęp do danych: udostępnienie interfejsów API dostępu do danych jako $.Deferred to często właściwą strukturę. Jest to oczywiste w przypadku danych zdalnych, ponieważ synchroniczne wywołania zdalne całkowicie utrudniają użytkownikom korzystanie z usług, ale dotyczy to też danych lokalnych, a często także interfejsów API niższego poziomu (np. SQLite i IndexedDB) same są asynchroniczne. Znaki $.when i .pipe w interfejsie API Deferred to niezwykle zaawansowane funkcje synchronizacji i łańcuchów asynchronicznych podzapytań.

Animacje interfejsu: organizowanie co najmniej jednej animacji za pomocą zdarzeń przejścia z efektem przejścia może być dość uciążliwe, zwłaszcza gdy są one mieszanką animacji CSS3 i JavaScriptu (jak to często bywa). Dodanie funkcji animacji jako odroczonej może znacznie zmniejszyć złożoność kodu i zwiększyć jego elastyczność. Bardzo przydatna może być nawet prosta ogólna funkcja otoki, taka jak cssAnimation(className), która zwraca obiekt Promise rozwiązany w momencie przejścia.

Wyświetlanie komponentów interfejsu: to trochę bardziej zaawansowane, ale zaawansowane struktury komponentów HTML również powinny korzystać z Odroczonych. Gdy aplikacja musi wyświetlać różne części interfejsu użytkownika, nie wdając się za bardzo w szczegóły (to będzie temat w innym poście), a cykl życia tych komponentów zawarty w sekcji Odroczone pozwala lepiej kontrolować czas.

Dowolny asynchroniczny interfejs API przeglądarki: na potrzeby normalizacji często dobrym pomysłem jest oznaczenie wywołań interfejsu API przeglądarki jako „Odroczone”. Zwykle zajmuje to 4–5 wierszy kodu, ale znacznie upraszcza każdy kod aplikacji. Jak pokazano w powyższym pseudokodzie getData/getLocation, umożliwia to kodowi aplikacji używanie jednego modelu asynchronicznego we wszystkich typach interfejsów API (przeglądarkach, specyfikach aplikacji i komponentach).

Pamięć podręczna: jest to pewna poboczna korzyść, ale w niektórych przypadkach może być bardzo przydatna. Ponieważ obiecujące interfejsy API (np. .done(...) i .fail(...)) można wywoływać przed lub po wykonaniu wywołania asynchronicznego. Obiekt Odroczony można użyć jako uchwytu pamięci podręcznej w przypadku wywołania asynchronicznego. Na przykład CacheManager może po prostu śledzić informacje o odroczonych żądaniach i zwrócić obietnicę dopasowania „Odroczone”, jeśli nie została unieważniona. Rozmówca nie musi wiedzieć, czy połączenie zostało już zakończone czy też jest właśnie przetwarzane – jej funkcja wywołania zwrotnego zostanie wywołana dokładnie w ten sam sposób.

Podsumowanie

Chociaż koncepcja $.Deferred jest prosta, jej opanowanie może trochę potrwać. Jednak ze względu na specyfikę środowiska przeglądarki opanowanie programowania asynchronicznego w języku JavaScript jest niezbędne dla każdego programisty aplikacji HTML5, a wzorzec „Promise” (i implementacja jQuery) to potężne narzędzia, dzięki którym programowanie asynchroniczne jest niezawodne i wydajne.