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 danno accesso al modello di threading e forniscono un unico thread per tutto ciò che accede all'interfaccia utente (ad esempio il DOM). Ciò significa che tutta la logica dell'applicazione che accede e modifica gli elementi dell'interfaccia utente è sempre nello stesso thread, quindi è importante 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 asincrone del browser

Fortunatamente, i browser forniscono una serie di API asincrone, come le API XHR (XMLHttpRequest o 'AJAX') comunemente utilizzate, nonché le API IndexedDB, SQLite, HTML5 Web worker e le API HTML5 GeoLocation, per citarne alcune. Anche alcune azioni relative al DOM vengono esposte in modo asincrono, ad esempio l'animazione CSS3 tramite gli eventi TransactionEnd.

Il modo in cui i browser espongono la programmazione asincrona alla logica dell'applicazione è tramite eventi o callback.
Nelle API asincrone basate sugli eventi, gli sviluppatori registrano un gestore di eventi per un determinato oggetto (ad es. un elemento HTML o altri oggetti DOM) e richiamano l'azione. Il browser eseguirà l'azione di solito in un thread diverso e attiverà l'evento nel thread principale quando appropriato.

Ad esempio, il codice che utilizza l'API XHR, un'API asincrona basata sugli eventi, avrebbe il seguente aspetto:

// 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 CSS3 TransactionEnd è 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 browser, come SQLite e HTML5 Geolocation, sono basate su callback, ovvero lo sviluppatore trasmette una funzione come argomento che viene richiamata dall'implementazione sottostante con la risoluzione corrispondente.

Ad esempio, per la geolocalizzazione HTML5, il codice sarà simile al 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 un metodo e passiamo una funzione che verrà richiamata con il risultato richiesto. Ciò consente al browser di implementare questa funzionalità in modo sincrono o asincrono e di fornire una singola API allo sviluppatore, indipendentemente dai dettagli di implementazione.

Rendere le applicazioni pronte per l'uso asincrono

Oltre alle API asincrone integrate nel browser, anche le applicazioni con una buona architettura dovrebbero esporre le API di basso livello in modo asincrono, soprattutto quando eseguono qualsiasi tipo di I/O o elaborazioni pesanti di calcolo. Ad esempio, le API per ottenere dati devono essere asincrone e NON dovrebbero avere il seguente aspetto:

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

Questa progettazione dell'API richiede il blocco di getData(), che bloccherà l'interfaccia utente finché i dati non vengono recuperati. 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 un indice, questo potrebbe avere un impatto significativo sull'esperienza utente.

Il design giusto consiste nel rendere asincroni tutte le API dell'applicazione che potrebbero richiedere del tempo per l'elaborazione in modo proattivo, poiché il retrofitting del codice dell'applicazione sincrono in modo che sia asincrono può essere un compito scoraggiante.

Ad esempio, la semplicistica API getData() diventerà simile al seguente:

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

L'aspetto positivo di questo approccio è che in questo modo il codice dell'interfaccia utente dell'applicazione è asincrone dall'inizio e consente alle API sottostanti di decidere se deve essere asincrono o meno in una fase successiva.

Tieni presente che non tutte le API dell'applicazione richiedono o devono essere asincrone. Come regola generale, qualsiasi API che esegue qualsiasi tipo di I/O o elaborazione pesante (qualsiasi attività 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 dei problemi della programmazione asincrona è che il metodo tradizionale di gestione degli errori, mediante prova/catch, non funziona più, poiché gli errori di solito si verificano in un altro thread. Di conseguenza, il chiamante deve disporre di un modo strutturato per avvisarlo in caso di problemi durante l'elaborazione.

In un'API asincrona basata sugli eventi, questo viene spesso eseguito dal codice dell'applicazione che esegue una query sull'evento o sull'oggetto alla ricezione dell'evento. Per le API asincrone basate su callback, la best practice consiste nell'avere un secondo argomento che prenda una funzione che venga richiamata in caso di errore con le informazioni appropriate sull'errore come argomento.

La chiamata getData avrà il seguente aspetto:

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

Organizzazione dei risultati 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 l'esecuzione di due API asincrone prima di crearne 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 persino diventare più complesse quando l'applicazione deve effettuare la stessa chiamata da più parti, dato che ogni chiamata deve eseguire queste chiamate in più passaggi oppure l'applicazione dovrà implementare il proprio meccanismo di memorizzazione nella cache.

