Como o JavaScript funciona: Event Loop e o surgimento da programação assíncrona + 5 maneiras de melhorar a codificação com async/await

Parte 4 - Explorando as entranhas do JavaScript: uma série reveladora sobre o motor, tempo de execução e pilha de chamadas por debaixo dos panos.

Como o JavaScript funciona: Event Loop e o surgimento da programação assíncrona + 5 maneiras de melhorar a codificação com async/await
Photo by Aleksandr Popov / Unsplash

Bem-vindo ao post nº 4 da série dedicada a explorar o JavaScript e seus componentes fundamentais. Nesta ocasião, vamos expandir nosso primeiro post, revisando as desvantagens de programar em um ambiente de thread único e como superá-las usando o Event Loop e async/await para criar interfaces de usuário JavaScript impressionantes. Como de costume, ao final do artigo, compartilharemos 5 dicas sobre como escrever um código mais limpo com async/await.

Você perdeu os três primeiros capítulos? Você pode encontrá-los aqui:

  1. Uma visão geral do motor, do tempo de execução e da pilha de chamadas.
  2. Dentro do motor V8 do Google + 5 dicas sobre como escrever código otimizado.
  3. Gerenciamento de memória + como lidar com 4 vazamentos de memória comuns.

Por que ter apenas uma única thread é uma limitação?


No primeiro post que lançamos, ponderamos sobre a pergunta: o que acontece quando você tem chamadas de função na Pilha de Chamadas que levam uma quantidade enorme de tempo para serem processadas?

Imagine, por exemplo, um algoritmo complexo de transformação de imagem sendo executado no navegador.

Enquanto a Pilha de Chamadas tem funções a serem executadas, o navegador não pode fazer mais nada - ele fica bloqueado. Isso significa que o navegador não pode renderizar, não pode executar nenhum outro código, ele está simplesmente travado. E aqui vem o problema - a interface do usuário do seu aplicativo não é mais eficiente e agradável.

Seu aplicativo fica travado.

Em alguns casos, isso pode não ser um problema tão crítico. Mas ei - aqui está um problema ainda maior. Quando o navegador começa a processar muitas tarefas na Pilha de Chamadas, ele pode parar de responder por um longo período de tempo. Nesse ponto, muitos navegadores tomariam uma ação, levantando um erro e perguntando se deveriam encerrar a página:

Isso é feio e acaba completamente com a experiência do usuário UX.

Os blocos de construção de um programa JavaScript

Você pode estar escrevendo seu aplicativo JavaScript em um único arquivo .js, mas seu programa é quase certamente composto por vários blocos, onde apenas um deles será executado agora e o restante será executado posteriormente. A unidade de bloco mais comum é a função.

O problema que a maioria dos desenvolvedores que estão começando com JavaScript parece ter é entender que o "posteriormente" não necessariamente acontece de forma estrita e imediata após o "agora". Em outras palavras, tarefas que não podem ser concluídas agora vão ser concluídas de forma assíncrona, o que significa que você não terá o comportamento de bloqueio mencionado acima, como talvez tenha inconscientemente esperado ou desejado.

Vamos dar uma olhada no seguinte exemplo:

// ajax(..) is some arbitrary Ajax function given by a library
var response = ajax('https://example.com/api');

console.log(response);
// `response` won't have the response

Você provavelmente está ciente de que as solicitações Ajax padrão não são concluídas de forma síncrona, o que significa que, no momento da execução do código, a função ajax(..) ainda não possui nenhum valor para retornar e ser atribuído a uma variável de resposta.

Uma maneira simples de "aguardar" o retorno de um resultado de uma função assíncrona é usar uma função chamada de callback:

ajax('https://example.com/api', function(response) {
    console.log(response); // `response` is now available
});

