Asynch JS – potęga $.Deferred

Jednym z najważniejszych aspektów tworzenia płynnie działających, elastycznych aplikacji HTML5 jest synchronizacja wszystkich elementów aplikacji, takich jak pobieranie danych, przetwarzanie, animacje i elementy interfejsu użytkownika.

Główna różnica w porównaniu z środowiskiem na komputerze lub w natywnym środowisku polega na tym, że przeglądarki nie udostępniają dostępu do modelu wątków i zapewniają jeden wątek dla wszystkich elementów korzystających z interfejsu użytkownika (np. DOM). Oznacza to, że cała logika aplikacji, uzyskująca dostęp do elementów interfejsu i je modyfikując, jest zawsze w tym samym wątku. Dlatego ważne jest, aby wszystkie jednostki robocze aplikacji były jak najmniejsze i najwydajniejsze, a także jak najlepiej wykorzystać wszelkie asynchroniczne funkcje przeglądarki.

Asynchroniczne interfejsy API przeglądarki

Na szczęście przeglądarki udostępniają wiele interfejsów API asynchronicznych, takich jak powszechnie używane interfejsy XHR (XMLHttpRequest lub „AJAX”), a także IndexedDB, SQLite, Web Workers HTML5 czy interfejsy API GeoLocation HTML5. Nawet niektóre działania związane z DOM są udostępniane asynchronicznie, np. animacja CSS3 za pomocą zdarzeń transitionEnd.

Programowanie asynchroniczne jest udostępniane logice aplikacji przez przeglądarki za pomocą zdarzeń lub wywołań zwrotnych.
W przypadku asynchronicznych interfejsów API opartych na zdarzeniach deweloperzy rejestrują w danym obiekcie (np. elemencie HTML lub innym obiekcie DOM) element obsługujący zdarzenia, a potem wywołują działanie. Przeglądarka wykona to działanie zwykle w innym wątku, a w odpowiednich przypadkach wywoła zdarzenie w głównym wątku.

Na przykład kod korzystający z interfejsu XHR API, który jest asynchronicznym interfejsem opartym na zdarzeniach, wygląda tak:

// 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 transitionEnd w CSS3 to kolejny przykład interfejsu API asynchronicznego 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ądarki, takie jak SQLite i HTML5 Geolocation, działają na zasadzie wywołania zwrotnego, co oznacza, że deweloper przekazuje jako argument funkcję, która zostanie wywołana przez implementację docelową z odpowiednim rozwiązaniem.

Na przykład w przypadku usługi Lokalizacja geograficzna 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 wywołujemy tylko metodę i przekazujemy funkcję, która zostanie wywołana z wynikiem żądanego działania. Pozwala to przeglądarce implementować tę funkcję synchronicznie lub asynchronicznie i udostępniać deweloperowi jeden interfejs API niezależnie od szczegółów implementacji.

Przygotowanie aplikacji do działania asynchronicznego

Oprócz wbudowanych asynchronicznych interfejsów API w przeglądarce aplikacje o dobrej architekturze powinny także przedstawiać swoje interfejsy API niskiego poziomu w sposób asynchroniczny, zwłaszcza w przypadku jakichkolwiek operacji wejścia-wyjścia lub przetwarzania intensywnie obliczeniowych. Na przykład interfejsy API 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);

Taka konstrukcja interfejsu API wymaga, by funkcja getData() była blokowana, co spowoduje zablokowanie interfejsu użytkownika do czasu pobrania danych. Jeśli dane są lokalne w kontekście JavaScript, może to nie stanowić problemu, ale jeśli dane muszą być pobierane z sieci lub nawet lokalnie z bazy SQLite lub indeksu, może to mieć ogromny wpływ na komfort użytkownika.

Odpowiednie rozwiązanie polega na tym, aby wszystkie interfejsy API aplikacji, które mogą wymagać więcej czasu na przetworzenie, były od początku asynchroniczne, ponieważ dostosowanie kodu aplikacji synchronicznej do asynchronicznego może być trudnym zadaniem.

Na przykład interfejs API getData() w uproszczonej formie wyglądałby tak:

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