Fortunatamente, esiste un pattern relativamente vecchio, chiamato Promises (un po' simile a Future in Java) e un'implementazione robusta 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 "Promise che il risultato sarà risolto con i dati corrispondenti". Per ottenere la risoluzione, il chiamante riceve l'oggetto Promise e chiama un done(successFunc(data)) che dirà all'oggetto Promise di chiamare successFunc quando i "dati" vengono risolti.

Quindi, l'esempio di chiamata getData riportato sopra sarà simile al 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 necessarie, poiché l'implementazione sottostante di Promise (codice jQuery) gestirà la registrazione e i callback.

Con questo pattern, è relativamente facile implementare un codice di sincronizzazione più avanzato e jQuery fornisce già quello più comune, ad esempio $.When.

Ad esempio, il callback getData/getLocation nidificato sopra diventerà simile al seguente:

// 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 semplifica l'implementazione della funzione asincrona per gli sviluppatori. 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();
}

Quindi, quando viene chiamato getData(), crea prima un nuovo oggetto jQuery.Deferred (1) e poi restituisce Promise (2) in modo che il chiamante possa registrare le funzioni di completamento e di errore. Quindi, quando la chiamata XHR ritorna, risolve la differita (3.1) o la rifiuta (3.2). L'esecuzione di deferred.resolve attiverà tutte le funzioni done(...) e le altre funzioni di promessa (ad es. then e pipe) e la chiamata di deferred.reject chiamerà tutte le funzioni fail().

Casi d'uso

Ecco alcuni casi d'uso molto utili in cui la funzione Differito può essere molto utile:

Accesso ai dati: l'esposizione delle API di accesso ai dati come $.Deferred è spesso il design giusto. Questo è ovvio per i dati remoti, poiché le chiamate remote sincrone comprometterebbero completamente l'esperienza utente, ma è vero anche per i dati locali, come spesso le API di livello inferiore (ad es. SQLite e IndexedDB) sono asincroni. $.When e .pipe dell'API Deferred sono estremamente potenti per sincronizzare e concatenare le sottoquery asincrone.

Animazioni UI: l'orchestrazione di una o più animazioni con gli eventi TransactionEnd può essere piuttosto noiosa, specialmente quando le animazioni sono una combinazione di animazioni CSS3 e JavaScript (come spesso avviene). L'aggregazione delle funzioni di animazione come differita può ridurre significativamente la complessità del codice e migliorare la flessibilità. Anche una semplice funzione wrapper generica come cssAnimation(className) che restituirà l'oggetto Promise che viene risolto su TransactionEnd potrebbe essere di grande aiuto.

Visualizzazione dei componenti dell'interfaccia utente. Questa opzione è un po' più avanzata, ma anche i framework dei componenti HTML avanzati dovrebbero utilizzare la funzionalità Differita. Senza scendere troppo nei dettagli (sarà oggetto di un altro post), quando un'applicazione deve visualizzare parti diverse dell'interfaccia utente, il ciclo di vita di questi componenti incapsulati in Deferred consente un maggiore controllo dei tempi.

Qualsiasi API asincrona del browser: ai fini della normalizzazione, spesso è una buona idea includere le chiamate API del browser come differite. Sono necessarie da 4 a 5 righe di codice ciascuna, ma semplificherà moltissimo il codice dell'applicazione. Come mostrato nello pseudocodice getData/getLocation precedente, questo consente al codice dell'applicazione di avere un solo modello asincrono per tutti i tipi di API (browser, specifiche delle applicazioni e composti).

Memorizzazione nella cache: questo è 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 di Deferred per determinate richieste e restituire la Promise of Deferred corrispondente se non è stato invalidato. Il bello è che il chiamante non deve sapere se la chiamata è già stata risolta o se sta per essere risolta, la sua funzione di callback verrà richiamata esattamente allo stesso modo.

Conclusione

Sebbene il concetto di $.Deferred sia semplice, può volerci del tempo per gestirlo correttamente. Tuttavia, data la natura dell'ambiente del browser, la padronanza della programmazione asincrone in JavaScript è un must per qualsiasi sviluppatore di applicazioni HTML5 serio. Inoltre, il pattern Promise (e l'implementazione di jQuery) sono strumenti straordinari per rendere la programmazione asincrona affidabile e potente.