Apenas uma observação: você pode fazer solicitações Ajax síncronas. Nunca, nunca faça isso. Se você fizer uma solicitação Ajax síncrona, a interface do usuário do seu aplicativo JavaScript ficará bloqueada - o usuário não poderá clicar, digitar dados, navegar ou rolar. Isso impediria qualquer interação do usuário. É uma prática terrível.

É assim que parece, mas por favor, nunca faça isso - não arruíne a web:

// This is assuming that you're using jQuery
jQuery.ajax({
    url: 'https://api.example.com/endpoint',
    success: function(response) {
        // This is your callback.
    },
    async: false // And this is a terrible idea
});

Nós usamos uma solicitação Ajax apenas como exemplo. Você pode fazer com que qualquer trecho de código seja executado de forma assíncrona.

Isso pode ser feito com a função setTimeout(callback, milissegundos). O que a função setTimeout faz é configurar um evento (um atraso) para ocorrer mais tarde. Vamos dar uma olhada:

function first() {
    console.log('first');
}
function second() {
    console.log('second');
}
function third() {
    console.log('third');
}
first();
setTimeout(second, 1000); // Invoke `second` after 1000ms
third();

A saída no console será a seguinte:

first
third
second

O que é Event Loop?

Vamos começar com uma afirmação um tanto estranha - apesar de permitir código JavaScript assíncrono (como o setTimeout que acabamos de discutir), até o ES6, o próprio JavaScript nunca teve nenhuma noção direta de assincronia embutida nele. O motor JavaScript nunca fez mais do que executar um único trecho do seu programa em qualquer momento dado.

Para mais detalhes sobre como os motores JavaScript funcionam (especificamente o V8 do Google), confira um de nossos artigos anteriores sobre o tema.

Então, quem diz ao motor JS para executar os trechos do seu programa? Na realidade, o motor JS não funciona isoladamente - ele é executado dentro de um ambiente hospedeiro, que para a maioria dos desenvolvedores é o navegador web típico ou o Node.js. Atualmente, o JavaScript é incorporado em todos os tipos de dispositivos, desde robôs até lâmpadas. Cada dispositivo representa um tipo diferente de ambiente hospedeiro para o motor JS.

O denominador comum em todos esses ambientes é um mecanismo embutido chamado "event loop" (laço de eventos), que lida com a execução de vários trechos do seu programa ao longo do tempo, invocando o motor JS a cada vez.

Isso significa que o motor JS é apenas um ambiente de execução sob demanda para qualquer código JS arbitrário. É o ambiente circundante que agenda os eventos (as execuções de código JS).

Por exemplo, quando seu programa JavaScript faz uma solicitação Ajax para buscar alguns dados do servidor, você configura o código de "resposta" em uma função (o "callback"), e o motor JS diz ao ambiente hospedeiro:
"Ei, vou suspender a execução por enquanto, mas sempre que você terminar essa solicitação de rede e tiver alguns dados, por favor, chame de volta esta função."

O navegador então é configurado para aguardar a resposta da rede e, quando tiver algo para retornar, agendará a execução da função de retorno de chamada, inserindo-a no event loop.

Vamos dar uma olhada no diagrama abaixo:

Você pode ler mais sobre o Heap de Memória e a Pilha de Chamadas em nosso artigo anterior.

E o que são essas Web API's? Em essência, são threads (fluxos de execução) às quais você não tem acesso direto, você só pode fazer chamadas para elas. Elas são as partes do navegador em que a concorrência entra em ação. Se você é um desenvolvedor Node.js, essas são as API's em C++.

Então, o que é o Event Loop?

O Event Loop tem uma tarefa simples - monitorar a Pilha de Chamadas (Call Stack) e a Fila de Callbacks (Callback Queue). Se a Pilha de Chamadas estiver vazia, o Event Loop pegará o primeiro evento da fila e o colocará na Pilha de Chamadas, executando-o efetivamente.

Essa iteração é chamada de tick no Event Loop. Cada evento é simplesmente um callback de função.

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');

