Artigo original: JavaScript — from callbacks to async/await

O JavaScript é síncrono. Isso quer dizer que ele executará seu bloco de código na ordem após o hoisting (texto em inglês). Antes de o código ser executado, as declarações de var e de function passam pelo processo de "hoisted" (algo como içamento, em português) para o topo do código.

Aqui temos um exemplo de código síncrono:

console.log('1')

console.log('2')

console.log('3')

O código, com segurança, registrará "1 2 3".

Solicitações assíncronas aguardarão o final de uma contagem de tempo ou a resposta de uma solicitação enquanto o resto do código continua a ser executado. Então, quando a hora certa chegar, uma função de callback colocará em ação essas solicitações assíncronas.

Este é um exemplo de código assíncrono:

console.log('1')

setTimeout(function depoisDeDoisSegundos() {
  console.log('2')
}, 2000)

console.log('3')

O que aparecerá no console, de fato, será "1 3 2", pois o "2" está em um setTimeout, que será executado apenas, neste exemplo, após dois segundos. Sua aplicação não fica parada aguardando que os dois segundos passem. Em vez disso, ela segue executando o resto do código e, quando o "timeout" chega ao final, ela retorna à função depoisDeDoisSegundos.

Você pode estar se perguntando "Qual é a utilidade disso?" ou "Como eu faço o meu código assíncrono se tornar síncrono?". Espero poder mostrar para você essas respostas.

"O problema"

Digamos que nosso objetivo é procurar por um usuário do GitHub e obter todos os repositórios daquele usuário. O problema é que não sabemos o nome exato do usuário. Então, temos uma lista com todos os usuários de nomes semelhantes e seus respectivos repositórios.

Não precisa ser nada muito estiloso. Algo assim já serve:

OHwdYj5jqLgcI0Sad-H3K0p0VUT14C0DmVV8
Uau, que estilo! E aqui está o fiddle com o código.

Nestes exemplos, o código de solicitação usará o XHR (XMLHttpRequest). Você pode substituí-lo pelo $.ajax do jQuery ou a abordagem nativa mais recente, chamada fetch. Ambas darão a você uma abordagem com promises de saída.

Haverá pequenas alterações, dependendo da sua abordagem, mas, para começar, temos:

// o argumento do url pode ser algo assim: 'https://api.github.com/users/daspinola/repos'

function request(url) {
  const xhr = new XMLHttpRequest();
  xhr.timeout = 2000;
  xhr.onreadystatechange = function(e) {
    if (xhr.readyState === 4) {
      if (xhr.status === 200) {
       // O código que vai aqui é para quando uma resposta do servidor foi um sucesso
      } else {
       // O código que vai aqui é para quando uma resposta do servidor falhou
      }
    }
  }
  xhr.ontimeout = function () {
    // Bem, levou um tempo para colocar aqui o código para tratar disso
  }
  xhr.open('get', url, true)
  xhr.send();
}

Lembre-se de que, nesses exemplos, a parte importante não é a forma do resultado final. Em vez disso, seu objetivo deve ser entender as diferenças entre as abordagens e como tirar proveito delas para o seu desenvolvimento.

Callback

É possível salvar uma referência de uma função em uma variável usando o JavaScript. Então, você pode usá-la como argumento de outra função para executá-la mais tarde. É isso que chamamos de "callback".

Um exemplo seria:

// Execute a função "fazIsso" com outra função como parâmetro (nesse caso, "eAgoraIsso". fazIsso executará sempre o código que tem. Quando acabar, eAgoraIsso será executada também.

fazIsso(eAgoraIsso)

// Dentro de "fazIsso" há uma referência a "callback", que é apenas uma variável que terá a referência dessa função.

function eAgoraIsso() {
  console.log('e agora isso')
}

// Você pode chamar a variável como você quiser. "callback" é uma abordagem comum

function fazIsso(callback) {
  console.log('isso primeiro')
  
  // O '()' ocorre para dizer ao seu código para executar a função de referência. Caso contrário, ela apenas registrará a referência em log
  
  callback()
}

Usar callback para resolver nosso problema nos permite fazer algo como isso para a função request que definimos anteriormente:

function request(url, callback) {
  const xhr = new XMLHttpRequest();
  xhr.timeout = 2000;
  xhr.onreadystatechange = function(e) {
    if (xhr.readyState === 4) {
      if (xhr.status === 200) {
       callback(null, xhr.response)
      } else {
       callback(xhr.status, null)
      }
    }
  }
  xhr.ontimeout = function () {
   console.log('Timeout')
  }
  xhr.open('get', url, true)
  xhr.send();
}

Nossa função de solicitação (em inglês, request) agora aceitará uma callback. Assim, quando uma request for realizada, a callback será chamada em caso de erro e em caso de sucesso.

