Artigo original: Async Await JavaScript Tutorial – How to Wait for a Function to Finish in JS

Quando se encerra uma função assíncrona? Por que essa é uma pergunta tão difícil de responder?

Bem, acontece que entender funções assíncronas requer muito conhecimento sobre como o JavaScript funciona em sua base.

Exploraremos esse conceito e  aprenderemos bastante sobre JavaScript no processo.

Prontos? Vamos lá.

O que é código assíncrono?

O JavaScript foi idealizado como uma linguagem de programação síncrona. Isso quer dizer que, quando o código é executado, o JavaScript começa no início de um arquivo e executa linha por linha de código até chegar ao final.

O resultado dessa decisão de design é o fato de que apenas uma coisa pode acontecer a cada momento.

Imagine que está fazendo malabarismo com seis bolinhas ao mesmo tempo. Enquanto suas mãos estiverem ocupadas com a proeza, não é possível fazer outras coisas com elas.

O mesmo ocorre com o JavaScript: quando o código começa a ser executado, suas mãos estão ocupadas com aquele código. Chamamos esse tipo de código síncrono de bloqueador. Ele, efetivamente, bloqueia a execução de outros códigos.

Voltemos ao exemplo do malabarismo. E se quiséssemos adicionar outra bola? Em vez de seis, faríamos malabarismo com sete bolinhas. Isso pode ser um problema.

Você não quer parar com o malabarismo porque é muito divertido. E também não consegue pegar outra bolinha, pois isso significa que você teria de parar.

A solução? Delegar o trabalho para um amigo ou familiar. Eles não estão fazendo malabarismo, então poder ir lá buscar a bolinha para você, jogá-la para você quando suas mãos estiverem livres e você, então, consegue adicionar a outra bolinha ao jogo.

Isso é o código assíncrono. O JavaScript delega o trabalho para outro e segue com sua vida. Quando o trabalho está pronto, ele recebe os resultados do trabalho de volta.

Quem está fazendo o trabalho?

Certo, já sabemos que o JavaScript é síncrono e preguiçoso. Ele não quer fazer todo o trabalho sozinho, então pede o auxílio em outro lugar.

Mas quem é essa entidade misteriosa que trabalha para o JavaScript? E como o JavaScript consegue botá-la a trabalhar?

Bem, vamos dar uma olhada em um exemplo de código assíncrono.

const logName = () => {
   console.log("Han")
}

setTimeout(logName, 0)

console.log("Olá!")

Ao rodar o resultado desse código, teremos o seguinte resultado no console:

// no console
Olá!
Han

Certo, o que aconteceu aqui?

Ocorre que a maneira de colocarmos a outra entidade a trabalhar em JavaScript é usar funções específicas do ambiente e APIs. Essa é a fonte da muita confusão em JavaScript.

O JavaScript sempre é executado em um ambiente.

Em geral, esse ambiente é o navegador. Mas ele também pode ser um servidor, com o NodeJS. Tá bem, mas qual é a diferença?

A diferença – e esta é a parte importante – é que o navegador e o servidor (o NodeJS), em termos de funcionalidade, não são equivalentes. Eles são, em geral, parecidos, mas não são a mesma coisa.

Vamos ilustrar isso com um exemplo. Digamos que o JavaScript é o protagonista de um livro fantástico épico. Apenas um simples garoto da fazenda.

Agora, digamos que esse garoto da fazenda encontrou duas armaduras especiais que lhe dão poderes acima dos que ele jamais imaginava.

Quando ele usa a armadura do navegador, ele ganha acesso a um determinado conjunto de habilidades.

Quando ele usa a armadura do servidor, ele ganha acesso a outro conjunto de habilidades.

Essas armaduras, por vezes, se sobrepõem, pois os criadores dessas armaduras tinham as mesmas necessidades em alguns lugares, mas não em outros.

Isso é o "ambiente". Ele é um lugar onde o código é executado, onde há ferramentas que são construídas sobre a linguagem JavaScript existente. Elas não são parte da linguagem, mas a linha por vezes é turva, pois usamos essas ferramentas todos os dias quando escrevemos o código.