Vamos "executar" este código e ver o que acontece:

  1. O estado está claro. O console do navegador está vazio e a Pilha de Chamadas está vazia.

2. console.log('Hi') é adicionado à Pilha de Chamadas (Call Stack).

3. console.log('Hi') é executado.

4. console.log('Hi') é removido da Pilha de Chamadas (Call Stack).

5. setTimeout(function cb1() { ... }) é adicionado à Pilha de Chamadas (Call Stack).

6. setTimeout(function cb1() { ... }) é executado. O navegador cria um temporizador como parte das Web API's. Ele ficará responsável pela contagem regressiva para você.

7. O próprio setTimeout(function cb1() { ... }) é concluído e é removido da Pilha de Chamadas (Call Stack).

8. console.log('Bye') é adicionado à Pilha de Chamadas (Call Stack).

9. console.log('Bye') é executado.

10. console.log('Bye') é removido da Pilha de Chamadas (Call Stack).

11. Após pelo menos 5000 ms, o temporizador é concluído e ele coloca o callback cb1 na Fila de Callbacks (Callback Queue).

12. O Event Loop pega o callback cb1 da Fila de Callbacks (Callback Queue) e o coloca na Pilha de Chamadas (Call Stack).

13. cb1 é executado e adiciona console.log('cb1') à Pilha de Chamadas (Call Stack).

14. console.log('cb1') é executado.

15. console.log('cb1') é removido da Pilha de Chamadas (Call Stack).

16. cb1 é removido da Pilha de Chamadas (Call Stack).

Uma rápida recapitulação:

É interessante observar que o ES6 especifica como o event loop deve funcionar, o que significa que, tecnicamente, está dentro do escopo das responsabilidades do motor JS, que não desempenha apenas o papel de ambiente hospedeiro. Uma das principais razões para essa mudança é a introdução das Promises no ES6, pois estas exigem acesso a um controle direto e detalhado sobre o agendamento de operações na fila do event loop (vamos discuti-las em maior detalhe mais tarde).

Como o setTimeout funciona ?

É importante observar que setTimeout(…) não coloca automaticamente o seu callback na fila do event loop. Ele configura um temporizador. Quando o temporizador expira, o ambiente coloca o seu callback na fila do event loop, para que em alguma futura etapa (tick) ele seja pego e executado. Dê uma olhada neste código:

setTimeout(myCallback, 1000);

Isso não significa que myCallback será executado em 1.000 ms, mas sim que, em 1.000 ms, myCallback será adicionado à fila do event loop. A fila, no entanto, pode ter outros eventos que foram adicionados anteriormente - seu callback terá que esperar.

Existem muitos artigos e tutoriais sobre como começar com código assíncrono em JavaScript que sugerem fazer um setTimeout(callback, 0). Bem, agora você sabe o que o Event Loop faz e como o setTimeout funciona: chamar setTimeout com 0 como segundo argumento apenas adia o callback até que a Call Stack esteja vazia.

Dê uma olhada no seguinte código:

console.log('Hi');
setTimeout(function() {
    console.log('callback');
}, 0);
console.log('Bye');

Embora o tempo de espera seja definido para 0 ms, o resultado no console do navegador será o seguinte:

Hi
Bye
callback

O que são Jobs no ES6?

Um novo conceito chamado "Job Queue" foi introduzido no ES6. É uma camada acima da fila do Event Loop. Você provavelmente o encontrará ao lidar com o comportamento assíncrono das Promises (falaremos sobre elas também).

Vamos abordar o conceito agora apenas para que, quando discutirmos o comportamento assíncrono com Promises, mais adiante, você entenda como essas ações estão sendo agendadas e processadas.

Imagine assim: a Job Queue é uma fila que está ligada ao final de cada tick na fila do Event Loop. Certas ações assíncronas que podem ocorrer durante um tick do event loop não farão com que um novo evento completo seja adicionado à fila do event loop, mas sim adicionarão um item (também conhecido como Job) ao final da Job queue do tick atual.

