O que são e para que servem os Async Generators?

Ei você desenvolvedor, provavelmente já lidou com fluxos de dados que não estão disponíveis de imediato – pense em dados vindos de uma API, downloads em partes ou mesmo leituras de arquivos grandes. Nesses cenários, é fundamental ter uma abordagem elegante e eficiente para processar esses dados conforme eles chegam. É aqui que entram os async iterators. Mas, antes de mergulhar nesse conceito, vamos entender primeiro como funcionam os iterators síncronos.

O que são Iterators?

Um iterator é um objeto que define uma sequência e sabe como acessar os seus elementos, um de cada vez, através de um método chamado next(). Em JavaScript, para tornar um objeto iterável – ou seja, que possa ser percorrido por um loop for..of – definimos um método especial chamado Symbol.iterator.

Imagine que temos um objeto range que representa um intervalo de números:

const range = {
    from: 1,
    to: 5,

    [Symbol.iterator]() {
        return {
            current: this.from,
            last: this.to,
            next() {
                if (this.current <= this.last) {
                    return { done: false, value: this.current++ };
                } else {
                    return { done: true };
                }
            }
        };
    }
};

for (let value of range) {
    console.log(value); // Imprime: 1, 2, 3, 4, 5
}

Nesse exemplo, cada chamada ao método next() retorna um objeto com a estrutura { value, done }. Enquanto done for false, o loop continua, e assim o for..of percorre todos os valores do range.

Limitações dos Iterators

Embora os iterators síncronos sejam perfeitos para sequências de dados disponíveis de imediato, eles não servem quando os valores só são conhecidos ou disponibilizados de forma assíncrona – por exemplo, quando cada valor depende de uma requisição de rede ou de um atraso (setTimeout).

Imagine que você precisa iterar sobre uma sequência que depende de uma operação que leva algum tempo para concluir (como uma chamada a uma API). Usar um iterator síncrono nesse cenário não funcionará, pois o método next() não pode aguardar a resolução de uma Promise ou um delay.

Generators: Simplificando a Criação de Iterators

Implementar manualmente um iterator pode ficar verboso. Os generators simplificam esse processo. Eles são funções especiais que podem "pausar" sua execução usando a palavra-chave yield, retornando valores sob demanda e mantendo seu estado interno entre as chamadas.

Um generator é definido com function* e pode ser utilizado para criar iterators de forma concisa:

function* generateSequence(start, end) {
    for (let i = start; i <= end; i++) {
        yield i;
    }
}

for (let value of generateSequence(1, 5)) {
    console.log(value); // Imprime: 1, 2, 3, 4, 5
}

Entrando nos Async Iterators

Os async iterators foram criados exatamente para lidar com cenários onde os valores são obtidos de forma assíncrona. Eles funcionam de maneira similar aos iterators síncronos, mas com algumas diferenças importantes:

  1. Método de iteração: Em vez de implementar Symbol.iterator, usamos Symbol.asyncIterator.
  2. Método next(): Em um async iterator, next() retorna uma Promise que se resolve com um objeto { value, done }.
  3. Loop de iteração: Para consumir os valores, usamos o for await...of, que aguarda cada Promise se resolver antes de prosseguir.

Vamos refatorar o exemplo do range para que os valores sejam retornados com um delay de 1 segundo:

const asyncRange = {
    from: 1,
    to: 5,

    [Symbol.asyncIterator]() {
        return {
            current: this.from,
            last: this.to,
            async next() {
                // Simula um delay (como se estivéssemos esperando uma resposta de rede)
                await new Promise(resolve => setTimeout(resolve, 1000));
	
                if (this.current <= this.last) {
                    return { done: false, value: this.current++ };
                } else {
                    return { done: true };
                }
            }
        };
    }
};

(async () => {
    for await (let value of asyncRange) {
        console.log(value); // Imprime: 1, 2, 3, 4, 5 (com 1 segundo de intervalo)
    }
})();

Nesse código, cada iteração aguarda a resolução do delay antes de prosseguir, permitindo que os dados sejam processados conforme chegam.

Por que usar Async Iterators?

Agora que já entendemos a diferença entre iterators síncronos e async iterators, vamos explorar as vantagens de utilizar os async iterators no dia a dia:

1. Processamento Sob Demanda

Em muitos casos, os dados chegam de forma fragmentada. Em vez de esperar que todos os dados estejam disponíveis, o async iterator permite processar cada valor assim que ele chega. Isso é especialmente útil para:

  • Requisições paginadas: Processar páginas de resultados de uma API sem precisar carregar tudo de uma vez.
  • Streams de dados: Como o download ou upload de arquivos, onde os dados são recebidos em partes.

2. Integração com APIs Assíncronas

Muitas APIs modernas (como a API Fetch ou a API Streams) trabalham com dados de forma assíncrona. Async iterators permitem integrar esses fluxos de dados de maneira natural e consistente, evitando soluções "gambiarras" ou acoplamento excessivo de código.

3. Melhoria na Performance e Eficiência

Ao processar os dados sob demanda, evitamos carregar grandes volumes de dados na memória de uma só vez. Isso pode levar a melhorias significativas em termos de performance, especialmente quando lidamos com grandes fluxos de informação.

Exemplo prático: Paginação de dados com Async Iterators

Um exemplo clássico do uso de async iterators é a paginação de dados. Suponha que precisamos obter commits de um repositório no GitHub, onde cada requisição retorna uma página com 30 commits e fornece um link para a próxima página.

Com um async iterator, podemos criar uma função fetchCommits(repo) que cuida de toda a paginação e nos permite iterar sobre os commits de forma simples:

async function* fetchCommits(repo) {
    let url = `https://api.github.com/repos/${repo}/commits`;

    while (url) {
        const response = await fetch(url, {
            headers: { 'User-Agent': 'Our App' }
        });
        const commits = await response.json();

        // Extrai o link para a próxima página, se houver
        let nextPageMatch = response.headers.get('Link')?.match(/<([^>]+)>;\s*rel="next"/);
        url = nextPageMatch ? nextPageMatch[1] : null;

        for (let commit of commits) {
            yield commit;
        }
    }
}

(async () => {
    for await (let commit of fetchCommits("username/repository")) {
        // Processa cada commit à medida que ele chega
        console.log(commit);
    }
})();

Esse padrão permite que você processe os commits de forma contínua e eficiente, sem precisar se preocupar com a lógica de paginação em cada chamada.

Considerações finais

Os async iterators são uma ferramenta poderosa para trabalhar com dados que chegam de forma assíncrona. Ao permitir a iteração sob demanda, eles facilitam a integração com APIs modernas, tornam o código mais limpo e expressivo e ajudam a evitar problemas de performance ao lidar com grandes volumes de dados.

Em resumo, ao optar por async iterators você ganha:

  • Controle: Processamento dos dados conforme sua disponibilidade.
  • Legibilidade: Uso do for await...of para um código mais intuitivo.
  • Eficiência: Evita carregamento desnecessário de grandes volumes de dados na memória.
  • Integração: Facilidade para lidar com APIs assíncronas e fluxos de dados em tempo real.

Até a próxima!