setTimeout, fetch e DOM (textos em inglês) são todos exemplos de APIs da web (veja a lista completa de APIs da web aqui). São ferramentas construídas no navegador que estão disponíveis para nós quando nosso código está em execução.

Como sempre executamos JavaScript em ujm ambiente, parece que as ferramentas são parte da linguagem, mas não são.

Assim, se você já se perguntou o motivo de poder usar fetch em JavaScript quando está no navegador (mas precisar instalar um pacote ao rodá-lo no NodeJS), essa é a questão. Alguém imaginou que fetch era uma boa ideia e fez dele uma ferramenta para o ambiente do NodeJS.

Confuso? Sim!

Mas agora, ao menos, você pode entender quem recebe o trabalho oferecido pelo JavaScript, e como essa entidade é convocada a trabalhar.

Ocorre que é o ambiente que recebe o trabalho. A maneira de fazer o ambiente trabalhar é usar a funcionalidade que pertence ao ambiente. Por exemplo, fetch e setTimeout estão no ambiente do navegador.

O que acontece com o trabalho?

Ótimo. O ambiente recebe a tarefa. E daí?

Em algum momento, você precisa receber de volta o trabalho. Vamos pensar em como isso funciona.

Voltemos ao exemplo do início com o malabarismo. Imagine que você tenha pedido mais uma bolinha para o amigo e que ele tenha lançado a bola para você quando você não estivesse pronto para recebê-la.

Seria um desastre. Talvez você tivesse sorte, a pegasse e conseguisse incorporá-la à sua rotina de modo eficaz. Há, porém, uma grande possibilidade de ela fazer com que você derrube todas as outras bolinhas e acabe com seu divertimento. Não seria melhor dar instruções claras de quando você quer receber a bolinha?

Acontece que há regras claras com relação a quando o JavaScript pode receber de volta o trabalho que delegou.

Essas regras são governadas pelo laço de evento (event loop, em inglês) e envolvem a fila de microtarefas e macrotarefas. Sim, eu sei. É muita coisa para absorver de repente. Mas continue comigo.

autodraw-31_08_2020

Certo. Então, quando delegamos código assíncrono para o navegador, ele o recebe e executa o código, recebendo a carga de trabalho. Existem, porém, diversas tarefas que são dadas ao navegador. Precisamos, portanto, garantir que podemos priorizar essas tarefas.

É aí que a fila de microtarefas age. O navegador receberá o trabalho, o realizará e colocará o resultado em uma das duas filas com base no tipo de trabalho que recebe.

Promises, por exemplo, são colocadas na fila de microtarefas e têm uma prioridade maior.

Eventos e setTimeout são exemplos de trabalhos que são colocados na fila de macrotarefas, tendo uma prioridade menor.

Agora que o trabalho está feito e colocado em uma das duas filas, o laço de evento percorrerá para trás e para frente, verificando se o JavaScript está ou não pronto para receber os resultados.

Somente quando o JavaScript tiver terminado de executar seu código síncrono é que o laço de eventos começará a pegar as tarefas das filas e a lidar com as funções, enviando-as de volta ao JavaScript para que ele as execute.

Vamos dar uma olhada em um exemplo:

setTimeout(() => console.log("hello"), 0) 

fetch("https://someapi/data").then(response => response.json())
                             .then(data => console.log(data))

console.log("What soup?")

Qual será a ordem aqui?

  1. Primeiro, setTimeout é delegado para o navegador, que faz o trabalho e coloca a função resultante na fila de macrotarefas.
  2. Depois, fetch é delegado para o navegador, que recebe o trabalho. Ele obtém os dados do endpoint e coloca as funções resultantes na fila de microtarefas.
  3. O Javascript imprime em tela "What soup"?
  4. O laço de eventos verifica se o JavaScript está pronto para receber os resultados dos trabalhos em fila.
  5. Quando o console.log terminar, o JavaScript está pronto. O laço de eventos seleciona as funções em fila da fila de microtarefas, que têm uma prioridade maior, e as retorna ao JavaScript para que ele as execute.
  6. Depois de esvaziar a fila de microtarefas, a função de callback de setTimeout é retirada da fila de macrotarefas e devolvida ao JavaScript para que ele a execute.
