Asynch JS - The power of $.Deferred

Jeremy Chone
Jeremy Chone

Uno degli aspetti più importanti della 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 è sempre nello stesso filo, quindi l'importanza di mantenere tutte le unità di lavoro dell'applicazione il più piccole ed efficienti possibile e sfruttare il più possibile le funzionalità asincrone offerte dal browser.

API browser asincrone

Fortunatamente, i browser forniscono una serie di API asincrone, come le API XHR (XMLHttpRequest o "AJAX") comunemente utilizzate, nonché IndexedDB, SQLite, HTML5 Web worker e le API HTML5 GeoLocation, solo 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. Di solito il browser eseguirà l'azione 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. Tuttavia, se i dati devono essere recuperati dalla rete o anche localmente in un archivio SQLite o Index, 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 dell'applicazione richiedono o devono essere asincrone. La regola empirica è che qualsiasi API che esegue qualsiasi tipo di I/O o elaborazione intensiva (qualsiasi operazione che possa 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 chiamante deve avere un modo strutturato per notificare al chiamante quando qualcosa va storto durante l'elaborazione.

In un'API asincrona basata su eventi, questo viene spesso realizzato 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 chiamata getData sarebbe 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 di cui 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 farne una 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 le cose, il pattern Promises definisce che l'API asincrona restituisce un oggetto Promise che è di tipo "Promise che il risultato verrà risolto con i dati corrispondenti". Per ottenere la risoluzione, il chiamante riceve l'oggetto Promise e chiama done(successFunc(data)), che dirà all'oggetto Promise di chiamare questo successFunc quando i "dati" sono stati 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 ritorna, 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 rovinano completamente l'esperienza utente, ma è vero anche per i dati locali, come 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à Ritardata. Senza entrare troppo nei dettagli (questo sarà l'oggetto di un altro post), quando un'applicazione deve visualizzare diverse parti dell'interfaccia utente, avere il ciclo di vita di questi componenti incapsulati in Differito consente un maggiore controllo delle tempistiche.

Qualsiasi API asincrona del browser: ai fini della normalizzazione, spesso conviene aggregare le chiamate API del browser come Differite. Sono necessarie solo 4-5 righe di codice ciascuna, ma semplificheranno notevolmente qualsiasi codice dell'applicazione. Come mostrato nel pseudo codice getData/getLocation riportato sopra, 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 collaterale, 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. L'aspetto positivo è che il chiamante non deve sapere se la chiamata è già stata risolta o se sta per essere risolta, la sua funzione di callback verrà chiamata esattamente allo 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.