Assíncronismo em JavaScript

Um assunto que vem se tornando cada vez mais popular é a programação assíncrona, principalmente em JavaScript, principalmente após a introdução de coisas como promises e async/await na linguagem. Por isso, hoje vamos explorar várias coisas relacionadas com esse tema no JavaScript, desde um setTimout simples com callbacks, até coisas mais avançadas como promises.

Enfim, o que é um código assíncrono? É um código que não executa na ordem em que o ele foi escrito. Ou seja, é um código que consegue executar várias coisas ao mesmo tempo.

Síncrono vs Assíncrono

Talvez só a pequena definição dada no paragrafo anterior não tenha sido o suficiente, então vou fazer uma análogia com o mundo real antes de começar a falar de código.

Primeiro imagine uma situação onde você é um cozinheiro e você decide fazer macarrão, aí você começa por separar os ingredientes, e aí deixa macarrão fervendo em uma panela com água, só que ao invés de continuar o preparo do molho com o restante dos ingredientes, você espera o macarrão ferver, para aí sim começar a preparar o molho, após, você termina o molho e o coloca em cima do macarrão para finalizar o prato.

Perceba que essa é uma situação sincrona, você fez um passo após o outro da receita.

Uma situação assíncrona seria aquela em que ao invés de esperar o macarrão ferver para aí sim começar a fazer o molho, você continua o preparo do molho enquanto espera o macarrão ficar pronto, assim otimizando o tempo para realizar essa tarefa, e não deixando você ocioso durante o tempo em que o macarrão fervia.

Programação assíncrona é quando aplicamos esse conceito de assíncronismo no nosso código, pois tem diversas operações que podem ocorrer no nosso código que podem demorar muito para terminar de executar, e que porém bloqueiam a execução de um outro código que não tem nada a ver com essa situação, por exemplo uma requisição a uma API, ou fazer uma consulta no banco de dados.

Callbacks: o começo de tudo

Você pode estar se perguntando: “Beleza, toda essa teoria é muito legal, mas e o código, onde a gente usa isso na prática?”. Bom, um dos exemplos mais simples é a função setTimeout :

console.log("Inicio do código")
setTimeout(() => console.log("Um segundo depois..."), 1000)
console.log("Continuo executando o setTimeout tenha terminado ou não")

Onde o resultado seria algo como:

"Inicio do código"
"Continuo executando o setTimeout tenha terminado ou não"
"Um segundo depois..."

Podemos confirmar que setTimeout é uma função assíncrona pois ela não bloqueou a execução do código enquanto esperava 1s para ser ativada, ela deixou que o código continua-se executando, e quando deu o seu timer ela executou a função que foi passada para ela como uma callback.

Outro exemplo de funçõe do dia-a-dia que é assíncrona seria a addEventListener :

const button = document.querySelector("button");
button.addEventListener("click", () => console.log("Clicou no botão"));
console.log("Olá")

Aqui podemos dizer que ela é assíncrona pois a função que passamos para ela como parâmetro não é executada logo que a página é carregada, ela só será executada quando o usuário clicar no botão que nós selecionamos com o querySelector , e mesmo assim a mensagem “Olá” é emitida no console quando a página é carregada, ou seja, o JS não ficou esperando você clicar no botão para ai sim mostrar o “Olá” na tela, ele continuou executando o código normalmente enquanto esperava que você clica-se no botão.

E o que esses dois códigos tem em comum? Sim, você acertou, ambos utilizam uma função callback para funcionar. A técnica mais tradicional no JS para lidar com códigos que precisam ser assíncronos é utilizar funções callbacks.

Mas o que é uma função callback?

Uma função callback é o nome que nós damos para uma função que é passada como parâmetro para outra função, nós chamamos ela assim, pois se traduzirmos o nome ficaria algo como função de retorno de chamada, e é exatamente isso que ela é: uma função que vai ser executada em algum momento no futuro pelo código que receber ela como parâmetro. Um exemplo simples seria:

function saudar(quem, aoIniciar, aoFinalizar) {
  aoIniciar();
  const saudacao = `Eu saúdo ${quem}`;
  console.log(saudacao);
  aoFinalizar(saudacao);
}

saudar(
  "A Mandioca",
  () => console.log("A função vai saudar"),
  (saudacaoFeitaPelaFuncao) => {
    console.log(`A saudação feita foi: ${saudacaoFeitaPelaFuncao}`)
  } 
);