Isso significa que você pode adicionar outra funcionalidade para ser executada mais tarde e pode ter certeza de que ela será executada imediatamente depois, antes de qualquer outra coisa.

Um Job também pode causar a adição de mais Jobs ao final da mesma fila. Em teoria, é possível que um "loop" de Job (um Job que continua adicionando outros Jobs, etc.) gire indefinidamente, esgotando assim os recursos necessários para prosseguir para o próximo tick do event loop. Conceitualmente, isso seria semelhante a expressar um loop longo ou infinito (como while (true) ..) em seu código.

Os Jobs são como o "truque" setTimeout(callback, 0), mas implementados de tal maneira que introduzem uma ordenação muito mais bem definida e garantida: mais tarde, mas assim que possível.

Callbacks

Como você já sabe, callbacks são de longe a forma mais comum de expressar e gerenciar a assincronicidade em programas JavaScript. De fato, o callback é o padrão assíncrono mais fundamental na linguagem JavaScript. Inúmeros programas em JS, até mesmo os mais sofisticados e complexos, foram escritos apenas com o uso de callbacks como base para a assincronicidade.

No entanto, callbacks não estão isentos de falhas. Muitos desenvolvedores estão buscando melhores padrões assíncronos. No entanto, é impossível usar efetivamente qualquer abstração se você não entende o que está acontecendo internamente.

No próximo capítulo, vamos explorar algumas dessas abstrações em detalhes para mostrar por que padrões assíncronos mais sofisticados (que serão discutidos em postagens subsequentes) são necessários e até mesmo recomendados.

Callbacks Aninhados

Veja o código a seguir:

listen('click', function (e){
    setTimeout(function(){
        ajax('https://api.example.com/endpoint', function (text){
            if (text == "hello") {
	        doSomething();
	    }
	    else if (text == "world") {
	        doSomethingElse();
            }
        });
    }, 500);
});

Temos uma cadeia de três funções aninhadas, cada uma representando uma etapa em uma série assíncrona.

Esse tipo de código é frequentemente chamado de "callback hell" (inferno de callbacks). No entanto, o "callback hell" tem na verdade quase nada a ver com o aninhamento/indentação. É um problema muito mais profundo do que isso.

Primeiro, estamos esperando pelo evento "click", depois estamos esperando pelo temporizador para disparar, em seguida, estamos esperando pela resposta da requisição Ajax, momento em que tudo isso pode se repetir novamente.

Num primeiro olhar, esse código pode parecer mapear sua assincronicidade de forma natural para etapas sequenciais como:

listen('click', function (e) {
	// ..
});

Em seguida, temos:


setTimeout(function(){
    // ..
}, 500);

Depois, mais adiante, temos:

ajax('https://api.example.com/endpoint', function (text){
    // ..
});

E, por fim:

if (text == "hello") {
    doSomething();
}
else if (text == "world") {
    doSomethingElse();
}

Então, essa maneira sequencial de expressar o código assíncrono parece muito mais natural, não é mesmo? Deve haver uma forma de fazer isso, certo?

Promises

Dê uma olhada no código a seguir:

var x = 1;
var y = 2;
console.log(x + y);

É tudo muito simples: ele soma os valores de x e y e os imprime no console. Mas e se o valor de x ou y estiver faltando e ainda precisar ser determinado? Digamos que precisamos recuperar os valores de x e y do servidor antes que eles possam ser usados na expressão. Vamos imaginar que temos uma função loadX e loadY que carregam respectivamente os valores de x e y do servidor. Em seguida, imagine que temos uma função sum que soma os valores de x e y assim que ambos são carregados.

Poderia ser assim (bem feio, não é?):

function sum(getX, getY, callback) {
    var x, y;
    getX(function(result) {
        x = result;
        if (y !== undefined) {
            callback(x + y);
        }
    });
    getY(function(result) {
        y = result;
        if (x !== undefined) {
            callback(x + y);
        }
    });
}
// A sync or async function that retrieves the value of `x`
function fetchX() {
    // ..
}


