Async JS - o poder de $.Deferred

Um dos aspectos mais importantes da 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 de geolocalização 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.

A maneira como os navegadores expõem a programação assíncrona à lógica do aplicativo é por eventos ou callbacks.
Em APIs assíncronas baseadas em eventos, os desenvolvedores registram um manipulador de eventos para um determinado objeto (por exemplo, elemento HTML ou outros objetos DOM) e 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, tem esta aparência:

// 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 fica 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, chamamos um método e transmitimos 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 de implementação.

Como preparar aplicativos para o modo assíncrono

Além das APIs assíncronas integradas do navegador, os aplicativos bem projetados precisam exibir as APIs de baixo nível de forma 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() seja bloqueado, o que congela 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() simplista 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 assíncrono desde o início e permite que as APIs subjacentes decidam se elas precisam ser assíncronas ou não em uma fase 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 forma assíncrona desde o início, mesmo que a primeira implementação seja síncrona.

Como lidar com falhas

Uma das desvantagens 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 chamado 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 fazer isso com $.Deferred

Uma limitação da abordagem de callback acima é que pode se tornar muito incômoda escrever 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 em várias partes dele, 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.

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 necessárias, 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 se tornaria 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 o melhor de tudo é 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 a deferred.resolve vai acionar todas as funções done(…) e outras funções de promessa (por exemplo, then e pipe), e chamar deferred.reject vai 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 certo. Isso é óbvio para dados remotos, já que chamadas remotas síncronas arruinariam completamente a experiência do usuário, mas também é verdade para dados locais, já que as APIs de nível inferior (por exemplo, SQLite e IndexedDB) são assíncronos. As funções $.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 costuma ser 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 tema de outra postagem. Quando um aplicativo precisa mostrar 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 4 a 5 linhas de código cada, mas simplifica muito 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.