Zaletą tego podejścia jest to, że od początku wymusza asynchroniczność kodu interfejsu aplikacji i pozwala podrzędnym interfejsom API decydowować, czy mają być asynchroniczne, czy nie.

Pamiętaj, że nie wszystkie interfejsy API aplikacji muszą być asynchroniczne. Zasada jest taka, że każdy interfejs API, który wykonuje jakiekolwiek operacje wejścia/wyjścia lub intensywne przetwarzanie (cokolwiek, co może zająć więcej niż 15 ms), powinien być od początku udostępniany asynchronicznie, nawet jeśli pierwsza implementacja jest synchroniczna.

Obsługa błędów

Jedną z wad programowania asynchronicznego jest to, że tradycyjny sposób obsługi błędów za pomocą instrukcji try/catch nie działa już tak dobrze, ponieważ błędy występują zwykle w innym wątku. W związku z tym wywoływany musi mieć uporządkowany sposób powiadamiania wywołującego, gdy coś pójdzie nie tak podczas przetwarzania.

W asynchronicznym interfejsie API opartym na zdarzeniach kod aplikacji wysyła zapytanie do zdarzenia lub obiektu w chwili otrzymania tego zdarzenia. W przypadku asynchronicznych interfejsów API opartych na wywołaniach zwrotnych najlepiej jest użyć drugiego argumentu, który przyjmuje funkcję, która zostałaby wywołana w przypadku awarii, z odpowiednimi informacjami o błędzie jako argumentem.

Nasz wywołanie getData będzie wyglądać tak:

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

Łączenie za pomocą funkcji $.Deferred

Jednym z ograniczeń opisanego powyżej podejścia do wywołania jest to, że może ono utrudniać pisanie nawet umiarkowanie zaawansowanej logiki synchronizacji.

Jeśli na przykład trzeba poczekać na zakończenie dwóch asynchronicznego interfejsu API przed wykonaniem trzeciego, złożoność kodu może szybko 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);
});

Sytuacja może się jeszcze bardziej skomplikować, gdy aplikacja musi wykonywać to samo wywołanie z różnych części aplikacji, ponieważ każde wywołanie będzie musiało wykonywać te wywołania wieloetapowe, lub aplikacja będzie musiała zaimplementować własny mechanizm buforowania.

Na szczęście istnieje stosunkowo stary wzorzec zwany obietnicami (podobny do przyszłości w Javie) oraz solidna i nowoczesna implementacja w rdzeniu jQuery o nazwie $.Deferred, która stanowi proste i zaawansowane rozwiązanie programowania asynchronicznego.

W uproszczeniu, zgodnie z wzorcem obietnic interfejs asynchroniczny zwraca obiekt Promise, który jest rodzajem „obietnicy, że wynik zostanie rozwiązany z odpowiednimi danymi”. Aby uzyskać rozwiązanie, wywołujący uzyskuje obiekt Promise i wywołuje metodę done(successFunc(data)), która każe obiektowi Promise wywołać tę metodę successFunc, gdy „data” zostanie rozwiązana.

Powyższy przykład wywołania getData staje się więc taki:

// 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 miejscu najpierw pobieramy obiekt dataPromise, a następnie wywołujemy metodę .done, aby zarejestrować funkcję, która ma być wywoływana po rozwiązaniu danych. Możemy też wywołać metodę .fail, aby obsłużyć ewentualną awarię. Pamiętaj, że możemy mieć dowolną liczbę wywołań .done lub .fail, ponieważ implementacja obietnicy (kod jQuery) będzie obsługiwać rejestrację i wywołania zwrotne.

Dzięki temu wzorcem można stosunkowo łatwo zaimplementować bardziej zaawansowany kod synchronizacji, a jQuery udostępnia już najpopularniejszą funkcję synchronizacji $.when.

Na przykład zagnieżdżony wywoływany przez siebie callback getData/getLocation wyglądałby 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);
});  

Urok całego entuzjazmu polega na tym, że jQuery.Deferred bardzo ułatwia programistom wdrożenie funkcji asynchronicznej. Funkcja getData może wyglądać na przykład 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();
}

