Um dos aspectos mais importantes na criação de aplicativos HTML5 suaves e responsivos é a sincronização entre todas as partes diferentes do aplicativo, como busca de dados, processamento, animações e elementos da interface do usuário.
A principal diferença em relação a um ambiente de computador ou nativo é que os navegadores não dão acesso ao modelo de linhas de execução e fornecem uma única linha de execução para tudo o que acessa a interface do usuário (ou seja, o DOM). Isso significa que toda a lógica do aplicativo que acessa e modifica os elementos da interface do usuário está sempre na mesma linha de execução. Por isso, é importante manter todas as unidades de trabalho do aplicativo o mais pequenas e eficientes possível e aproveitar ao máximo os recursos assíncronos oferecidos pelo navegador.
APIs assíncronas do navegador
Felizmente, os navegadores oferecem várias APIs assíncronas, como as APIs XHR (XMLHttpRequest ou "AJAX") usadas com frequência, além de IndexedDB, SQLite, workers da Web HTML5 e APIs GeoLocation do HTML5, para citar algumas. Mesmo algumas ações relacionadas ao DOM são exibidas de forma assíncrona, como a animação CSS3 pelos eventos transitionEnd.
Os navegadores expõem a programação assíncrona à lógica do aplicativo por meio de eventos ou callbacks.
Em APIs assíncronas baseadas em eventos, os desenvolvedores registram um manipulador de eventos para um determinado objeto
(por exemplo, um elemento HTML ou outros objetos DOM) e depois chamam a ação. O navegador vai realizar
a ação geralmente em uma linha de execução diferente e acionar o evento na linha de execução principal quando apropriado.
Por exemplo, o código que usa a API XHR, uma API assíncrona baseada em eventos, ficaria assim:
// 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();
O evento transitionEnd do CSS3 é outro exemplo de uma API assíncrona baseada em 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')
Outras APIs do navegador, como SQLite e Geolocalização HTML5, são baseadas em callback, ou seja, o desenvolvedor transmite uma função como argumento que será chamado de volta pela implementação com a resolução correspondente.
Por exemplo, para a geolocalização do HTML5, o código é assim:
// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){
alert('Lat: ' + position.coords.latitude + ' ' +
'Lon: ' + position.coords.longitude);
});
Nesse caso, apenas chamamos um método e passamos uma função que será chamada de volta com o resultado solicitado. Isso permite que o navegador implemente essa funcionalidade de forma síncrona ou assíncrona e forneça uma única API ao desenvolvedor, independentemente dos detalhes da implementação.
Como preparar aplicativos para o modo assíncrono
Além das APIs assíncronas integradas do navegador, aplicativos bem arquitetados precisam expor as APIs de baixo nível de maneira assíncrona, especialmente quando fazem qualquer tipo de E/S ou processamento computacional pesado. Por exemplo, as APIs para receber dados precisam ser assíncronas e NÃO podem ser parecidas com esta:
// WRONG: this will make the UI freeze when getting the data
var data = getData();
alert("We got data: " + data);
Esse design de API exige que getData() esteja bloqueando, o que congelará a interface do usuário até que os dados sejam buscados. Se os dados estiverem no contexto local do JavaScript, isso não será um problema. No entanto, se os dados precisarem ser buscados da rede ou até mesmo localmente em uma loja de índice ou SQLite, isso poderá ter um impacto significativo na experiência do usuário.
O design correto é fazer proativamente todas as APIs do aplicativo que podem levar algum tempo para processar, assíncronas desde o início, já que a adaptação do código do aplicativo síncrono para assíncrono pode ser uma tarefa assustadora.
Por exemplo, a API getData() simplificada se tornaria algo como:
getData(function(data){
alert("We got data: " + data);
});
O bom dessa abordagem é que ela força o código da interface do aplicativo a ser centralizado em assíncrono desde o início e permite que as APIs subjacentes decidam se elas precisam ser assíncronas ou não em um estágio posterior.
Nem toda a API do aplicativo precisa ou deve ser assíncrona. A regra geral é que qualquer API que faça qualquer tipo de E/S ou processamento pesado (qualquer coisa que possa levar mais de 15 ms) precisa ser exposta de maneira assíncrona desde o início, mesmo que a primeira implementação seja síncrona.
Como lidar com falhas
Um problema da programação assíncrona é que a maneira tradicional de try/catch para processar falhas não funciona mais, já que os erros geralmente acontecem em outra linha de execução. Consequentemente, o recebedor da chamada precisa ter uma maneira estruturada de notificar o autor da chamada quando algo der errado durante o processamento.
Em uma API assíncrona baseada em eventos, isso geralmente é feito pelo código do aplicativo que consulta o evento ou objeto ao receber o evento. Para APIs assíncronas baseadas em callback, a prática recomendada é ter um segundo argumento que recebe uma função que seria chamada em caso de falha com as informações de erro adequadas como argumento.
Nossa chamada getData ficaria assim:
// getData(successFunc,failFunc);
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});
Como juntar tudo com $.Deferred
Uma limitação da abordagem de callback acima é que pode se tornar muito incômoda escrever até mesmo uma lógica de sincronização moderadamente avançada.
Por exemplo, se você precisar esperar que duas APIs assíncronas sejam concluídas antes de fazer uma terceira, a complexidade do código pode aumentar 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);
});
As coisas podem ficar ainda mais complexas quando o aplicativo precisa fazer a mesma chamada de várias partes do aplicativo, já que cada chamada precisa realizar essas chamadas de várias etapas ou o aplicativo precisa implementar o próprio mecanismo de armazenamento em cache.
Felizmente, há um padrão relativamente antigo, chamado Promises (semelhante ao Future em Java) e uma implementação robusta e moderna no núcleo do jQuery chamada $.Deferred, que oferece uma solução simples e poderosa para a programação assíncrona.
Para simplificar, o padrão de promessas define que a API assíncrona retorna um objeto de promessa, que é uma espécie de "promessa de que o resultado será resolvido com os dados correspondentes". Para conseguir a resolução, o autor da chamada recebe o objeto de promessa e chama um done(successFunc(data)), que dirá ao objeto de promessa para chamar essa successFunc quando os "dados" forem resolvidos.
Assim, o exemplo de chamada getData acima fica assim:
// 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);
});
Aqui, primeiro recebemos o objeto dataPromise e, em seguida, chamamos o método .done para registrar uma função que queremos que seja chamada quando os dados forem resolvidos. Também podemos chamar o método .fail para processar a falha eventual. Podemos ter quantas chamadas .done ou .fail quanto precisarmos, já que a implementação de promessas (código jQuery) vai processar o registro e os callbacks.
Com esse padrão, é relativamente fácil implementar um código de sincronização mais avançado, e o jQuery já fornece o mais comum, como $.when.
Por exemplo, o callback getData/getLocation aninhado acima seria algo como:
// 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);
});
E a beleza de tudo isso é que o jQuery.Deferred facilita muito a implementação da função assíncrona para os desenvolvedores. Por exemplo, a função getData pode ser parecida com esta:
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();
}
Portanto, quando a função getData() é chamada, ela primeiro cria um novo objeto jQuery.Deferred (1) e depois retorna a promessa (2) para que o autor da chamada possa registrar as funções de sucesso e falha. Em seguida, quando a chamada XHR retornar, ela vai resolver o adiado (3.1) ou rejeitá-lo (3.2). Fazer o adiado.resolve vai acionar todas as funções registradas (...) e outras funções de promessas (por exemplo, depois e pipe). A chamada de ferido.reject chamará todas as funções fail().
Casos de uso
Confira alguns casos de uso em que o Deferred pode ser muito útil:
Acesso a dados: expor APIs de acesso a dados como $.Deferred geralmente é o design correto. Isso é óbvio para dados remotos, porque chamadas remotas síncronas prejudicam totalmente a experiência do usuário, mas também é verdade para dados locais, já que as APIs de nível mais baixo (por exemplo, SQLite e IndexedDB) são assíncronos. $.when e .pipe da API Deferred são extremamente eficientes para sincronizar e encadear subconsultas assíncronas.
Animações da interface: orquestrar uma ou mais animações com eventos transitionEnd pode ser bastante tedioso, especialmente quando as animações são uma mistura de CSS3 e JavaScript (como geralmente é o caso). O agrupamento das funções de animação como adiadas pode reduzir significativamente a complexidade do código e melhorar a flexibilidade. Até mesmo uma função wrapper genérica simples, como cssAnimation(className), que retorna o objeto Promise que é resolvido em transitionEnd, pode ser muito útil.
Exibição de componentes da interface:isso é um pouco mais avançado, mas frameworks de componentes HTML avançados também precisam usar o Deferred. Sem entrar em muitos detalhes (este será o assunto de outra postagem), quando um aplicativo precisa exibir diferentes partes da interface do usuário, ter o ciclo de vida desses componentes encapsulados em Deferred permite um maior controle do tempo.
Qualquer API assíncrona do navegador: para fins de normalização, é recomendável envolver as chamadas de API do navegador como adiadas. Isso leva literalmente de quatro a cinco linhas de código, mas simplificará qualquer código do aplicativo. Como mostrado no pseudocódigo getData/getLocation acima, isso permite que o código do aplicativo tenha um modelo assíncrono em todos os tipos de API (navegadores, especificidades do aplicativo e compostos).
Armazenamento em cache: é um benefício extra, mas pode ser muito útil em algumas ocasiões. Porque as APIs de promessa (por exemplo, .done(…) e .fail(…)) podem ser chamados antes ou depois que a chamada assíncrona é realizada. O objeto Deferred pode ser usado como um identificador de armazenamento em cache para uma chamada assíncrona. Por exemplo, um CacheManager pode acompanhar o Deferred para solicitações específicas e retornar a promessa do Deferred correspondente se ele não tiver sido invalidado. A beleza é que o autor da chamada não precisa saber se a chamada já foi resolvida ou está em processo de resolução. A função de callback será chamada da mesma maneira.
Conclusão
Embora o conceito de $.Deferred seja simples, pode levar algum tempo para você se familiarizar com ele. No entanto, dada a natureza do ambiente do navegador, dominar a programação assíncrona em JavaScript é essencial para qualquer desenvolvedor de aplicativos HTML5 sério, e o padrão de promessa (e a implementação do jQuery) são ferramentas incríveis para tornar a programação assíncrona confiável e poderosa.