const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`

request(userGet, function handleUsersList(error, users) {
  if (error) throw error
  const list = JSON.parse(users).items
  
  list.forEach(function(user) {
    request(user.repos_url, function handleReposList(err, repos) {
      if (err) throw err
      // Trate da lista de repositórios aqui
    })
  })
})

Vamos analisar o código:

  • Fazemos uma solicitação para obter os repositórios do usuário
  • Após a solicitação ser concluída, usamos a callback handleUsersList
  • Se não houver erro, fazemos o parsing de nossa resposta do servidor em um objeto usando JSON.parse
  • Em seguida, iteramos nossa lista de usuário, pois é possível que haja mais de um. Para cada usuário, solicitamos sua lista de repositórios. Usaremos o url retornado por usuário em nossa primeira resposta. Chamamos repos_url como o url para nossas próximas solicitações ou o que vier da primeira resposta
  • Quando a solicitação concluir a callback, a chamaremos. Isso tratará do erro ou da resposta com a lista de repositórios daquele usuário.

Observação: enviar o erro primeiro como parâmetro é uma prática comum, especialmente ao usar o Node.js.

Uma abordagem mais "completa" e legível seria fazer um tratamento do erro. Manteríamos a callback separada da execução da solicitação.

Um exemplo seria algo assim:

try {
  request(userGet, handleUsersList)
} catch (e) {
  console.error('Festa de solicitações! ', e)
}

function handleUsersList(error, users) {
  if (error) throw error
  const list = JSON.parse(users).items
  
  list.forEach(function(user) {
    request(user.repos_url, handleReposList)
  })
}

function handleReposList(err, repos) {
  if (err) throw err
  
  // Tratar da lista de repositórios aqui
  console.log('Meus poucos repositórios', repos)
}

Isso acaba gerando problemas como o 'racing' e questões de tratamento de erros. 'Racing' é o que acontece quando você não controla qual usuário você obterá primeiro. Estamos solicitando as informações para todos eles, no caso de haver mais de um. Não estamos levando a ordem em consideração. Por exemplo, o usuário 10 pode vir primeiro e o usuário 2 no final. Veremos uma solução possível posteriormente neste artigo.

O problema principal com as callbacks é o fato de a manutenção e a legibilidade poderem se tornar complicadas. De fato, elas já são um pouco e o código não faz quase nada. Isso é conhecido como callback hell (ou, em português, o 'inferno das callbacks'), que pode ser evitado com nossa próxima abordagem.

gnjFO34QsB-GSxf1kW-rES6NKbXikObOWHTG
Imagem extraída daqui. Um callback hell caprichado.

Promises

As promises podem tornar seu código mais legível. Um desenvolvedor novo pode vir na base do código e ver uma ordem clara de execução em seu código.

Para criar uma promise, você pode usar:

const myPromise = new Promise(function(resolve, reject) {
  
  // código aqui
  
  if (codigoBom) {
    resolve('tudo bem')
  } else {
    reject('erro')
  }
  
})

myPromise
  .then(function quandoOk(response) {
    console.log(response)
    return response
  })
  .catch(function comProblemas(err) {
    console.error(err)
  })

Vamos analisar passo a passo:

  • Uma promise é iniciada com uma function que tem as instruções resolve e reject
  • Torne seu código assíncrono dentro da função Promise. Retorne resolve quando tudo der certo. Do contrário, retorne reject
  • Quando resolve é encontrado, o método .then será executado para aquela Promise. Quando um reject for encontrado, .catch é acionado

Coisas para levar em consideração:

  • resolve e reject aceitam apenas um parâmetro
    resolve('oba', 'funciona') enviará apenas 'oba' à função de callback .then
  • Se você encadear diversos .then, adicione um return se quiser que o valor de .then seguinte não seja undefined
  • Quando um reject é pego com o .catch se você tiver um .then encadeado a ele, ele ainda executará aquele .then. Você pode ver esse .then como um "sempre executável" e pode conferir um exemplo neste comentário
  • Com vários .then encadeados, se um erro acontece no primeiro, ele saltará os .then subsequentes até encontrar um .catch
  • Uma promise tem três estados:
    pending: quando aguarda que um resolve ou um reject aconteçam
    resolved: quando é resolvida
    rejected: quando é rejeitada
  • Quando a promise estiver no estado resolved ou rejected, ela não poderá mais ser alterada

Observação: você pode criar promises sem a função no momento das declarações. A maneira como estou mostrando é somente uma maneira comum de fazê-lo.

"Quanta teoria… já me deixou confuso", você pode até dizer.

Vamos usar nosso exemplo de solicitação com uma promise para tentar esclarecer as coisas:

function request(url) {
  return new Promise(function (resolve, reject) {
    const xhr = new XMLHttpRequest();
    xhr.timeout = 2000;
    xhr.onreadystatechange = function(e) {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          resolve(xhr.response)
        } else {
          reject(xhr.status)
        }
      }
    }
    xhr.ontimeout = function () {
      reject('timeout')
    }
    xhr.open('get', url, true)
    xhr.send();
  })
}

Neste cenário, quando você executa uma request, ela retornará algo assim:

NAgYZaSSRVgbc42aFAOEaTIYnA-2JweED-At
Uma promise em estado pending, para ser resolvida ou rejeitada
const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`

