Async JS - o poder de $.Deferred

Um dos aspectos mais importantes na criação de aplicativos HTML5 suaves e responsivos é a sincronização entre todas as diferentes partes do aplicativo, como busca, processamento, animações e elementos da interface do usuário.

A principal diferença entre um ambiente de área de trabalho ou um ambiente nativo é que os navegadores não dão acesso ao modelo de linha de execução e fornecem uma única linha de execução para tudo que acessa a interface do usuário (ou seja, o DOM). Isso significa que toda a lógica do app que acessa e modifica os elementos da interface do usuário está sempre na mesma linha de execução. Por isso, a importância de manter todas as unidades de trabalho do aplicativo as menores e eficientes possível e aproveitar todos os recursos assíncronos que o navegador oferece ao máximo.

APIs de navegador assíncronas

Felizmente, os navegadores fornecem várias APIs assíncronas, como as APIs XHR (XMLHttpRequest ou "AJAX") comumente usadas, bem como APIs IndexedDB, SQLite, Web workers HTML5 e APIs GeoLocation HTML5, para citar algumas. Até mesmo algumas ações relacionadas ao DOM são expostas de maneira assíncrona, como a animação CSS3 por meio dos eventos transitionEnd.

A forma como os navegadores expõem a programação assíncrona à lógica do aplicativo é por meio de eventos ou callbacks.
Nas 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 realiza a ação geralmente em uma linha de execução diferente e aciona 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 CSS3transitionEnd é outro exemplo de uma API assíncrona baseada em evento.

// 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 HTML5 Geolocation, são baseadas em callback, o que significa que o desenvolvedor transmite uma função como argumento que vai ser chamada de volta pela implementação subjacente com a resolução correspondente.

Por exemplo, para Geolocalização 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, basta chamar um método e passar 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 tornar aplicativos assíncronos

Além das APIs assíncronas integradas do navegador, aplicativos bem projetados também precisam expor as APIs de baixo nível de maneira assíncrona, especialmente quando fazem qualquer tipo de processamento intenso de E/S ou computação. Por exemplo, as APIs para receber dados precisam ser assíncronas e NÃO podem ter esta aparência:

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

Este projeto de API exige que getData() esteja bloqueando, o que congela a interface do usuário até que os dados sejam buscados. Se os dados forem locais no contexto do JavaScript, talvez isso não seja um problema. No entanto, se eles precisarem ser buscados na rede ou até mesmo localmente em um repositório SQLite ou de índice, isso poderá ter um impacto significativo na experiência do usuário.

O design certo é tornar proativamente todas as APIs do aplicativo que podem levar algum tempo para serem processadas de forma assíncrona desde o início, já que adaptar o código do aplicativo síncrono para ser 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 de base decidam se precisam ser assíncronos ou não em um estágio posterior.

Nem todas as APIs do aplicativo precisam ou devem ser assíncronas. A regra prática é que qualquer API que faça qualquer tipo de E/S ou processamento pesado (tudo que possa levar mais de 15 ms) seja exposta de forma assíncrona desde o início, mesmo que a primeira implementação seja síncrona.

Como lidar com falhas

Uma desvantagem da programação assíncrona é que o método tradicional try/catch de lidar com falhas não funciona mais, já que os erros geralmente ocorrem 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 use uma função que seria chamada em caso de falha com as informações de erro apropriadas 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);
});

Juntando tudo com $.Deferred

Uma limitação da abordagem de callback acima é que pode ser muito complicado escrever até mesmo uma lógica de sincronização moderadamente avançada.

Por exemplo, se você precisar esperar duas APIs assíncronas serem concluídas antes de executar uma terceira, a complexidade do código poderá 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 mais complexas quando o aplicativo precisa fazer a mesma chamada de várias partes do aplicativo, porque cada chamada terá que realizar essas chamadas de várias etapas ou o aplicativo terá que implementar o próprio mecanismo de armazenamento em cache.

