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 un entorno nativo o de escritorio es que los navegadores no otorgan acceso al modelo de subprocesos y proporcionan un solo 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 a los elementos de la interfaz de usuario y la modifica siempre está en el mismo subproceso, por lo que es importante mantener todas las unidades de trabajo de la aplicación lo más pequeñas y eficientes posible, y aprovechar las capacidades asíncronas que el navegador ofrece al máximo.
APIs asíncronas del navegador
Afortunadamente, los navegadores proporcionan varias APIs asíncronas, como las APIs de XHR (XMLHttpRequest o "AJAX") de uso general, además de IndexedDB, SQLite, Web Workers HTML5 y las APIs de GeoLocation de HTML5, por nombrar algunas. Incluso algunas acciones relacionadas con el DOM se exponen de forma asíncrona, como las animaciones CSS3 a través de los eventos crossEnd.
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 de la siguiente manera:
// 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 crossEnd 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 de navegador, como SQLite y HTML5 Geolocation, se basan en devoluciones de llamada, lo que significa que el desarrollador pasa una función como argumento al que la implementación subyacente llamará 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 llamamos a un método y pasamos una función a la que se le llamará con el resultado solicitado. Esto permite que el navegador implemente esta funcionalidad de forma síncrona o asíncrona y le proporcione una sola API al desarrollador, independientemente de los detalles de la implementación.
Preparación de las aplicaciones para el procesamiento asíncrono
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 de procesamiento intensivo. Por ejemplo, las APIs para obtener datos deben ser asíncronas y NO deben 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 inmovilizará la interfaz del usuario hasta que se recuperen los datos. Si los datos son locales en el contexto de JavaScript, es posible que esto no sea un problema. Sin embargo, si los datos deben recuperarse de 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 es hacer de manera proactiva que todas las APIs de aplicaciones que podrían tardar un tiempo en procesarse sean asíncronas desde el principio, ya que la actualización del código síncrono de la aplicación para que sea asíncrono puede ser una tarea abrumadora.
Por ejemplo, la API de getData() simple se convertiría en algo como:
getData(function(data){
alert("We got data: " + data);
});
Lo bueno de este enfoque es que obliga al código de la IU de la aplicación a ser centrado en la asincronía 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 (cualquier elemento que pueda tardar más de 15 ms) debe exponerse de forma asíncrona desde el principio, incluso si la primera implementación es síncrona.
Cómo manejar fallas
Una desventaja de la programación asíncrona es que la forma tradicional de manejar fallas con try/catch ya no funciona, ya que los errores suelen ocurrir en otro subproceso. En consecuencia, el llamado debe tener una forma estructurada de notificar al llamador cuando algo sale mal durante el procesamiento.
En una API asíncrona basada en eventos, esto suele lograrse cuando el código de la aplicación consulta el evento o el objeto cuando lo recibe. En el caso de las APIs asíncronas que se basan en devoluciones de llamada, se recomienda tener un segundo argumento que tome una función a la que se llamaría en caso de que se produzca una falla con la información de error adecuada como argumento.
Nuestra llamada a getData se vería así:
// getData(successFunc,failFunc);
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});
Une todo 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);
});
Las cosas pueden complicarse aún más cuando la aplicación necesita realizar la misma llamada desde varias partes de la aplicación, ya que cada llamada deberá realizar estas llamadas de varios pasos, o la aplicación deberá implementar su propio mecanismo de almacenamiento en caché.
Por suerte, existe un patrón relativamente antiguo, llamado promesas (algo similar a Future en Java) y una implementación sólida y moderna en el núcleo de jQuery llamada $.Deferred que proporciona una solución simple y potente para la programación asíncrona.
Para simplificar el proceso, el patrón de Promesas define que la API asíncrona muestra un objeto de promesa, que es una especie de "promesa que el resultado se resolverá con los datos correspondientes". Para obtener la resolución, el llamador obtiene el objeto de la promesa y llama a un done(successFunc(data)), que le indicará al objeto la promesa que llame a este successFunc cuando se resuelvan los “datos”.
Por lo tanto, el ejemplo de llamada getData anterior 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í, primero obtenemos el objeto dataPromise y, luego, llamamos al método .done para registrar una función a la que queremos que se le llame cuando se resuelvan los datos. También podemos llamar al método .fail para controlar la falla eventual. Ten en cuenta que podemos tener tantas llamadas a .done o .fail como necesitemos, ya que la implementación subyacente de Promise (código de jQuery) controlará el 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 como 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 mejor de todo es que jQuery.Deferred facilita mucho a los desarrolladores la implementación de la función asíncrona. Por ejemplo, getData podría verse de la siguiente manera:
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 se crea un nuevo objeto jQuery.Deferred (1) y, luego, se muestra su promesa (2) para que el llamador pueda registrar sus funciones de éxito y error. Luego, cuando se muestra la llamada XHR, resuelve el aplazamiento (3.1) o lo rechaza (3.2). Si haces deferred.resolve, se activarán todas las funciones de done(…) y otras funciones de promesa (p. ej., then y pipe), y si llamas a deferred.reject, se llamará a todas las funciones de fail().
Casos de uso
Estos son algunos casos de uso 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 obvio 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 y IndexedDB) son asíncronos. $.when y .pipe de la API de Deferred son muy potentes para sincronizar y encadenar subconsultas asíncronas.
Animaciones de la IU: La orquestación de una o más animaciones con eventos transitionEnd puede ser bastante tediosa, en especial cuando las animaciones son una combinación de animaciones de CSS3 y JavaScript (como suele ser el caso). Unir las funciones de animación como diferidas puede reducir significativamente la complejidad del código y mejorar la flexibilidad. Incluso una función de wrapper genérica simple como cssAnimation(className) que devuelva el objeto Promise que se resuelve en transitionEnd podría ser de gran ayuda.
Visualización de componentes de la IU: Es un poco más avanzado, pero los frameworks de componentes HTML avanzados también deben usar la opción diferida. Sin entrar en demasiados detalles (este será el 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 de los tiempos.
Cualquier API asíncrona del navegador: para fines de normalización, a menudo es una buena idea unir las llamadas a la API del navegador como Deferred. Esto requiere literalmente de 4 a 5 líneas de código cada una, pero simplificará en gran medida cualquier código de aplicación. Como se muestra en el pseudocódigo de getData/getLocation anterior, esto permite que el código de la aplicación tenga un modelo asíncrono en todos los tipos de API (navegadores, especificaciones de la aplicación y compuestos).
Almacenamiento en caché: Es un beneficio adicional, 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 diferido se puede usar como un controlador de almacenamiento en caché para una llamada asíncrona. Por ejemplo, un CacheManager solo podría realizar un seguimiento de Deferred para determinadas solicitudes y mostrar la promesa de la Deferred coincidente si no se invalidó. La ventaja es que el llamador no tiene que saber si la llamada ya se resolvió o está en proceso de resolución, ya que se llamará a su función de devolución de llamada de la misma manera.
Conclusión
Si bien el concepto de $.Deferred es simple, puede llevar tiempo dominarlo. 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 profesional, y el patrón de promesa (y la implementación de jQuery) es una excelente herramienta para hacer que la programación asíncrona sea confiable y potente.