E a saída desse código seria algo como:

"A função vai saudar"
"Eu saúdo A Mandioca"
"A saudação feita foi: Eu saúdo A Mandioca"

Perceba que funções para o JS são como qualquer outro valor, ele trata a função que nem ele trata uma string por exemplo, você consegue passar elas como parâmetro de uma função, você consegue retornar elas de uma função, você consegue atribuir elas para uma variável e assim por diante.

Logo a função do nosso exemplo, faz algo semelhante ao que o addEventListener faz, ela recebe duas funções como parâmetro, e executa a primeira ao iniciar a função, e executa a segunda ao finalizar a função. A ideia de callback é essa, a função que recebe as callbacks que decide quando elas vão ser executadas, e até que ela faça isso, as callbacks não são executadas.

Assim, é com essa lógica que funções assíncronas funcionam no JS, pois é exatamente isso que nós queremos, pense na addEventListener, ela recebe uma callback, e só executa ela quando o evento relacionado a ela ocorre, até isso acontecer a callback fica lá paradinha sem ser executada.

Uma coisa importante de ressaltar é que nem todas as funções que recebem callbacks são assíncronas, podemos pegar por exemplo a função forEach, ela não é assíncrona. Se fizermos o seguinte código, ele será executado linha a linha sem pular ninguém:

const numbers = [1, 2, 3, 4, 5]
numbers.forEach(number => console.log(number))
console.log("Executei após mostrar todos os números")

Callback hell: o problema das callbacks

E como nem tudo são flores, utilizar callbacks pode levar um problema muito grave, algo que nós chamamos de callback hell.

O callback hell é uma situação que ocorre quando nós temos muitas operações assíncronas que são dependentes umas das outras.

Por exemplo quando uma operação assíncrona depende do resultado de outra operação assíncrona. Um exemplo simples seria termos que consultar a API para pegar os dados de um usuário, e a partir desses dados fazer outra requisição para essa API para pegar outros dados relacionados a esse usuário. Ex:

function getUser(id, onSuccess, onFailure) {
  // lógica para puxar o usuário pelo id da API...
}

function getUserPosts(user, onSuccess, onFailure) {
  // lógica para puxar os posts do usuário da API...
}

function logError(location, error) {
  console.log(`Erro em ${location}`)
  console.log(error)
}

getUser(1, (user) => {
  getUserPosts(user, (posts) => {
    console.log(`The user has ${posts.length} posts.`)
  }, (error) => logError("getUserPosts", error))
}, (error) => logError("getUser", error))

Perceba como o código rapidamente ganha vários níveis de identação (o que nunca é um bom sinal), e como ele ficou confuso de se ler e entender, além disso considere como será complicado dar manutenção em um código assim.

Promises: a salvação para o callback hell

Como vimos até aqui callbacks são um jeito interessante de fazer código assíncrono, mas elas vem com o problema do callback hell, então como poderíamos resolver o problema levantado na sessão anterior?

A solução seria utilizarmos um objeto especial do JS chamado de Promise. A Promise é um objeto que simboliza um dado assíncrono, ou seja, um dado que talvez exista agora, talvez exista no futuro ou talvez não exista (é parecido com a ideia do gato de Schrödinger).

E assim que temos uma promise em mãos, nós podemos registrar uma callback para conseguirmos receber o dado assim que ele estiver disponível (seguindo a mesma lógica de todo o artigo até aqui), e outra para conseguirmos receber o erro caso algo de errado (semelhante ao onSuccess e ao onError do nosso último exemplo, porém veremos que de forma mais elegante).

Então vamos criar uma promise, em JS, podemos utilizar o construtor dela:

const promise = new Promise((resolve) => {
  setTimeout(() => resolve(1), 1000);
})

A promise recebe como parâmetro em seu construtor, uma função que vai receber dois parâmetros: resolve e reject.

O primeiro parâmetro é uma função que podemos usar para informar para a promise qual o valor que ela vai ter, e o segundo é a função para informar a promise se algo deu errado. Assim podemos executar todo aquele código assíncrono com callbacks que nós tinhamos antes, dentro dessa função, e ao invés de lidar com o resultado do processamento assíncrono na mesma função callback que recebe os dados, podemos só passar eles para o resolve, e então utilizar a promise instânciada para processar esses valores.