Felizmente, há um padrão relativamente antigo chamado Promises (semelhante a Future em Java) e uma implementação robusta e moderna no núcleo do jQuery chamada $.Deferred, que fornece 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 Promise e chama done(successFunc(data)) que diz a ele para chamar 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, conseguimos o objeto dataPromise primeiro e, em seguida, chamamos o método .done para registrar uma função que queremos chamar de volta quando os dados forem resolvidos. Também podemos chamar o método .fail para lidar com a falha eventual. Podemos ter quantas chamadas .done ou .fail forem necessárias, uma vez que a implementação das promessas subjacente (código jQuery) 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á oferece o código mais comum, como $.when.

Por exemplo, o retorno de chamada 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 a beleza de tudo isso é que o jQuery.Deferred facilita a implementação da função assíncrona para os desenvolvedores. Por exemplo, getData poderia ser semelhante a este:

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 getData() é chamado, primeiro ele cria um novo objeto jQuery.Deferred (1) e, em seguida, retorna sua promessa (2) para que o autor da chamada possa registrar as funções concluídas e com falha. Em seguida, quando a chamada XHR retorna, ela resolve o adiado (3.1) ou o rejeita (3.2). Fazer deferred.resolve acionará todas as funçõesDone(...) e outras funções de promessa (por exemplo, then e pipe). Além disso, chamar deferred.reject chamará todas as funções fail().

Casos de uso

Veja alguns bons casos de uso em que o "adiado" pode ser muito útil:

Acesso a dados:a exposição de APIs de acesso a dados como $.Deferred geralmente é o design correto. Isso é óbvio para dados remotos, já que chamadas remotas síncronas prejudicariam completamente a experiência do usuário, mas também acontece com dados locais, já que as APIs de nível mais baixo (por exemplo, SQLite e IndexedDB) são assíncronas. O $.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 eventostransitionEnd pode ser bastante tedioso, especialmente quando as animações combinam animação CSS3 e JavaScript (como geralmente acontece). Unir as funções de animação como "Adiado" pode reduzir significativamente a complexidade do código e melhorar a flexibilidade. Mesmo uma função wrapper genérica simples, como cssAnimation(className) que vai retornar o objeto Promise que é resolvido em transitionEnd, pode ser de grande ajuda.

Exibição de componentes da interface: é um pouco mais avançado, mas frameworks de componentes HTML avançados também precisam usar os "Adiados". Sem entrar em muitos detalhes (isso será o assunto de outra postagem), quando um aplicativo precisa mostrar diferentes partes da interface do usuário, ter o ciclo de vida desses componentes encapsulado em "Adiado" permite maior controle do tempo.

Qualquer API assíncrona de navegador: para fins de normalização, geralmente é uma boa ideia agrupar as chamadas de API do navegador como "Adiada". Isso leva literalmente de 4 a 5 linhas de código cada, mas simplificará 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, especificações do aplicativo e composto).

Armazenamento em cache: isso é um benefício colateral, mas pode ser muito útil em algumas vezes. Como as APIs de promessa (por exemplo, .done(...) e .fail(...)) podem ser chamados antes ou depois da execução da chamada assíncrona, o objeto adiado pode ser usado como gerenciador de armazenamento em cache para uma chamada assíncrona. Por exemplo, um CacheManager pode simplesmente acompanhar o "Deferido" para determinadas solicitações e retornar a promessa do "adiado" correspondente se ele não tiver sido invalidado. O mais legal é que o autor da chamada não precisa saber se a chamada já foi resolvida ou está em processo de ser resolvida. A função de callback será chamada exatamente da mesma forma.

Conclusão

Embora o conceito $.Deferred seja simples, pode levar algum tempo para lidar com ele. No entanto, devido à natureza do ambiente do navegador, é essencial dominar a programação assíncrona em JavaScript para qualquer desenvolvedor de aplicativos HTML5 sério. Além disso, o padrão Promise (e a implementação do jQuery) são ferramentas incríveis para tornar a programação assíncrona confiável e eficiente.