const myPromise = request(userGet)

console.log('estará pending ao aparecer no console', myPromise)

myPromise
  .then(function handleUsersList(users) {
    console.log('quando um resolve é encontrado, vem parar aqui com a response, neste caso, os usuários ', users)
    
    const list = JSON.parse(users).items
    return Promise.all(list.map(function(user) {
      return request(user.repos_url)
    }))
  })
  .then(function handleReposList(repos) {
    console.log('Todos os repositórios de usuários em um array', repos)
  })
  .catch(function handleErrors(error) {
    console.log('quando um reject é encontrado, vem parar aqui, ignorando a instrução then ', error)
  })

É assim que resolvemos o 'racing' e alguns dos problemas de tratamento de erros. O código ainda é um pouco confuso, mas é uma forma de mostrar para você que essa abordagem ainda pode criar problemas de legibilidade.

Um conserto rápido seria separar as callbacks assim:

const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`

const userRequest = request(userGet)

// Uma simples leitura em voz alta dessa parte dá uma boa ideia do que faz o código
userRequest
  .then(handleUsersList)
  .then(repoRequest)
  .then(handleReposList)
  .catch(handleErrors)
  
// callback que trata da lista de usuários
function handleUsersList(users) {
  return JSON.parse(users).items
}

// callback que faz a solicitação dos repositórios
function repoRequest(users) {
  return Promise.all(users.map(function(user) {
    return request(user.repos_url)
  }))
}

// callback que trata da lista de repositórios
function handleReposList(repos) {
  console.log('All users repos in an array', repos)
}

// callback que trata dos erros
function handleErrors(error) {
  console.error('Something went wrong ', error)
}

Só de olhar para o que userRequest está aguardando na ordem com os .then, você pode entender o que esperamos desse bloco de código. Tudo é mais ou menos separado por responsabilidade.

Esta é apenas uma amostra do que são as promises. Para entender melhor como elas funcionam, recomendo e muito a leitura deste artigo.

Generators

Outra abordagem é o uso de generators. Isso é um pouco mais avançado, então fique à vontade de pular para o próximo tópico.

Um dos usos dos generators é permitir que você tenha código assíncrono que se parece com código síncrono.

Eles são representados por um * na função e têm essa aparência:

function* foo() {
  yield 1
  const args = yield 2
  console.log(args)
}
var fooIterator = foo()

console.log(fooIterator.next().value) // mostrará 1 no console
console.log(fooIterator.next().value) // mostrará 2 no console

fooIterator.next('aParam') // registrará o console.log dentro do generator 'aParam'

Em vez de retornar com um return, os generators têm uma instrução yield. Ela interromperá a execução da função até que um .next seja feito para aquela iteração da função. É semelhante à promise com .then, pois somente executa quando é retornado um resolved.

Nossa função de solicitação (request) tem essa aparência:

function request(url) {
  return function(callback) {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(e) {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          callback(null, xhr.response)
        } else {
          callback(xhr.status, null)
        }
      }
    }
    xhr.ontimeout = function () {
      console.log('timeout')
    }
    xhr.open('get', url, true)
    xhr.send()
  }
}

Queremos o url como um argumento. Porém, em vez de executar a solicitação diretamente, nós a queremos apenas quando tivermos uma callback para tratar da resposta.

Nosso generator terá essa aparência:

function* list() {
  const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
 
  const users = yield request(userGet)
  
  yield
  
  for (let i = 0; i<=users.length; i++) {
    yield request(users[i].repos_url)
  }
}

Ele será responsável por:

  • Aguardar até que a primeira request seja preparada
  • Retornar uma referência da function esperando uma callback para a primeira request. Nossa função de request aceita um url e retorna uma function que espera uma callback
  • Esperar que os users sejam enviados no próximo .next
  • Iterar pelos users
  • Aguardar pelo .next para cada um dos users
  • Retornar suas respectivas funções de callback

Assim, uma execução teria essa aparência:

try {
  const iterator = list()
  iterator.next().value(function handleUsersList(err, users) {
    if (err) throw err
    const list = JSON.parse(users).items
    
    // envia a lista dos usuários para iterator
    iterator.next(list)
    
    list.forEach(function(user) {
      iterator.next().value(function userRepos(error, repos) {
        if (error) throw repos
        
        // Trata de cada repositório de usuário individual aqui
        console.log(user, JSON.parse(repos))
      })
    })
  })  
} catch (e) {
  console.error(e)
}

Podemos separar as funções de callback como fizemos anteriormente. Você já deve ter entendido a ideia agora. Uma lição para se relembrar é que podemos tratar de cada lista de repositórios de cada usuário individualmente.

Não sei bem como eu me sinto quanto aos generators. Por um lado, eu consigo entender o que é esperado do código simplesmente olhado para o generator.

Sua execução, no entanto, acaba tendo problemas semelhantes aos do callback hell.

Como ocorre com async/await, é recomendado usar um compilador. Isso ocorre porque ele não tem o suporte de versões mais antigas de navegadores.

Além disso, na minha experiência, ele não é muito comum. Ele, portanto, pode gerar confusão em bases de código mantidas por diversos desenvolvedores.

Uma ideia ótima sobre o funcionamento dos generators pode ser encontrada neste artigo (em inglês). Aqui, você vê um outro recurso (também em inglês).

Async/Await

Este método parece uma mistura de generators e promises. Você precisa apenas informar ao seu código quais funções serão async e que parte do código precisará await (aguardar, em português) até que a promise seja concluída.

somaVinteDepoisDeDoisSegundos(10)
  .then(result => console.log('after 2 seconds', result))
  
async function somaVinteDepoisDeDoisSegundos(valor) {
  const restante = depoisDeDoisSegundos(20)
  return valor + await restante
}

function depoisDeDoisSegundos(valor) {
  return new Promise(resolve => {
    setTimeout(() => { resolve(valor) }, 2000);
  });
}

Neste cenário:

  • Temos somaVinteDepoisDeDoisSegundos como a função assíncrona
  • Dizemos ao código para aguardar pelo resolve ou reject de nossas funções de promise depoisDeDoisSegundos
  • Somente chegaremos ao .then quando as operações await encerrarem. Nesse caso, temos apenas uma.

Ao aplicar isso à nossa request, a deixamos como uma promise, como vimos antes:

function request(url) {
  return new Promise(function(resolve, reject) {
    const xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(e) {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          resolve(xhr.response)
        } else {
          reject(xhr.status)
        }
      }
    }
    xhr.ontimeout = function () {
      reject('timeout')
    }
    xhr.open('get', url, true)
    xhr.send()
  })
}

Criamos nossa função async com as await necessárias, assim:

async function list() {
  const userGet = `https://api.github.com/search/users?page=1&q=daspinola&type=Users`
  
  const users = await request(userGet)
  const usersList = JSON.parse(users).items
  
  usersList.forEach(async function (user) {
    const repos = await request(user.repos_url)
    
    handleRepoList(user, repos)
  })
}