// A sync or async function that retrieves the value of `y`
function fetchY() {
    // ..
}
sum(fetchX, fetchY, function(result) {
    console.log(result);
});

Há algo muito importante aqui - nesse trecho, tratamos x e y como valores futuros e expressamos uma operação sum(...) que (do ponto de vista externo) não se importava se x ou y ou ambos estavam ou não disponíveis imediatamente.

Claro, essa abordagem bruta baseada em callbacks deixa muito a desejar. É apenas um pequeno primeiro passo em direção a compreender os benefícios de raciocinar sobre valores futuros sem se preocupar com o aspecto temporal de quando eles estarão disponíveis.

Valor da Promise

Vamos apenas dar uma breve olhada em como podemos expressar o exemplo de x + y com Promessas:

function sum(xPromise, yPromise) {
	// `Promise.all([ .. ])` takes an array of promises,
	// and returns a new promise that waits on them
	// all to finish
	return Promise.all([xPromise, yPromise])

	// when that promise is resolved, let's take the
	// received `X` and `Y` values and add them together.
	.then(function(values){
		// `values` is an array of the messages from the
		// previously resolved promises
		return values[0] + values[1];
	} );
}

// `fetchX()` and `fetchY()` return promises for
// their respective values, which may be ready
// *now* or *later*.
sum(fetchX(), fetchY())

// we get a promise back for the sum of those
// two numbers.
// now we chain-call `then(...)` to wait for the
// resolution of that returned promise.
.then(function(sum){
    console.log(sum);
});

Há duas camadas de Promessas neste trecho.

fetchX() e fetchY() são chamados diretamente, e os valores que eles retornam (promessas!) são passados para sum(...). Os valores subjacentes que essas promessas representam podem estar prontos agora ou mais tarde, mas cada promessa normaliza seu comportamento para ser o mesmo, independentemente disso. Raciocinamos sobre os valores de x e y de forma independente no tempo. Eles são valores futuros, ponto final.

A segunda camada é a promessa que sum(...) cria (por meio de Promise.all([ ... ])) e retorna, na qual aguardamos chamando then(...). Quando a operação sum(...) é concluída, nosso valor futuro de soma está pronto e podemos imprimi-lo. Escondemos a lógica de espera pelos valores futuros de x e y dentro de sum(...).

Observação: Dentro de sum(...), a chamada Promise.all([ ... ]) cria uma promessa (que está aguardando a resolução de promiseX e promiseY). A chamada encadeada para .then(...) cria outra promessa, na qual a linha return values[0] + values[1] é resolvida imediatamente (com o resultado da adição). Assim, a chamada then(...) que encadeamos ao final da chamada sum(...) — no final do trecho — na verdade está operando na segunda promessa retornada, em vez da primeira criada por Promise.all([ ... ]). Além disso, embora não estejamos encadeando ao final desse segundo then(...), ele também criou outra promessa, caso tivéssemos escolhido observá-la/usá-la. Essa questão de encadeamento de Promessas será explicada com muito mais detalhes posteriormente neste capítulo.

Com Promessas, a chamada then(...) pode, na verdade, receber duas funções: a primeira para o cumprimento (como mostrado anteriormente) e a segunda para a rejeição:

sum(fetchX(), fetchY())
.then(
    // fullfillment handler
    function(sum) {
        console.log( sum );
    },
    // rejection handler
    function(err) {
    	console.error( err ); // bummer!
    }
);

Se algo der errado ao obter o valor de x ou y, ou se ocorrer alguma falha durante a adição, a promessa retornada por sum(...) será rejeitada, e o segundo manipulador de erro (passado para then(...)) receberá o valor de rejeição da promessa.

