Uno degli aspetti più importanti per la creazione di applicazioni HTML5 fluide e adattabili è la sincronizzazione tra tutte le diverse parti dell'applicazione, come il recupero dei dati, l'elaborazione, le animazioni e gli elementi dell'interfaccia utente.
La differenza principale con un ambiente desktop o nativo è che i browser non forniscono l'accesso al modello di threading e forniscono un singolo thread per tutto ciò che accede all'interfaccia utente (ovvero il DOM). Ciò significa che tutta la logica dell'applicazione che accede e modifica gli elementi dell'interfaccia utente si trova sempre nello stesso thread, da qui l'importanza di mantenere tutte le unità di lavoro dell'applicazione il più piccole ed efficienti possibile e di sfruttare al meglio le eventuali funzionalità asincrone offerte dal browser.
API asincrone del browser
Fortunatamente, i browser forniscono una serie di API asincrone, come le API XHR (XMLHttpRequest o "AJAX") di uso comune, nonché IndexedDB, SQLite, i web worker HTML5 e le API GeoLocation HTML5, per citarne alcuni. Anche alcune azioni relative al DOM sono esposte in modo asincrono, ad esempio l'animazione CSS3 tramite gli eventi transitionEnd.
I browser espongono la programmazione asincrona alla logica dell'applicazione tramite eventi o callback.
Nelle API asincrone basate su eventi, gli sviluppatori registrano un gestore di eventi per un determinato oggetto (ad es. elemento HTML o altri oggetti DOM) e poi chiamano l'azione. Il browser eseguirà
l'azione di solito in un thread diverso e attiverà l'evento nel thread principale, se opportuno.
Ad esempio, il codice che utilizza l'API XHR, un'API asincrona basata su eventi, sarà simile al seguente:
// 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();
L'evento transitionEnd CSS3 è un altro esempio di API asincrona basata su eventi.
// 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')
Altre API del browser, come SQLite e Geolocalizzazione HTML5, si basano sui callback, il che significa che lo sviluppatore passa come argomento una funzione che verrà richiamata dall'implementazione di base con la risoluzione corrispondente.
Ad esempio, per la geolocalizzazione HTML5, il codice è il seguente:
// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){
alert('Lat: ' + position.coords.latitude + ' ' +
'Lon: ' + position.coords.longitude);
});
In questo caso, chiamiamo semplicemente un metodo e passiamo una funzione che verrà richiamata con il risultato richiesto. In questo modo, il browser può implementare questa funzionalità in modo sincrono o asincrono e fornire allo sviluppatore una singola API indipendentemente dai dettagli di implementazione.
Preparare le applicazioni per l'utilizzo asincrono
Oltre alle API asincrone integrate del browser, le applicazioni ben progettate devono anche esporre le proprie API di basso livello in modo asincrono, in particolare quando eseguono qualsiasi tipo di I/O o elaborazione ad alta intensità di calcolo. Ad esempio, le API per recuperare i dati devono essere asincrone e NON avere il seguente aspetto:
// WRONG: this will make the UI freeze when getting the data
var data = getData();
alert("We got data: " + data);
Questo design dell'API richiede che getData() sia bloccante, il che bloccherà l'interfaccia dell'utente fino al recupero dei dati. Se i dati sono locali nel contesto JavaScript, questo potrebbe non essere un problema, ma se devono essere recuperati dalla rete o anche localmente in un database SQLite o in un indice, questo potrebbe avere un impatto significativo sull'esperienza utente.
La progettazione corretta consiste nel rendere in modo proattivo tutte le API dell'applicazione che potrebbero richiedere del tempo per essere elaborate asincrone fin dall'inizio, poiché il retrofitting del codice dell'applicazione sincrono per renderlo asincrono può essere un'impresa ardua.
Ad esempio, l'API getData() semplicistica diventerà qualcosa di simile a:
getData(function(data){
alert("We got data: " + data);
});
Il vantaggio di questo approccio è che forza il codice dell'interfaccia utente dell'applicazione a essere asincrono fin dall'inizio e consente alle API sottostanti di decidere se devono essere asincrone o meno in una fase successiva.
Tieni presente che non tutte le API di applicazione devono o devono essere asincrone. La regola di base è che qualsiasi API che esegue qualsiasi tipo di I/O o elaborazione pesante (qualsiasi operazione che può richiedere più di 15 ms) deve essere esposta in modo asincrono fin dall'inizio, anche se la prima implementazione è sincrona.
Gestione degli errori
Uno svantaggio della programmazione asincrona è che il metodo try/catch tradizionale per gestire i fallimenti non funziona più, poiché gli errori si verificano di solito in un altro thread. Di conseguenza, il chiamato deve avere un modo strutturato per informare il chiamante quando qualcosa va storto durante l'elaborazione.
In un'API asincrona basata su eventi, questo viene spesso ottenuto dal codice dell'applicazione che esegue query sull'evento o sull'oggetto al momento della ricezione. Per le API asincrone basate su callback, la best practice è avere un secondo argomento che accetti come argomento una funzione che verrà chiamata in caso di errore con le informazioni di errore appropriate.
La nostra chiamata getData sarà simile alla seguente:
// getData(successFunc,failFunc);
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});
Combinazione con $.Deferred
Un limite dell'approccio di callback riportato sopra è che può diventare molto complicato scrivere una logica di sincronizzazione anche moderatamente avanzata.
Ad esempio, se devi attendere il completamento di due API asincrone prima di eseguire la terza, la complessità del codice può aumentare rapidamente.
// 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);
});
Le cose possono diventare ancora più complesse quando l'applicazione deve effettuare la stessa chiamata da più parti dell'applicazione, poiché ogni chiamata dovrà eseguire queste chiamate con più passaggi oppure l'applicazione dovrà implementare il proprio meccanismo di memorizzazione nella cache.
Fortunatamente, esiste un pattern relativamente vecchio, chiamato Promise (simile a Future in Java) e un'implementazione solida e moderna nel core di jQuery chiamata $.Deferred che fornisce una soluzione semplice e potente alla programmazione asincrona.
Per semplificare, il pattern Promises definisce che l'API asincrona restituisce un oggetto Promise, che è una sorta di "promessa che il risultato verrà risolto con i dati corrispondenti". Per ottenere la risoluzione, l'autore della chiamata ottiene l'oggetto Promise e chiama done(successFunc(data)), che dice all'oggetto Promise di chiamare questa funzione di successo quando i "dati" vengono risolti.
Di conseguenza, l'esempio di chiamata getData riportato sopra diventa il seguente:
// 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);
});
Qui otteniamo prima l'oggetto dataPromise e poi chiamiamo il metodo .done per registrare una funzione che vogliamo venga richiamata quando i dati vengono risolti. Possiamo anche chiamare il metodo .fail per gestire l'eventuale errore. Tieni presente che possiamo avere tutte le chiamate .done o .fail di cui abbiamo bisogno, poiché l'implementazione di Promise sottostante (codice jQuery) gestirà la registrazione e i callback.
Con questo pattern, è relativamente facile implementare codice di sincronizzazione più avanzato e jQuery fornisce già quello più comune, ad esempio $.when.
Ad esempio, il callback getData/getLocation nidificato riportato sopra diventerà qualcosa di simile a:
// 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);
});
Il bello è che jQuery.Deferred consente agli sviluppatori di implementare molto facilmente la funzione asincrona. Ad esempio, getData potrebbe avere il seguente aspetto:
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();
}
Pertanto, quando viene chiamato getData(), viene prima creato un nuovo oggetto jQuery.Deferred (1) e poi viene restituita la relativa promessa (2) in modo che chi chiama possa registrare le funzioni done e fail. Quando la chiamata XHR restituisce, risolve l'operazione differita (3.1) o la rifiuta (3.2). L'esecuzione di deferred.resolve attiverà tutte le funzioni done(…) e altre funzioni di promessa (ad es. then e pipe), mentre la chiamata a deferred.reject attiverà tutte le funzioni fail().
Casi d'uso
Ecco alcuni casi d'uso in cui Deferred può essere molto utile:
Accesso ai dati:spesso è opportuno esporre le API di accesso ai dati come $.Deferred. Questo è ovvio per i dati remoti, poiché le chiamate remote sincrone rovinerebbero completamente l'esperienza utente, ma vale anche per i dati locali, poiché spesso le API di livello inferiore (ad es. SQLite e IndexedDB) sono essi stessi asincroni. $.when e .pipe dell'API Deferred sono estremamente efficaci per sincronizzare e concatenare sottoquery asincrone.
Animazioni UI: orchestrare una o più animazioni con eventi transitionEnd può essere piuttosto noioso, soprattutto quando le animazioni sono un mix di animazioni CSS3 e JavaScript (come spesso accade). Il wrapping delle funzioni di animazione come Deferred può ridurre notevolmente la complessità del codice e migliorare la flessibilità. Anche una semplice funzione wrapper generica come cssAnimation(className) che restituisce l'oggetto Promise risolto in transitionEnd potrebbe essere di grande aiuto.
Visualizzazione dei componenti dell'interfaccia utente: si tratta di un'opzione un po' più avanzata, ma i framework di componenti HTML avanzati dovrebbero utilizzare anche la funzionalità Deferred. Senza addentrarci troppo nei dettagli (questo sarà l'argomento di un altro post), quando un'applicazione deve visualizzare parti diverse dell'interfaccia utente, avere il ciclo di vita di questi componenti incapsulati in Deferred consente un maggiore controllo della tempistica.
Qualsiasi API asincrona del browser: a scopo di normalizzazione, spesso è buona idea avvolgere le chiamate API del browser come Deferred. Sono necessarie solo 4-5 righe di codice ciascuna, ma semplificheranno notevolmente qualsiasi codice dell'applicazione. Come mostrato sopra nel pseudo codice getData/getLocation, questo consente al codice delle applicazioni di avere un modello asincrono per tutti i tipi di API (browser, specifiche dell'applicazione e composite).
Memorizzazione nella cache:si tratta di un vantaggio secondario, ma può essere molto utile in alcune occasioni. Poiché le API Promise (ad es. .done(…) e .fail(…)) possono essere chiamati prima o dopo l'esecuzione della chiamata asincrona, l'oggetto Deferred può essere utilizzato come handle di memorizzazione nella cache per una chiamata asincrona. Ad esempio, un CacheManager potrebbe semplicemente tenere traccia dei posticipamenti per determinate richieste e restituire la promessa del posticipamento corrispondente se non è stato invalidato. Il bello è che chi chiama non deve sapere se la chiamata è già stata risolta o è in fase di risoluzione, la sua funzione di callback verrà chiamata esattamente nello stesso modo.
Conclusione
Sebbene il concetto di $.Deferred sia semplice, può essere necessario del tempo per acquisire una buona padronanza. Tuttavia, data la natura dell'ambiente del browser, padroneggiare la programmazione asincrona in JavaScript è un must per qualsiasi sviluppatore di applicazioni HTML5 serio e il pattern Promise (e l'implementazione jQuery) sono strumenti straordinari per rendere la programmazione asincrona affidabile e potente.