function handleRepoList(user, repos) {
  const userRepos = JSON.parse(repos)
  
  // Trata de cada repositório de usuário individual aqui
  
  console.log(user, userRepos)
}

Até o momento, temos uma função list assíncrona que tratará das solicitações. Outra async é necessária no forEach para que tenhamos a lista de repos de cada usuário para serem manipuladas.

Chamamos isso assim:

list()
  .catch(e => console.error(e))

Esta e a abordagem das promises são as minhas favoritas, já que o código é de fácil leitura e alteração. Você pode ler sobre async/await com mais profundidade aqui (texto em inglês).

Um lado negativo de usar async/await é o fato de não ter o suporte no front-end em navegadores antigos nem no back-end. Seria preciso usar, no mínimo, a versão 8 do Node.

Você pode usar um compilador como o babel para ajudar a resolver isso.

"Solução"

Você pode ver o código final, onde conseguimos atender à nossa meta inicial usando async/await neste trecho.

Algo que você pode fazer é experimentar com as diversas formas mencionadas neste artigo.

Conclusão

Dependendo do cenário, você poderá usar:

  • async/await
  • callbacks
  • uma mistura delas

É com você achar o que melhor atende aos seus propósitos e aquilo que ajuda você a manter seu código, de maneira que seja compreensível para os outros e para seu futuro eu.

Observação: qualquer uma das abordagens se torna levemente menos extensa ao usar as alternativas para solicitações, como $.ajax e fetch.

Conte-me o que você faria de diferente e as diversas maneiras que você encontrou para tornar cada abordagem mais legível.

Este é o artigo 11 de uma série de 30. Ele é parte de um projeto de publicação de pelo menos um artigo por semana, de ideias a tutoriais. Deixe seu comentário e siga-me no Twitter (Diogo Spínola) e continue com seus projetos brilhantes!