Porque as Promessas encapsulam o estado dependente do tempo - esperando pelo cumprimento ou rejeição do valor subjacente - do lado de fora, a Própria Promessa é independente do tempo e, portanto, as Promessas podem ser compostas (combinadas) de maneiras previsíveis, independentemente do tempo ou resultado subjacente.

Além disso, uma vez que uma Promessa é resolvida, ela permanece assim para sempre - torna-se um valor imutável a partir desse ponto - e pode ser observada quantas vezes forem necessárias.

É realmente útil que você possa encadear Promessas:

function delay(time) {
    return new Promise(function(resolve, reject){
        setTimeout(resolve, time);
    });
}

delay(1000)
.then(function(){
    console.log("after 1000ms");
    return delay(2000);
})
.then(function(){
    console.log("after another 2000ms");
})
.then(function(){
    console.log("step 4 (next Job)");
    return delay(5000);
})
// ...

Chamar delay(2000) cria uma promessa que será cumprida em 2000ms, e então retornamos essa promessa a partir do primeiro callback de cumprimento then(...), o que faz com que a promessa do segundo then(...) aguarde essa promessa de 2000ms.

Observação: Como uma Promessa é externamente imutável uma vez resolvida, agora é seguro passar esse valor para qualquer parte, sabendo que ele não pode ser modificado acidentalmente ou maliciosamente. Isso é especialmente verdadeiro em relação a várias partes observando o cumprimento de uma Promessa. Não é possível que uma parte afete a capacidade de outra parte de observar a resolução da Promessa. A imutabilidade pode parecer um tópico acadêmico, mas é na verdade um dos aspectos mais fundamentais e importantes do design de Promessas e não deve ser tratada de forma displicente.

Devo usar ou não uma Promise?

Um detalhe importante sobre Promessas é saber com certeza se algum valor é uma Promessa real ou não. Em outras palavras, é um valor que se comportará como uma Promessa?

Sabemos que Promessas são construídas pela sintaxe new Promise(...), e você pode pensar que p instanceof Promise seria uma verificação suficiente. Bem, não exatamente.

Principalmente porque você pode receber um valor de Promessa de outra janela do navegador (por exemplo, iframe), que teria sua própria Promessa, diferente daquela na janela ou frame atual, e essa verificação falharia em identificar a instância da Promessa.

Além disso, uma biblioteca ou framework pode optar por fornecer suas próprias Promessas e não utilizar a implementação nativa de Promessas do ES6 para fazê-lo. De fato, é muito provável que você esteja usando Promessas com bibliotecas em navegadores mais antigos que não possuem Promessas nativas.

Engolindo Exceções

Se em algum momento da criação de uma Promessa, ou na observação de sua resolução, ocorrer um erro de exceção JavaScript, como TypeError ou ReferenceError, essa exceção será capturada e forçará a Promessa em questão a ser rejeitada.

Por exemplo:

var p = new Promise(function(resolve, reject){
    foo.bar();	  // `foo` is not defined, so error!
    resolve(374); // never gets here :(
});

p.then(
    function fulfilled(){
        // never gets here :(
    },
    function rejected(err){
        // `err` will be a `TypeError` exception object
	// from the `foo.bar()` line.
    }
);

Mas o que acontece se uma Promessa for cumprida, mas ocorrer um erro de exceção JS durante a observação (em um callback registrado em then(...))? Mesmo que ela não seja perdida, você pode achar surpreendente a forma como são tratadas. Até que você investigue um pouco mais a fundo:

var p = new Promise( function(resolve,reject){
	resolve(374);
});

p.then(function fulfilled(message){
    foo.bar();
    console.log(message);   // never reached
},
    function rejected(err){
        // never reached
    }
);

Parece que a exceção de foo.bar() realmente foi engolida. No entanto, não foi. Houve algo mais profundo que deu errado, no entanto, não conseguimos ouvir isso. A chamada p.then(...) em si retorna outra promessa, e é essa promessa que será rejeitada com a exceção TypeError.

Tratamento de exceções não capturadas

