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, która odczytuje i zmodyfikuje elementy interfejsu użytkownika, jest zawsze w tym samym wątku, dlatego ważne jest, aby wszystkie jednostki robocze aplikacji były jak najmniejsze i jak najbardziej wydajne oraz aby w jak największym stopniu korzystać z funkcji asynchronicznych oferowanych przez przeglądarkę.
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 przez przeglądarki logice aplikacji 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 wątku głównym.
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 parametrem rozdzielczości.
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 przeglądarki dobrze zaprojektowane aplikacje powinny też udostępniać swoje interfejsy API na niższym poziomie asynchronicznie, zwłaszcza gdy wykonują jakiekolwiek operacje wejścia/wyjścia lub intensywne przetwarzanie. 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);
Ta konstrukcja interfejsu API wymaga, aby funkcja getData() była blokująca, co spowoduje zamrożenie interfejsu użytkownika do czasu pobrania danych. Jeśli dane są lokalne w kontekście JavaScriptu, 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ć znaczący 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 kod interfejsu użytkownika aplikacji od początku jest asynchroniczny, a interfejsy API mogą na późniejszym etapie zdecydować, czy mają być asynchroniczne.
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 przypadku asynchronicznego interfejsu API opartego na zdarzeniach jest to często realizowane przez kod aplikacji, który wysyła zapytanie o zdarzenie lub obiekt po jego otrzymaniu. W przypadku interfejsów API asynchronicznych opartych na wywołaniu zwrotnym dobrą praktyką jest podanie drugiego argumentu, który przyjmuje funkcję, która zostanie wywołana w przypadku błędu z odpowiednimi informacjami o błędzie jako argument.
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 np. przed wywołaniem 3 interfejsów API musisz poczekać na zakończenie 2 wywołań asynchronicznych, 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 o nazwie obietnice (coś podobnego do Future w języku Java) oraz solidna i nowocześniejsza implementacja w jądrze jQuery o nazwie $.Deferred, która zapewnia proste i wydajne rozwiązanie do 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 przypadku najpierw pobieramy obiekt dataPromise, a potem wywołujemy metodę .done, aby zarejestrować funkcję, którą chcemy wywołać, gdy dane zostaną rozwiązane. 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 wdrażać bardziej zaawansowany kod synchronizacji, a jQuery udostępnia już najpopularniejsze funkcje, takie jak $.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);
});
Najważniejsze jest to, że jQuery.Deferred ułatwia deweloperom implementowanie 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. Gdy wywołanie XHR zwróci wartość, rozwiązuje opóźnienie (3.1) lub odrzuca je (3.2). Wywołanie deferred.resolve spowoduje wywołanie wszystkich funkcji done(…) i innych funkcji obietnicy (np. then i pipe), a wywołanie deferred.reject spowoduje wywołanie wszystkich funkcji fail().
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 popsułyby wrażenia użytkownika.Dotyczy to jednak również danych lokalnych, ponieważ często interfejsy API niższego poziomu (np. SQLite i IndexedDB są asynchroniczne. Zapytania $.when i .pipe interfejsu Deferred API są bardzo przydatne do synchronizowania i łańcuchowania asynchronicznych zapytań podrzędnych.
Animacje interfejsu użytkownika: tworzenie co najmniej 1 animacji za pomocą zdarzeń transitionEnd może być dość żmudne, zwłaszcza gdy animacje są mieszanką animacji CSS3 i JavaScriptu (co często ma miejsce). Opakowanie funkcji animacji w postaci funkcji opóźnionej 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 wywołaniu transitionEnd, może być bardzo pomocna.
Wyświetlanie komponentu UI: jest to nieco bardziej zaawansowana technika, ale zaawansowane frameworki HTML Component powinny też używać opóźnienia. Nie wdając się w szczegóły (będzie to temat kolejnego wpisu), gdy aplikacja musi wyświetlać różne części interfejsu użytkownika, umieszczenie cyklu życia tych komponentów w opóźnionym wątku pozwala na lepszą kontrolę nad czasem.
Interfejsy API asynchroniczne przeglądarki: na potrzeby normalizacji często warto owinąć wywołania interfejsu API przeglądarki w funkcji opóźnione. Wystarczy 4–5 wierszy kodu, a znacznie uprości 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 działanie, ale w niektórych przypadkach może być bardzo przydatne. Ponieważ interfejsy API obiecujące (np. Ponieważ metody .done(…) i .fail(…) można wywołać przed wykonaniem wywołania asynchronicznego lub po nim, obiekt Deferred można używać jako element do buforowania wywołań asynchronicznych. 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.