Asynch JS: El poder de $.Deferred

Uno de los aspectos más importantes de la compilación de aplicaciones HTML5 fluidas y responsivas es la sincronización entre todas las diferentes partes de la aplicación, como la recuperación de datos, el procesamiento, las animaciones y los elementos de la interfaz de usuario.

La principal diferencia con una computadora de escritorio o un entorno nativo es que los navegadores no otorgan acceso al modelo de subprocesos y proporcionan un único subproceso para todo lo que accede a la interfaz de usuario (es decir, el DOM). Esto significa que toda la lógica de la aplicación que accede y modifica los elementos de la interfaz de usuario está siempre en el mismo subproceso, por lo que la importancia de mantener todas las unidades de trabajo de la aplicación lo más pequeñas y eficientes posible, y de aprovechar las capacidades asíncronas que ofrece el navegador tanto como sea posible.

APIs asíncronas del navegador

Afortunadamente, los navegadores proporcionan varias APIs asíncronas, como las APIs de XHR (XMLHttpRequest o "AJAX") de uso general, IndexedDB, SQLite, HTML5 Web Workers y las APIs de ubicación geográfica de HTML5, por nombrar algunas. Incluso algunas acciones relacionadas con DOM se exponen de forma asíncrona, como la animación CSS3 a través de los eventos transactionEnd.

La forma en que los navegadores exponen la programación asíncrona a la lógica de la aplicación es a través de eventos o devoluciones de llamada.
En las APIs asíncronas basadas en eventos, los desarrolladores registran un controlador de eventos para un objeto determinado (p.ej., un elemento HTML o algún otro objeto DOM) y, luego, llaman a la acción. Por lo general, el navegador realizará la acción en un subproceso diferente y activará el evento en el subproceso principal cuando corresponda.

Por ejemplo, el código que usa la API de XHR, una API asíncrona basada en eventos, se vería así:

// 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();

El evento transactionEnd de CSS3 es otro ejemplo de una API asíncrona basada en eventos.

// 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') 

Otras APIs del navegador, como SQLite y HTML5 Geolocation, se basan en devoluciones de llamada, lo que significa que el desarrollador pasa una función como argumento que la implementación subyacente realizará la llamada con la resolución correspondiente.

Por ejemplo, para la ubicación geográfica HTML5, el código tiene el siguiente aspecto:

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

En este caso, solo se llama a un método y se pasa una función a la que se llamará con el resultado solicitado. De esta manera, el navegador puede implementar esta funcionalidad de forma síncrona o asíncrona y brindar una sola API al desarrollador, sin importar los detalles de la implementación.

Haz que las aplicaciones sean asíncronas

Más allá de las APIs asíncronas integradas del navegador, las aplicaciones bien diseñadas también deben exponer sus APIs de bajo nivel de forma asíncrona, en especial cuando realizan cualquier tipo de E/S o procesamiento intensivo de procesamiento. Por ejemplo, las APIs para obtener datos deben ser asíncronas y NO deberían verse de la siguiente manera:

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

Este diseño de API requiere que getData() esté bloqueado, lo que congelará la interfaz de usuario hasta que se recuperen los datos. Si los datos son locales en el contexto de JavaScript, esto podría no ser un problema. Sin embargo, si los datos se deben recuperar desde la red o incluso de forma local en un almacén de índices o SQLite, esto podría tener un impacto significativo en la experiencia del usuario.

El diseño correcto consiste en hacer de manera proactiva todas las APIs de aplicaciones que podrían tardar un tiempo en procesarse, de manera asíncrona desde el principio, ya que adaptar el código de la aplicación síncrono para que sea asíncrono puede ser una tarea abrumadora.

Por ejemplo, la API simple de getData() se convertiría en algo así:

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

Lo bueno de este enfoque es que esto obliga al código de la IU de la aplicación a centrarse de manera asíncrona desde el principio y permite que las APIs subyacentes decidan si deben ser asíncronas o no en una etapa posterior.

Ten en cuenta que no todas las APIs de la aplicación necesitan o deben ser asíncronas. La regla general es que cualquier API que realice cualquier tipo de E/S o procesamiento intensivo (todo lo que pueda tardar más de 15 ms) debe exponerse de forma asíncrona desde el inicio, incluso si la primera implementación es síncrona.

Manejo de fallas

Un obstáculo de la programación asíncrona es que la forma tradicional de tratar las fallas para controlar las fallas ya no funciona, ya que los errores suelen ocurrir en otro subproceso. En consecuencia, el destinatario debe tener una forma estructurada de notificar al emisor cuando algo sale mal durante el procesamiento.

En una API asíncrona basada en eventos, esto se logra con frecuencia mediante el código de la aplicación que consulta el evento o el objeto cuando recibe el evento. Para las APIs asíncronas basadas en devoluciones de llamada, la práctica recomendada es tener un segundo argumento que tome una función que se llamaría en caso de una falla con la información de error adecuada como argumento.

Nuestra llamada getData se vería de la siguiente manera:

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

Cómo combinarlo con $.Deferred

Una limitación del enfoque de devolución de llamada anterior es que puede ser muy engorroso escribir incluso una lógica de sincronización moderadamente avanzada.

Por ejemplo, si necesitas esperar a que se completen dos API asíncronas antes de realizar una tercera, la complejidad del código puede aumentar rápidamente.

// 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);
});