Gdy wywołana zostanie funkcja getData(), najpierw tworzy nowy obiekt jQuery.Deferred (1), a potem zwraca obietnicę (2), aby wywołujący mógł zarejestrować funkcje done i fail. Następnie, gdy wywołanie XHR zwraca, zamyka odroczoną (3.1) lub odrzuca je (3.2). Wykonanie polecenia deferred.resolve spowoduje uruchomienie wszystkich funkcji „Done(...)” i innych funkcji obiecujących (np. następnie i pionowa kreska), a wywołanie funkcji deferred.reject spowoduje wywołanie wszystkich funkcji error().

Przykłady zastosowania

Oto kilka przydatnych zastosowań funkcji opóźnienia:

Dostęp do danych: często odpowiednim rozwiązaniem jest udostępnienie interfejsów API dostępu do danych w postaci funkcji $.Deferred. Jest to oczywiste w przypadku danych zdalnych, ponieważ synchroniczne wywołania zdalne całkowicie zniechęcają użytkownika, ale dotyczą też danych lokalnych, jak często interfejsów API niższego poziomu (np. SQLite i IndexedDB są asynchroniczne. Za pomocą funkcji $.when i .pipe interfejsu Deferred API możesz synchronizować i łańcuchowo łączyć zapytania podrzędne asynchroniczne.

Animacje UI: przygotowywanie jednej lub kilku animacji ze zdarzeniami conversionEnd może być dość pracochłonne, zwłaszcza gdy animacje to połączenie animacji CSS3 i JavaScriptu (co to często jest). Owijanie funkcji animacji w funkcje opóźnione może znacznie zmniejszyć złożoność kodu i zwiększyć elastyczność. Nawet prosta ogólna funkcja opakowująca, np. cssAnimation(className), która zwraca obiekt Promise, który jest rozwiązywany po zakończeniu przejścia, może być bardzo pomocna.

Wyświetlanie komponentu UI: jest to nieco bardziej zaawansowana technika, ale zaawansowane frameworki komponentów HTML powinny też używać opóźnienia. Nie wdając się zbytnio w szczegóły (te informacje będą omawiane w kolejnym poście), gdy aplikacja musi wyświetlać różne części interfejsu użytkownika, uwzględnienie cyklu życia tych komponentów w sekcji „Odroczone” zapewnia większą kontrolę nad czasem.

Dowolny asynchroniczny interfejs API przeglądarki: na potrzeby normalizacji często warto oznaczyć wywołania interfejsu API przeglądarki jako „Odroczone”. Zajmuje to dosłownie 4–5 wierszy kodu, ale znacznie upraszcza to kod aplikacji. Jak widać w powyższym pseudokodzie getData/getLocation, pozwala to na użycie w kodzie aplikacji jednego modelu asynchronicznego we wszystkich typach interfejsów API (przeglądarki, specyficzne aplikacje i złożone).

Pamięć podręczna: jest to pewnego rodzaju dodatkowe udogodnienie, ale w niektórych przypadkach może być bardzo przydatne. Ponieważ interfejsy API obiecujące (np. Funkcje .done(…) i .fail(…) mogą być wywoływane przed wykonaniem wywołania asynchronicznego lub po nim, a obiekt Deferred może być używany jako element do buforowania wywołania asynchronicznego. Na przykład menedżer pamięci podręcznej może śledzić opóźnienia w przypadku określonych żądań i zwracać obietnicę odpowiadającego opóźnienia, jeśli nie została ona unieważniona. Piękno tego rozwiązania polega na tym, że wywołujący nie musi wiedzieć, czy wywołanie zostało już zakończone czy jest w trakcie przetwarzania. Funkcja wywołania zwrotnego zostanie wywołana w taki sam sposób.

Podsumowanie

Chociaż koncepcja $.Deferred jest prosta, jej opanowanie może zająć trochę czasu. Jednak ze względu na charakter środowiska przeglądarki opanowanie asynchronicznego programowania w JavaScript jest niezbędne dla każdego poważnego programisty aplikacji HTML5. Wzorce obietnic (i ich implementacja w jQuery) to potężne narzędzia, które sprawiają, że programowanie asynchroniczne jest niezawodne i skuteczne.