No console:
// What soup?
// os dados da api
// hello

Promises

Agora, você já sabe bastante sobre como o código assíncrono é tratado pelo JavaScript e pelo ambiente do navegador. É a hora de falarmos de promises.

Uma promise é uma construção do JavaScript que representa um valor futuro desconhecido (apenas a "promessa" de um valor). Por conceito, uma promise é apenas o JavaScript "prometendo" retornar um valor. Esse valor pode ser o resultado de uma chamada de API, ou um objeto de erro de uma solicitação à rede que não funcionou. Sua garantia é a de que receberá "alguma coisa".

const promise = new Promise((resolve, reject) => {
	// Fazer uma solicitação à rede
   if (response.status === 200) {
      resolve(response.body)
   } else {
      const error = { ... }
      reject(error)
   }
})

promise.then(res => {
	console.log(res)
}).catch(err => {
	console.log(err)
})

Uma promise pode ter os seguintes estados:

  • fulfilled (atendida) - a ação foi concluída com sucesso
  • rejected (rejeitada) - a ação falhou
  • pending (pendente) - a ação ainda não foi concluída
  • settled (resolvida) - a ação foi atendida ou rejeitada

Uma promise recebe uma função de resolve (resolução) e uma função de reject (rejeição), que podem ser chamadas para acionar um desses estados.

Um dos pontos mais interessantes das promises é o fato de podermos encadear funções sobre o que queremos que aconteça no caso de sucesso (resolve) ou de erro (reject):

  • Para registrar uma função que ocorra quando houver sucesso, usamos .then
  • Para registrar uma função que ocorra quando houver um erro, usamos .catch
