JavaScript Assíncrono: Domine Async/Await, Promises e Callbacks!

Ao desenvolver aplicações em JavaScript, é provável que você se depare com funções assíncronas, como a função de busca do navegador ou a função readFile no Node.js.

Se você tentou utilizar essas funções da mesma forma que funções comuns, pode ter obtido resultados inesperados. Isso ocorre porque são funções assíncronas. Este artigo explica o que isso significa e como utilizar funções assíncronas de forma eficaz.

Entendendo Funções Síncronas

JavaScript é uma linguagem de thread único, o que significa que ela só consegue executar uma tarefa por vez. Se o processador encontrar uma função que leve muito tempo para ser concluída, o JavaScript aguardará até que essa função termine antes de prosseguir para outras partes do código.

A maioria das funções é executada integralmente pelo processador. Durante a execução dessas funções, o processador fica totalmente ocupado, independentemente do tempo que leve. Essas são as chamadas funções síncronas. Veja um exemplo de função síncrona:

function somar(a, b) {
    for (let i = 0; i < 1000000; i ++) {
        // Não faz nada
    }
    return a + b;
}

// A chamada da função levará um tempo
resultado = somar(10, 5);

// No entanto, o processador não pode avançar para o
console.log(resultado);

Essa função executa um loop extenso que leva um tempo considerável para ser concluído antes de retornar a soma dos dois argumentos.

Após definir a função, nós a chamamos e armazenamos o resultado na variável ‘resultado’. Em seguida, exibimos o valor dessa variável. Embora a execução da função ‘somar’ demore um pouco, o processador não pode prosseguir para exibir a soma até que a execução esteja finalizada.

A grande maioria das funções que você encontrará terá um comportamento previsível, como descrito acima. No entanto, algumas funções são assíncronas e não se comportam da mesma forma que as funções regulares.

Explorando Funções Assíncronas

Funções assíncronas realizam a maior parte do seu trabalho fora do processador principal. Isso significa que, mesmo que a função leve um tempo para ser concluída, o processador estará disponível e livre para executar outras tarefas.

Abaixo está um exemplo de uma função assíncrona:

fetch('https://jsonplaceholder.typicode.com/users/1');

Para otimizar a eficiência, o JavaScript permite que o processador avance para outras tarefas que exigem a CPU, mesmo antes da conclusão da execução da função assíncrona.

Como o processador prossegue antes da conclusão da função assíncrona, seu resultado não estará disponível imediatamente. Ele ficará pendente. Se o processador tentasse executar partes do programa que dependem desse resultado pendente, ocorreriam erros.

Portanto, o processador deve executar apenas as partes do programa que não dependem do resultado pendente. Para isso, o JavaScript moderno utiliza promessas.

O Conceito de Promessa em JavaScript

Em JavaScript, uma promessa é um valor temporário que uma função assíncrona retorna. As promessas são a base da programação assíncrona moderna em JavaScript.

Uma vez que uma promessa é criada, uma de duas coisas acontece: ela é resolvida quando o valor de retorno da função assíncrona é produzido com sucesso, ou é rejeitada em caso de erro. Esses são eventos dentro do ciclo de vida de uma promessa. Portanto, podemos associar manipuladores de eventos à promessa que serão chamados quando ela for resolvida ou rejeitada.

Qualquer código que necessite do valor final de uma função assíncrona pode ser anexado ao manipulador de eventos da promessa para quando ela for resolvida. Da mesma forma, qualquer código que manipule o erro de uma promessa rejeitada também será anexado ao seu respectivo manipulador de eventos.

Aqui está um exemplo de leitura de dados de um arquivo no Node.js:

const fs = require('fs/promises');

arquivoPromessa = fs.readFile('./hello.txt', 'utf-8');

arquivoPromessa.then((dados) => console.log(dados));

arquivoPromessa.catch((erro) => console.log(erro));

Na primeira linha, importamos o módulo fs/promises.

Na segunda linha, chamamos a função readFile, passando o nome e a codificação do arquivo cujo conteúdo queremos ler. Essa função é assíncrona; portanto, ela retorna uma promessa. Armazenamos a promessa na variável ‘arquivoPromessa’.

Na terceira linha, anexamos um ouvinte de evento para quando a promessa for resolvida. Fazemos isso chamando o método ‘then’ no objeto de promessa. Como argumento para a chamada do método ‘then’, passamos a função que deve ser executada quando a promessa for resolvida.

Na quarta linha, anexamos um ouvinte para quando a promessa for rejeitada. Isso é feito chamando o método ‘catch’ e passando o manipulador de eventos de erro como argumento.

Uma abordagem alternativa é utilizar as palavras-chave async e await. Abordaremos essa abordagem a seguir.

Async e Await: Uma Explicação Detalhada

As palavras-chave ‘async’ e ‘await’ podem ser usadas para escrever código JavaScript assíncrono de uma maneira que seja sintaticamente mais agradável. Nesta seção, explicarei como usar essas palavras-chave e qual o efeito que elas têm no seu código.

A palavra-chave ‘await’ é usada para pausar a execução de uma função enquanto se aguarda a conclusão de uma função assíncrona. Veja um exemplo:

const fs = require('fs/promises');

function lerDados() {
    const dados = await fs.readFile('./hello.txt', 'utf-8');

    // Esta linha não será executada até que os dados estejam disponíveis
    console.log(dados);
}

lerDados()

Utilizamos a palavra-chave ‘await’ ao chamar ‘readFile’. Isso instrui o processador a esperar até que o arquivo seja lido antes que a próxima linha (o console.log) possa ser executada. Isso garante que o código que depende do resultado de uma função assíncrona não seja executado até que o resultado esteja disponível.

