Escrevendo tarefas assíncronas em JavaScript moderno
Publicados: 2022-03-10O JavaScript tem duas características principais como linguagem de programação, ambas importantes para entender como nosso código funcionará. Primeiro é sua natureza síncrona , o que significa que o código será executado linha após linha, quase como você o lê e, segundo, que é single-threaded , apenas um comando está sendo executado a qualquer momento.
À medida que a linguagem evoluiu, novos artefatos apareceram na cena para permitir a execução assíncrona; os desenvolvedores tentaram diferentes abordagens ao resolver algoritmos e fluxos de dados mais complicados, o que levou ao surgimento de novas interfaces e padrões em torno deles.
Execução síncrona e o padrão do observador
Conforme mencionado na introdução, o JavaScript executa o código que você escreve linha por linha, na maioria das vezes. Mesmo em seus primeiros anos, a linguagem teve exceções a essa regra, embora fossem algumas e você já deve conhecê-las: solicitações HTTP, eventos DOM e intervalos de tempo.
const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })
Se adicionarmos um ouvinte de evento, por exemplo, o clique de um elemento e o usuário acionar essa interação, o mecanismo JavaScript enfileirará uma tarefa para o retorno de chamada do ouvinte de evento, mas continuará executando o que está presente em sua pilha atual. Após terminar com as chamadas ali presentes, ele agora executará o callback do ouvinte.
Esse comportamento é semelhante ao que ocorre com requisições e timers de rede, que foram os primeiros artefatos de acesso à execução assíncrona para desenvolvedores web.
Embora essas fossem exceções da execução síncrona comum em JavaScript, é crucial entender que a linguagem ainda é single-thread e, embora possa enfileirar tarefas, executá-las de forma assíncrona e depois voltar para a thread principal, ela só pode executar um pedaço de código de uma vez.
Por exemplo, vamos verificar uma solicitação de rede.
var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();
Quando o servidor volta, uma tarefa para o método atribuído a onreadystatechange
é enfileirada (a execução do código continua no thread principal).
Nota : Explicar como os mecanismos JavaScript enfileiram tarefas e tratam de threads de execução é um tópico complexo para abordar e provavelmente merece um artigo próprio. Ainda assim, recomendo assistir “What The Heck Is The Event Loop Anyway?” por Phillip Roberts para ajudá-lo a entender melhor.
Em cada caso mencionado, estamos respondendo a um evento externo. Um determinado intervalo de tempo atingido, uma ação do usuário ou uma resposta do servidor. Não conseguimos criar uma tarefa assíncrona per se, sempre observamos ocorrências fora do nosso alcance.
É por isso que o código moldado dessa maneira é chamado de Observer Pattern , que é melhor representado pela interface addEventListener
nesse caso. Logo, bibliotecas ou frameworks emissores de eventos que expõem esse padrão floresceram.
Node.js e emissores de eventos
Um bom exemplo é o Node.js, cuja página se descreve como “um tempo de execução JavaScript assíncrono orientado a eventos”, de modo que emissores de eventos e retornos de chamada eram cidadãos de primeira classe. Ele ainda tinha um construtor EventEmitter
já implementado.
const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');
Essa não era apenas a abordagem para execução assíncrona, mas um padrão e convenção central de seu ecossistema. O Node.js abriu uma nova era para escrever JavaScript em um ambiente diferente — mesmo fora da web. Como consequência, outras situações assíncronas foram possíveis, como criar novos diretórios ou escrever arquivos.
const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })
Você pode notar que os retornos de chamada recebem um error
como primeiro argumento, se um dado de resposta for esperado, ele será como um segundo argumento. Isso foi chamado de Error-first Callback Pattern , que se tornou uma convenção que autores e contribuidores adotaram para seus próprios pacotes e bibliotecas.
Promessas e a interminável cadeia de retorno de chamada
À medida que o desenvolvimento web enfrentava problemas mais complexos para resolver, surgiu a necessidade de melhores artefatos assíncronos. Se observarmos o último trecho de código, podemos ver um encadeamento de retorno de chamada repetido que não é dimensionado bem à medida que o número de tarefas aumenta.
Por exemplo, vamos adicionar apenas mais duas etapas, leitura de arquivos e pré-processamento de estilos.
const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })
Podemos ver como, à medida que o programa que estamos escrevendo se torna mais complexo, o código se torna mais difícil de seguir para o olho humano devido ao encadeamento de retorno de chamada múltiplo e tratamento de erros repetidos.
Promessas, invólucros e padrões de corrente
As Promises
não receberam muita atenção quando foram anunciadas pela primeira vez como a nova adição à linguagem JavaScript, elas não são um conceito novo, pois outras linguagens tiveram implementações semelhantes décadas antes. A verdade é que eles acabaram mudando muito a semântica e a estrutura da maioria dos projetos em que trabalhei desde o seu surgimento.
Promises
não apenas introduziu uma solução integrada para desenvolvedores escreverem código assíncrono, mas também abriu um novo estágio no desenvolvimento da Web, servindo como base de construção de novos recursos posteriores da especificação da Web, como fetch
.
Migrar um método de uma abordagem de retorno de chamada para uma baseada em promessa tornou-se cada vez mais comum em projetos (como bibliotecas e navegadores), e até o Node.js começou a migrar lentamente para eles.
Vamos, por exemplo, envolver o método readFile
do Node:
const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }
Aqui obscurecemos o retorno de chamada executando dentro de um construtor Promise, chamando resolve
quando o resultado do método é bem-sucedido e reject
quando o objeto de erro é definido.
Quando um método retorna um objeto Promise
podemos acompanhar sua resolução bem sucedida passando uma função para then
, seu argumento é o valor que a promessa foi resolvida, neste caso, data
.
Se um erro foi lançado durante o método, a função catch
será chamada, se presente.
Observação : Se você precisar entender mais a fundo como as Promises funcionam, recomendo o artigo “JavaScript Promises: An Introduction” de Jake Archibald, que ele escreveu no blog de desenvolvimento web do Google.
Agora podemos usar esses novos métodos e evitar cadeias de retorno de chamada.
asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))
Ter uma maneira nativa de criar tarefas assíncronas e uma interface clara para acompanhar seus possíveis resultados permitiu que a indústria saísse do Observer Pattern. Os baseados em promessas pareciam resolver o código ilegível e propenso a erros.
Como um melhor realce de sintaxe ou mensagens de erro mais claras ajudam durante a codificação, um código que é mais fácil de raciocinar se torna mais previsível para o desenvolvedor que o lê, com uma melhor imagem do caminho de execução mais fácil de detectar uma possível armadilha.
A adoção de Promises
foi tão global na comunidade que o Node.js lançou rapidamente versões integradas de seus métodos de E/S para retornar objetos Promise, como importar operações de arquivo de fs.promises
.
Ele até forneceu um promisify
de promessa para envolver qualquer função que seguisse o padrão de retorno de chamada Error-first e transformá-lo em um baseado em promessa.
Mas as Promessas ajudam em todos os casos?
Vamos reimaginar nossa tarefa de pré-processamento de estilo escrita com Promises.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))
Há uma clara redução de redundância no código, especialmente em torno do tratamento de erros, pois agora dependemos de catch
, mas Promises de alguma forma falhou em fornecer um recuo de código claro que se relaciona diretamente à concatenação de ações.
Na verdade, isso é alcançado na primeira instrução then
após readFile
ser chamado. O que acontece depois dessas linhas é a necessidade de criar um novo escopo onde podemos primeiro fazer o diretório, para depois escrever o resultado em um arquivo. Isso causa uma quebra no ritmo de indentação, não tornando fácil determinar a sequência de instruções à primeira vista.
Uma maneira de resolver isso é pré-cozinhar um método personalizado que trata disso e permite a concatenação correta do método, mas estaríamos introduzindo mais uma profundidade de complexidade em um código que já parece ter o que precisa para realizar a tarefa nós queremos.
Nota : Leve em conta que este é um programa de exemplo, e estamos no controle de alguns dos métodos e todos eles seguem uma convenção da indústria, mas isso nem sempre é o caso. Com concatenações mais complexas ou a introdução de uma biblioteca com uma forma diferente, nosso estilo de código pode quebrar facilmente.
Felizmente, a comunidade JavaScript aprendeu novamente com outras sintaxes de linguagem e adicionou uma notação que ajuda muito nesses casos em que a concatenação de tarefas assíncronas não é tão agradável ou simples de ler quanto o código síncrono.
Assíncrono e Aguardo
Um Promise
é definido como um valor não resolvido em tempo de execução e criar uma instância de um Promise
é uma chamada explícita desse artefato.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))
Dentro de um método assíncrono, podemos usar a palavra reservada await
para determinar a resolução de uma Promise
antes de continuar sua execução.
Vamos revisitar ou snippet de código usando essa sintaxe.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()
Nota : Observe que precisávamos mover todo o nosso código para um método porque não podemos usar await
fora do escopo de uma função assíncrona hoje.
Toda vez que um método assíncrono encontra uma instrução await
, ele para de ser executado até que o valor ou a promessa do procedimento seja resolvido.
Há uma consequência clara de usar a notação async/await, apesar de sua execução assíncrona, o código parece ser síncrono , o que é algo que nós desenvolvedores estamos mais acostumados a ver e raciocinar.
E quanto ao tratamento de erros? Para isso, usamos declarações que estão presentes há muito tempo na linguagem, try
e catch
.
const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()
Temos certeza de que qualquer erro gerado no processo será tratado pelo código dentro da instrução catch
. Temos um local centralizado que cuida do tratamento de erros, mas agora temos um código mais fácil de ler e seguir.
Ter ações consequentes que retornam valor não precisa ser armazenado em variáveis como mkdir
que não quebram o ritmo do código; também não há necessidade de criar um novo escopo para acessar o valor de result
em uma etapa posterior.
É seguro dizer que as promessas foram um artefato fundamental introduzido na linguagem, necessário para habilitar a notação async/await em JavaScript, que você pode usar em navegadores modernos e nas versões mais recentes do Node.js.
Nota : Recentemente no JSConf, Ryan Dahl, criador e primeiro contribuidor do Node, lamentou não aderir ao Promises em seu desenvolvimento inicial, principalmente porque o objetivo do Node era criar servidores orientados a eventos e gerenciamento de arquivos para os quais o padrão Observer servia melhor.
Conclusão
A introdução de Promises no mundo do desenvolvimento web veio para mudar a maneira como enfileiramos ações em nosso código e mudamos como raciocinamos sobre a execução de nosso código e como criamos bibliotecas e pacotes.
Mas afastar-se das cadeias de callback é mais difícil de resolver, acho que ter que passar um método para then
não nos ajudou a nos afastar da linha de pensamento depois de anos acostumados ao Observer Pattern e abordagens adotadas pelos principais fornecedores na comunidade como Node.js.
Como diz Nolan Lawson em seu excelente artigo sobre usos errados em concatenações de Promise, velhos hábitos de callback custam a morrer ! Mais tarde, ele explica como escapar de algumas dessas armadilhas.
Acredito que as Promises foram necessárias como um passo intermediário para permitir uma maneira natural de gerar tarefas assíncronas, mas não nos ajudaram muito a avançar em padrões de código melhores, às vezes você realmente precisa de uma sintaxe de linguagem mais adaptável e aprimorada.
À medida que tentamos resolver quebra-cabeças mais complexos usando JavaScript, vemos a necessidade de uma linguagem mais madura e experimentamos arquiteturas e padrões que não estávamos acostumados a ver na web antes.
“
Ainda não sabemos como será a especificação ECMAScript daqui a anos, pois estamos sempre estendendo a governança JavaScript para fora da web e tentando resolver quebra-cabeças mais complicados.
É difícil dizer agora o que exatamente precisaremos da linguagem para que alguns desses quebra-cabeças se transformem em programas mais simples, mas estou feliz com a forma como a web e o próprio JavaScript estão movendo as coisas, tentando se adaptar a desafios e novos ambientes. Sinto que agora o JavaScript é um lugar mais assíncrono e amigável do que quando comecei a escrever código em um navegador há mais de uma década.
Leitura adicional
- “Promessas JavaScript: uma introdução”, Jake Archibald
- “Promise Anti-Patterns”, uma documentação da biblioteca Bluebird
- “Temos um problema com promessas”, Nolan Lawson