El proceso puede incluso volverse más complejo cuando la aplicación necesita realizar la misma llamada desde varias partes, ya que cada llamada deberá realizar estas llamadas de varios pasos o la aplicación deberá implementar su propio mecanismo de almacenamiento en caché.

Afortunadamente, hay un patrón relativamente antiguo llamado Promesas (similar a Future en Java) y una implementación moderna y sólida en jQuery llamada $.Deferred que proporciona una solución simple y potente para la programación asíncrona.

Para simplificarlo, el patrón Promises define que la API asíncrona muestra un objeto Promise, que se asemeja a la "promesa de que el resultado se resolverá con los datos correspondientes". Para obtener la resolución, el llamador obtiene el objeto Promise y llama a done(successFunc(data)), que le indicará al objeto Promise que llame a successFunc cuando se resuelvan los "data".

Por lo tanto, el ejemplo anterior de la llamada getData se ve así:

// 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);
});

Aquí, obtenemos el objeto dataPromise primero y, luego, llamamos al método .done para registrar una función que queremos que se llame cuando se resuelvan los datos. También podemos llamar al método .fail para manejar la falla eventual. Ten en cuenta que podemos tener tantas llamadas a .done o .fail como sea necesario, ya que la implementación de la promesa subyacente (código de jQuery) se encargará del registro y las devoluciones de llamada.

Con este patrón, es relativamente fácil implementar un código de sincronización más avanzado, y jQuery ya proporciona el más común, como $.when.

Por ejemplo, la devolución de llamada anidada getData/getLocation anterior se convertiría en algo similar a lo siguiente:

// 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);
});  

Y lo bueno de todo es que jQuery.Deferred permite que los desarrolladores implementen la función asíncrona de manera muy sencilla. Por ejemplo, getData podría tener el siguiente aspecto:

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();
}

Por lo tanto, cuando se llama a getData(), primero crea un nuevo objeto jQuery.Deferred (1) y, luego, muestra su promesa (2) para que el llamador pueda registrar sus funciones completadas y fallidas. Luego, cuando se muestra la llamada XHR, se resuelve la diferida (3.1) o se rechaza (3.2). Si haces lo deferred.resolve, se activarán todas las funciones ready(...) y otras funciones de promesa (p. ej., y la canalización) y la llamada a deferred.reject llamará a todas las funciones fail().

Casos de uso

Estos son algunos casos de uso útiles en los que Deferred puede ser muy útil:

Acceso a los datos: Exponer las APIs de acceso a los datos como $.Deferred suele ser el diseño correcto. Esto es evidente para los datos remotos, ya que las llamadas remotas síncronas arruinarían por completo la experiencia del usuario, pero también es cierto para los datos locales, ya que a menudo las APIs de nivel inferior (p.ej., SQLite e IndexedDB) son asíncronos en sí. Los recursos $.when y .pipe de la API diferida son muy potentes para sincronizar y encadenar subconsultas asíncronas.

Animaciones de la IU: Organizar una o más animaciones con eventos transactionEnd puede ser bastante tedioso, en especial cuando las animaciones son una combinación de animación CSS3 y JavaScript (como suele ser el caso). Unir las funciones de animación como Deferred puede reducir de manera significativa la complejidad del código y mejorar la flexibilidad. Incluso una función de wrapper genérica simple como cssAnimation(className) que mostrará el objeto Promise que se resuelve en transactionEnd podría ser de gran ayuda.

Pantalla de componentes de la IU: Esta opción es un poco más avanzada, pero los frameworks de componentes HTML avanzados también deberían usar Deferred. Sin entrar demasiado en detalles (esto será tema de otra publicación), cuando una aplicación necesita mostrar diferentes partes de la interfaz de usuario, tener el ciclo de vida de esos componentes encapsulados en Deferred permite un mayor control del tiempo.

Cualquier API asíncrona del navegador: Para normalizar los datos, suele ser una buena idea unir las llamadas a la API del navegador como Deferred. Esto requiere literalmente entre 4 y 5 líneas de código cada una, pero simplificará notablemente cualquier código de la aplicación. Como se muestra en el seudocódigo getData/getLocation anterior, esto permite que el código de las aplicaciones tenga un modelo asíncrono en todos los tipos de API (navegadores, detalles específicos de la aplicación y compuesto).

Almacenamiento en caché: Este es un beneficio secundario, pero puede ser muy útil en algunas ocasiones. Debido a que las APIs de Promise (p.ej., .done(...) y .fail(...)) antes o después de que se realice la llamada asíncrona, el objeto Diferred se puede usar como controlador de almacenamiento en caché para una llamada asíncrona. Por ejemplo, un CacheManager podría realizar un seguimiento de Deferred para determinadas solicitudes y mostrar la promesa del Deferred coincidente si no se invalidó. Lo bueno es que el llamador no necesita saber si la llamada ya se resolvió o está en proceso de resolución, ya que se llamará a la función de devolución de llamada de la misma manera.

Conclusión

Si bien el concepto $.Deferred es simple, puede llevar tiempo comprenderlo. Sin embargo, dada la naturaleza del entorno del navegador, dominar la programación asíncrona en JavaScript es imprescindible para cualquier desarrollador de aplicaciones HTML5 serio, y el patrón Promise (y la implementación de jQuery) son herramientas excelentes para hacer que la programación asíncrona sea confiable y potente.