Existem outras abordagens que muitos consideram melhores.

Uma sugestão comum é que as Promessas devem ter um método adicionado chamado done(...), que essencialmente marca a cadeia de Promessas como "concluída". O done(...) não cria e retorna uma Promessa, portanto, os callbacks passados para done(...) não são conectados para relatar problemas a uma Promessa encadeada que não existe.

No caso de condições de erro não capturadas, ele é tratado como você normalmente esperaria: qualquer exceção dentro de um manipulador de rejeição do done(...) seria lançada como um erro global não capturado (no console do desenvolvedor, basicamente):

var p = Promise.resolve(374);

p.then(function fulfilled(msg){
    // numbers don't have string functions,
    // so will throw an error
    console.log(msg.toLowerCase());
})
.done(null, function() {
    // If an exception is caused here, it will be thrown globally 
});

O que está acontecendo no ES8? Async/await

O JavaScript ES8 introduziu o async/await, que facilita o trabalho com Promessas. Vamos dar uma breve olhada nas possibilidades que o async/await oferece e como aproveitá-lo para escrever código assíncrono.

Como usar Async/await ?

Você define uma função assíncrona usando a declaração async. Essas funções retornam um objeto AsyncFunction. O objeto AsyncFunction representa a função assíncrona que executa o código contido dentro dela.

Quando uma função assíncrona é chamada, ela retorna uma Promessa (Promise). Se a função assíncrona retornar um valor que não seja uma Promessa, uma Promessa será criada automaticamente e será resolvida com o valor retornado pela função. Se a função assíncrona lançar uma exceção, a Promessa será rejeitada com o valor da exceção lançada.

Uma função assíncrona pode conter uma expressão await, que pausa a execução da função e aguarda a resolução da Promessa passada. Em seguida, a execução da função assíncrona é retomada e o valor resolvido é retornado.

Você pode pensar em uma Promessa em JavaScript como o equivalente ao Future do Java ou ao Task do C#.

O objetivo do async/await é simplificar o uso de Promessas.

Vamos dar uma olhada no exemplo a seguir:

// Just a standard JavaScript function
function getNumber1() {
    return Promise.resolve('374');
}
// This function does the same as getNumber1
async function getNumber2() {
    return 374;
}

Da mesma forma, funções que lançam exceções são equivalentes a funções que retornam Promessas que foram rejeitadas:

function f1() {
    return Promise.reject('Some error');
}
async function f2() {
    throw 'Some error';
}

A palavra-chave "await" só pode ser usada em funções assíncronas e permite esperar sincronamente por uma Promessa. Se usarmos Promessas fora de uma função assíncrona, ainda teremos que usar callbacks then:

async function loadData() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}
// Since, we're not in an `async function` anymore
// we have to use `then`.
loadData().then(() => console.log('Done'));

Você também pode definir funções assíncronas usando uma "expressão de função assíncrona" (async function expression). Uma expressão de função assíncrona é muito semelhante e tem quase a mesma sintaxe que uma declaração de função assíncrona. A principal diferença entre uma expressão de função assíncrona e uma declaração de função assíncrona é o nome da função, que pode ser omitido em expressões de função assíncronas para criar funções anônimas. Uma expressão de função assíncrona pode ser usada como uma IIFE (Immediately Invoked Function Expression - Expressão de Função Imediatamente Invocada), que é executada assim que é definida.

A aparência é a seguinte:

var loadData = async function() {
    // `rp` is a request-promise function.
    var promise1 = rp('https://api.example.com/endpoint1');
    var promise2 = rp('https://api.example.com/endpoint2');
   
    // Currently, both requests are fired, concurrently and
    // now we'll have to wait for them to finish
    var response1 = await promise1;
    var response2 = await promise2;
    return response1 + ' ' + response2;
}

Mais importante ainda, o async/await é suportado em todos os principais navegadores:

Se essa compatibilidade não é o que você procura, também existem vários transpiladores JS, como Babel e TypeScript.