Se você tentasse executar o código acima, encontraria um erro. Isso acontece porque ‘await’ só pode ser usado dentro de uma função assíncrona. Para declarar uma função como assíncrona, você utiliza a palavra-chave ‘async’ antes da declaração da função da seguinte maneira:

const fs = require('fs/promises');

async function lerDados() {
    const dados = await fs.readFile('./hello.txt', 'utf-8');

    // Esta linha não será executada até que os dados estejam disponíveis
    console.log(dados);
}

// Chamando a função para executá-la
lerDados()

// O código neste ponto será executado enquanto se aguarda a conclusão da função lerDados
console.log('Aguardando a conclusão dos dados')

Ao executar esse trecho de código, você perceberá que o JavaScript executa o console.log externo enquanto aguarda a disponibilização dos dados lidos do arquivo de texto. Uma vez disponíveis, o console.log dentro de ‘lerDados’ é executado.

O tratamento de erros ao usar as palavras-chave ‘async’ e ‘await’ geralmente é feito utilizando blocos try/catch. Também é importante saber como fazer loops com código assíncrono.

Async e await estão disponíveis no JavaScript moderno. Tradicionalmente, o código assíncrono era escrito usando callbacks.

Introdução aos Callbacks

Um callback é uma função que será chamada assim que o resultado estiver disponível. Todo o código que necessita do valor de retorno será colocado dentro do callback. Todo o restante fora do callback não depende do resultado e, portanto, pode ser executado.

Aqui está um exemplo de leitura de um arquivo no Node.js:

const fs = require("fs");

fs.readFile("./hello.txt", "utf-8", (erro, dados) => {

    // Neste callback, colocamos todo o código que precisa
    if (erro) console.log(erro);
    else console.log(dados);
});

// Nesta parte aqui podemos realizar todas as tarefas que não precisam do resultado
console.log("Olá do programa")

Na primeira linha, importamos o módulo fs. Em seguida, chamamos a função readFile do módulo fs. A função readFile lerá o texto de um arquivo especificado. O primeiro argumento é o arquivo em questão e o segundo especifica o formato do arquivo.

A função readFile lê o texto dos arquivos de forma assíncrona. Para isso, ela utiliza uma função como argumento. Esse argumento de função é um callback e será chamado assim que os dados forem lidos.

O primeiro argumento passado quando o callback é chamado é um erro que terá um valor se ocorrer um erro durante a execução da função. Se nenhum erro for encontrado, ele será indefinido.

O segundo argumento passado para o callback são os dados lidos do arquivo. O código dentro dessa função acessará os dados do arquivo. O código fora dessa função não precisa dos dados do arquivo; portanto, pode ser executado enquanto aguarda os dados do arquivo.

A execução do código acima produziria o seguinte resultado:

Principais Características do JavaScript

Existem algumas características principais que influenciam o funcionamento do JavaScript assíncrono. Elas estão bem explicadas no vídeo abaixo:

Descrevi brevemente duas características importantes abaixo.

#1. Single-threaded

Ao contrário de outras linguagens que permitem ao programador usar várias threads, o JavaScript permite apenas o uso de uma thread. Uma thread é uma sequência de instruções que dependem logicamente umas das outras. Várias threads permitem que o programa execute uma thread diferente quando operações de bloqueio são encontradas.

No entanto, várias threads adicionam complexidade e dificultam a compreensão dos programas que as utilizam. Isso torna mais provável a introdução de bugs no código e tornaria a depuração difícil. O JavaScript foi feito single-threaded para simplificar. Como uma linguagem de thread único, ele depende de ser orientado a eventos para lidar com as operações de bloqueio de forma eficiente.

#2. Orientado a Eventos

O JavaScript também é orientado a eventos. Isso significa que alguns eventos acontecem durante o ciclo de vida de um programa JavaScript. Como programador, você pode anexar funções a esses eventos e, sempre que o evento ocorrer, a função anexada será chamada e executada.

Alguns eventos podem resultar da disponibilidade do resultado de uma operação de bloqueio. Nesse caso, a função associada é então chamada com o resultado.

Considerações ao Escrever JavaScript Assíncrono

Nesta última seção, mencionarei algumas coisas a serem consideradas ao escrever JavaScript assíncrono. Isso incluirá suporte ao navegador, práticas recomendadas e a importância do código assíncrono.

Suporte do Navegador

Esta é uma tabela que mostra o suporte de promessas em diferentes navegadores:

Fonte: caniuse.com

Esta é uma tabela que mostra o suporte de palavras-chave assíncronas em diferentes navegadores:

Fonte: caniuse.com

Melhores Práticas

  • Prefira sempre async/await, pois ajuda a escrever código mais limpo e fácil de entender.
  • Lide com erros usando blocos try/catch.
  • Use a palavra-chave async somente quando precisar aguardar o resultado de uma função.

Importância do Código Assíncrono

O código assíncrono permite escrever programas mais eficientes que utilizam apenas uma thread. Isso é importante, pois o JavaScript é usado para criar sites que realizam muitas operações assíncronas, como requisições de rede e leitura ou escrita de arquivos em disco. Essa eficiência permitiu que runtimes como o NodeJS crescessem em popularidade como o runtime preferido para servidores de aplicativos.

Considerações Finais

Este foi um artigo extenso, mas nele conseguimos abordar as diferenças entre funções assíncronas e síncronas. Também discutimos como usar código assíncrono utilizando promessas, palavras-chave async/await e callbacks.

Além disso, abordamos as principais características do JavaScript. Na última seção, concluímos com o suporte do navegador e as práticas recomendadas.

Em seguida, confira as perguntas frequentes sobre entrevistas do Node.js.