// Fetch retorna uma promise
fetch("https://swapi.dev/api/people/1")
	.then((res) => console.log("Essa função é executada se a solicitação tiver sucesso", res)
    .catch(err => console.log("Essa função é executada se houver uma falha na solicitação", err)
           
// Encadeando diversas funções
 fetch("https://swapi.dev/api/people/1")
	.then((res) => doSomethingWithResult(res))
    .then((finalResult) => console.log(finalResult))
    .catch((err => doSomethingWithErr(err))

Perfeito. Agora, vamos ver mais de perto qual a aparência disso internamente, usando fetch como exemplo:

const fetch = (url, options) => {
  // simplificado
  return new Promise((resolve, reject) => {

  const xhr = new XMLHttpRequest()
  // ... fazer a solicitação
  xhr.onload = () => {
    const options = {
        status: xhr.status,
        statusText: xhr.statusText
        ...
    }
    
    resolve(new Response(xhr.response, options))
  }
  
  xhr.onerror = () => {
    reject(new TypeError("Falha na solicitação"))
  }
}
 
 fetch("https://swapi.dev/api/people/1")
   // Registrar handleResponse para que execute na resolução da promise
	.then(handleResponse)
  .catch(handleError)
  
 // Por conceito, essa é a aparência da promise agora:
 // { status: "pending", onsuccess: [handleResponse], onfailure: [handleError] }
  
 const handleResponse = (response) => {
  // handleResponse receberá automaticamente a resposta, ¨
  // pois a promise resolve com um valor e automaticamente o injeta na função
   console.log(response)
 }
 
  const handleError = (response) => {
  // handleError receberá automaticamente o erro, ¨
  // pois a promise resolve com um valor e automaticamente o injeta na função
   console.log(response)
 }
  
// a promise será resolvida ou rejeitada, fazendo com que execute todas as funções registradas nos arrays respectivos,
// injetando o valor. Vamos dar uma olhada no "caminho feliz":
  
// 1. Disparo do listener de eventos do XHR
// 2. Se houver sucesso na solicitação, o listener de eventos onload será disparado
// 3. O listener onloaddispara a função resolve(VALOR) com o valor dado
// 4. Resolve dispara e agenda as funções registradas com .then
  
  

Assim, podemos usar promises para fazer o trabalho assíncrono e garantir que podemos tratar qualquer resultado vindo dessas promises. Essa é a proposição de valor. Se quiser saber mais sobre promises, pode ler a respeito delas aqui e aqui.

Quando usamos promises, encadeamos nossas funções na promise para tratar dos diferentes cenários.

Isso funciona, mas ainda precisamos lidar com nossa lógica dentro de callbacks (funções aninhadas) ao receber de volta nossos resultados. E se pudéssemos usar as promises escrevendo um código que pareça síncrono? Acontece que é possível!

Async/await

Async/await são a forma de escrever promises que nos permitam escrever código assíncrono que parece síncrono. Vejamos abaixo.

const getData = async () => {
    const response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
    const data = await response.json()
    
    console.log(data)
}

getData()

Aqui, nada mudou internamente. Ainda estamos usando promises para fazer o fetch dos dados, mas agora o código tem a aparência de código síncrono, e não temos mais blocos .then e .catch.

Async/await são, de fato, apenas um adendo sintático que nos dá um modo de criar código mais fácil de se raciocinar a respeito, sem mudar a dinâmica subjacente.

Vejamos como isso funciona.

Async/Await nos permitem usar generators para pausar a execução de uma função. Quando usamos async/await, não estamos bloqueando nada, pois a função está dando o controle de volta para o programa principal.

Assim, quando a promise for resolvida, usaremos o generator para devolver o controle para a função assíncrona com o valor da promise resolvida.

Você pode ler mais neste artigo para ter uma visão geral sensacional de generators e de código assíncrono (texto em inglês).

Com efeito, podemos agora escrever código assíncrono que se parece com código síncrono. Isso significa que agora é mais fácil pensar o código. Além disso, agora podemos usar as ferramentas síncronas, como try/catch, para lidar com erros:

const getData = async () => {
    try {
    	const response = await fetch("https://jsonplaceholder.typicode.com/todos/1")
    	const data = await response.json()
        console.log(data)
    } catch (err) {
       console.log(err)
    }
    
}

getData()

Certo. Como usamos isso? Para usar async/await, precisamos anexar async à função. Isso não a torna uma função assíncrono. Somente nos permite usar await dentro dela.

Se não colocarmos a palavra-chave async, teremos um erro de sintaxe ao tentar usar await em uma função regular.

const getData = async () => {
	console.log("Podemos usar await nesta função")
}

Por causa disso, não podemos usar async/await no código de nível superior. Async e await, no entanto, são apenas um adendo sintático às promises. Podemos, assim, lidar com os casos de nível superior com o encadeamento de promises sem problemas:

async function getData() {
  let response = await fetch('http://apiurl.com');
}

// getData é uma promise
getData().then(res => console.log(res)).catch(err => console.log(err); 

Isso expõe outro fato interessante sobre async/await. Ao definir uma função como async, ela sempre retornará uma promise.

Usar async/await pode parecer um pouco com magia de início. Porém, como ocorre com qualquer outro truque de mágica, eles são apenas uma tecnologia avançada o suficiente e que evoluiu com o passar dos anos. Esperamos que agora, você tenha um entendimento forte das questões básicas e que consiga usar async/await com confiança.

Conclusão

Se você chegou até aqui, parabéns. Acaba de adicionar um conhecimento chave sobre JavaScript e sobre como funcionam seus ambientes à sua coleção.

Esse tópico é, certamente, um que causa confusão. As linhas divisórias nem sempre são claras. Esperamos, no entanto, que agora você saiba sobre como o JavaScript funciona com código assíncrono no navegador, e que esteja sabendo mais a respeito de promises e de async/await.

Se gostou deste artigo, você também pode gostar do canal do autor no YouTube. O canal, de momento, tem uma série sobre fundamentos da web onde o autor examina HTTP, a construção de servidores da web do zero e mais (em inglês).

Se essa é a sua paixão, também há uma série sobre como criar um app completo com React. O autor também planeja adicionar aqui mais conteúdos no futuro, analisando mais a fundo tópicos de JavaScript.

Se quiser dar um oi ou conversar sobre desenvolvimento para a web, você também pode acessar o autor pelo Twitter: @foseberg. Obrigado pela leitura!