No final do dia, o importante não é escolher cegamente a abordagem "mais recente" para escrever código assíncrono. É essencial entender os detalhes internos do JavaScript assíncrono, compreender por que é tão importante e conhecer profundamente os detalhes do método que você escolheu. Cada abordagem tem vantagens e desvantagens, como tudo o mais na programação.

5 Dicas para escrever código assíncrono altamente manutenível e robusto

1. Código limpo: Usar async/await permite que você escreva bem menos código. Sempre que você usa async/await, você pula algumas etapas desnecessárias: escrever .then, criar uma função anônima para lidar com a resposta, nomear a resposta daquele callback, etc.

// `rp` is a request-promise function.
rp(‘https://api.example.com/endpoint1').then(function(data) {
 // …
});

Versus:

// `rp` is a request-promise function.
var response = await rp(‘https://api.example.com/endpoint1');

2. Tratamento de erros: Async/await torna possível lidar com erros síncronos e assíncronos com a mesma estrutura de código - os conhecidos blocos try/catch. Vamos ver como fica com Promessas:

function loadData() {
    try { // Catches synchronous errors.
        getJSON().then(function(response) {
            var parsed = JSON.parse(response);
            console.log(parsed);
        }).catch(function(e) { // Catches asynchronous errors
            console.log(e); 
        });
    } catch(e) {
        console.log(e);
    }
}

Versus:

async function loadData() {
    try {
        var data = JSON.parse(await getJSON());
        console.log(data);
    } catch(e) {
        console.log(e);
    }
}

3. Condicionais: Escrever código condicional com async/await é muito mais simples:

function loadData() {
  return getJSON()
    .then(function(response) {
      if (response.needsAnotherRequest) {
        return makeAnotherRequest(response)
          .then(function(anotherResponse) {
            console.log(anotherResponse)
            return anotherResponse
          })
      } else {
        console.log(response)
        return response
      }
    })
}

Versus:

async function loadData() {
  var response = await getJSON();
  if (response.needsAnotherRequest) {
    var anotherResponse = await makeAnotherRequest(response);
    console.log(anotherResponse)
    return anotherResponse
  } else {
    console.log(response);
    return response;    
  }
}

4. Quadros de pilha (Stack Frames): Ao contrário do async/await, a pilha de erros retornada de uma cadeia de Promessas não fornece nenhuma pista de onde o erro ocorreu. Veja o exemplo a seguir:

function loadData() {
  return callAPromise()
    .then(callback1)
    .then(callback2)
    .then(callback3)
    .then(() => {
      throw new Error("boom");
    })
}
loadData()
  .catch(function(e) {
    console.log(err);
// Error: boom at callAPromise.then.then.then.then (index.js:8:13)
});

Versus:

async function loadData() {
  await callAPromise1()
  await callAPromise2()
  await callAPromise3()
  await callAPromise4()
  await callAPromise5()
  throw new Error("boom");
}
loadData()
  .catch(function(e) {
    console.log(err);
    // output
    // Error: boom at loadData (index.js:7:9)
});

5. Depuração: Se você já utilizou Promessas, sabe que depurá-las pode ser um pesadelo. Por exemplo, se você definir um ponto de interrupção dentro de um bloco .then e usar atalhos de depuração como "stop-over" (parar sobre), o depurador não passará para o próximo .then porque ele só "percorre" o código síncrono.

Com async/await, você pode percorrer as chamadas de await exatamente como se fossem funções síncronas normais durante a depuração. Isso facilita muito o processo de depuração e torna a identificação de problemas em código assíncrono muito mais intuitiva.

Referências

Este é um artigo traduzido. O artigo original pode ser lido no link abaixo.

How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with…
Welcome to post # 4 of the series dedicated to exploring JavaScript and its building components. In the process of identifying and…

Autor do post original — Alexander Zlatkov— Co-founder & CEO @SessionStack