Usar essa forma de criar a promise é útil quando queremos converter algum código que utiliza callbacks para passar a usar promises, como por exemplo esse código que usa XMLHttpRequest para fazer uma requisição para uma API:

function makeGetRequest(url, onSuccess, onFailure) {
  const httpRequest = new XMLHttpRequest()
  
  httpRequest.onerror = () => onFailure("Request Failed");
  httpRequest.onload = () => {
    const isOk = httpRequest.status === 200; 
    if (isOk) onSuccess(httpRequest.responseText)
    else onError(httpRequest.error)
  }
  
  httpRequest.open("GET", url, true)
  httpRequest.send()
}

// Usando
makeGetRequest(
  "<https://minha-api.com>",
  (resultado) => {
    console.log(resultado)
	}, 
  (erro) => console.log(erro)
)

Podemos converter essa função em uma função que nos retorna uma promise, assim nos livramos dos parâmetros onSuccess e onFailure :

function makeGetRequest(url) {
  return new Promise((resolve, reject) => {
    const httpRequest = new XMLHttpRequest()
  
    httpRequest.onerror = () => reject("Request Failed");
    httpRequest.onload = () => {
      const isOk = httpRequest.status === 200; 
      if (isOk) resolve(httpRequest.responseText)
      else reject(httpRequest.error)
    }
  
    httpRequest.open("GET", url, true)
    httpRequest.send()
  })
}

// Usando
const requisicao = makeGetRequest("<https://minha-api.com>")
requisicao
  .then((resultado) => console.log(resultado))
  .catch((erro) => console.log(erro))

Outro exemplo legal de aplicação das promises é no setTimeout, nós podemos usar elas para tornar o setTimeout muito mais legível:

function executeAfter(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

const oneSecond = 1000;
executeAfter(oneSecond).then(() => console.log("Executou depois de um segundo"))

Legal, aprendemos como criar a promise, mas como usamos ela? E o que são esses then e catch?

A resposta para isso é, nós não temos como acessar o valor de dentro da promise, porque nós nem temos como saber se ele existe ou não, afinal temos que nos lembrar que a promise representa um valor assíncrono então ele pode existir ou não. Logo para usarmos a promise (ou seja acessar o valor dentro dela) nós precisamos registrar callbacks na promise (pense nisso como registrar eventos no JS), uma para quando as coisas derem certo e assim nós podemos finalmente receber o nosso dado, e outra para quando as coisas derem errado, e nós podermos ter acesso ao erro (ou seja, uma recebe o valor que nós passamos para o resolve, e a outra recebe o valor que nós passarmos para o reject).

Para registrar a callback que será executada se tudo der certo, para que assim nós tenhamos acesso ao valor da promise, nós utilizamos o método then. E para registrarmos a callback que vai lidar com os erros, nós passamos ela para o catch.

Logo, se voltarmos para o nosso primeiro exemplo:

const requisicao = makeGetRequest("<https://minha-api.com>")
requisicao
  .then((resultado) => console.log(resultado))
  .catch((erro) => console.log(erro))

Perceba que o then recebe a callback que recebe como parâmetro o resultado da requisição, e o catch recebe o erro.

A promise tem um detalhe interessante, ela tem 4 estados possíveis:

  • cumprida (fulfilled) – a ação relativa à promessa foi bem-sucedida
  • rejeitada (rejected) – a ação relativa à promessa falhou
  • pendente (pending) – ainda não cumprida ou rejeitada
  • resolvida (settled) – cumpriu ou rejeitou

Quando ela for cumprida, ela vai cair na callback registrada no método then, se ela for rejeitada, ela vai cair na callback do catch, se ela estiver pendente, significa que ela ainda não chamou nem a callback do catch e nem a do then, e se ela estiver como resolvida, quer dizer que ou o then foi chamado ou o catch foi chamado.

E lendo isso note que conseguimos tratar o estado de cumprida, e o estado de rejeitada, mas será que é possível tratar o estado de resolvida? E a resposta é sim, caso você queira fazer algo após a promise terminar de ser processada (executar ou o then ou o catch), você pode chamar um outro método parecido com o then e o catch, chamado de finally que é executado quando o estado da promise muda para resolvida.

Um caso de uso interessante para o finally seria um loader, imagine que nós temos que carregar as postagens em um blog e queremos que apareça um loader na tela enquanto os posts não carregam, nós poderíamos fazer assim:

function getPosts() {
  /**
   * Os dados foram omitidos para acelerar a escrita do exemplo.
   *
   * Note que o Promise.resolve é um atalho para quando queremos
   * converter um dado normal em uma promise, ai não temos que
   * escrever todo o boilerplate padrão de instânciação.
   */
  return Promise.resolve([{ ... }, { ... }, { ... }])
}

function startLoading() {
  // Lógica para fazer o loader aparecer na tela...
}

function endLoading() {
  // Lógica para fazer o loader desaparecer da tela...
}

function renderPosts(posts) {
  // Lógica para mostrar os posts na tela...
}

startLoading();
getPosts()
  .then(renderPosts)
  .catch(() => alert("Não foi possível encontrar as postagens"))
  .finally(endLoading)

Beleza, agora nós já entendemos o que são as promises, como criar elas, e como utilizar elas, mas como podemos usar elas para resolver o problema do callback hell apontado na sessão passada? Afinal elas foram criadas apenas para isso não é mesmo?

Simples, quando o nós chamamos pelo método then (ou pelo catch), o que acontece é que uma nova promise é gerada com o valor retornado pela função que foi recebida como parâmetro, por exemplo:

// O valor dentro dessa promise é o número 1
const promiseComNumero = Promise.resolve(1)

// Agora foi gerada uma nova promise com o valor 2
// Porém ela foi criada a partir do valor da primeira que recebemos como parâmetro.
const novaPromiseComNumero = promiseComNumero.then(numero => numero + 1);

// E temos acesso ao valor novamente pelo parâmetro da callback do then
novaPromiseComNumero.then(numero => console.log(numero))

O truque aqui é que se você retorna uma promise ali, no próximo then, não vem uma promise, e sim o valor dela. Ex:

// O valor dentro dessa promise é o número 1
const promiseComNumero = Promise.resolve(1)

// Agora foi gerada uma nova promise com o valor 2
// Porém ela foi criada a partir do valor da primeira que recebemos como parâmetro.
const novaPromiseComNumero = promiseComNumero
                               .then(numero => Promise.resolve(numero + 1));

// E temos acesso ao valor novamente pelo parâmetro da callback do then
// Em vez de vir algo como Promise { valor }, ele já vem o valor direto
// mesmo que uma promise tenha sido retornada no then anterior
novaPromiseComNumero.then(numero => console.log(numero))

Assim podemos resolver o código do exemplo de callback hell assim:

// Usando callbacks
getUser(1, (user) => {
  getUserPosts(user, (posts) => {
    console.log(`The user has ${posts.length} posts.`)
  }, (error) => logError("getUserPosts", error))
}, (error) => logError("getUser", error))

// Usando promises
getUser(1)
  .catch((error) => logError("getUser", error))
  .then(user => getUserPosts(user))
  .catch((error) => logError("getUserPosts", error))
  .then(posts => console.log(`The user has ${posts.length} posts.`))

Async/Await: uma nova sintaxe para as promises

Agora que já vimos o que as promises podem fazer, e se pudessemos utilizar as promises, mas com a sintaxe parecida com a que utilizamos nos nosso programas com código síncrono (sem usar callbacks para todo lado)?

E é para isso que serve o async e await, nós podemos ter o melhor dos dois mundos, a familiariedade do código síncrono com o poder do código assíncrono.

Para utilizar é muito simples, nós utilizamos a palavra async para definir que uma função agora consegue lidar com código assíncrono (ou seja com promises), e utilizamos o await para poder tirar o valor da promise sem ter que usar o then. Ex:

async function handleAsyncCode() {
  try {
    // Cada then pode virar um await
    const user = await getUser(1);
    const posts = await getUserPosts(user);
    console.log(`The user has ${posts.length} posts.`)

    // Agora trocamos o catch, pelo try/catch normal que usamos para lidar com
    // erros no JS.
  } catch (error) {
    console.log(error)
  }
}

handleAsyncCode()

E é isso, espero que você leitor tenha gostado dessa introdução ao JS assíncrono e tenha visto todo o poder que essa linguagem tem para lidar com esse tipo de cenário.

Caso queira se aprofundar mais

Uma lista de links com conteúdos para quem quiser se aprofundar mais no assunto

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *