Usando Generadores Asíncronos en JavaScript

Async/Await es una de las características de ECMAScript 2017 que más he usado junto a Object.entries. Nos permite escribir de forma más simple código asíncrono, se lee como sincrónico pero se ejecuta asíncrono. Veamos un ejemplo rápido

async function main() {
  setLoading(true);
  try {
    const response = await fetch("/api/users");
    if (!response.ok) throw new Error("Response not OK");
    return await response.json();
  } catch (error) {
    if (error.message !== "Response not OK") throw error;
    return { error: { message: error.message, code: "not_ok" } };
  } finally {
    setLoading(false);
  }
}

Esta pequeña función usando promesas se podría escribir de esta forma.

function main() {
  setLoading(true);
  return fetch("/api/users")
    .then(response => {
      if (!response.ok) throw new Error("Response not OK");
      setLoading(false);
      return response.json();
    })
    .catch(error => {
      setLoading(false);
      if (error.message !== "Response not OK") throw error;
      return { error: { message: error.message, code: "not_ok" } };
    });
}

Aunque casi tan corta como nuestra función asíncrona es un poco más compleja, por ejemplo necesitamos ejecutar setLoading(false) en dos lugares para ocultar un posible spinner.

Resulta que Async/Await está construido sobre dos funcionalidades añadidas en ECMAScript 2015, Promesas y Generadores, ya vimos un ejemplo de Promesas, veamos que son Generadores.

Generadores

El objecto Generador es retornado por generator function y conforma tanto un protocolo iterable como un protocolo iterador

Esa es la descripción en español según MDN, lo cual no muy fácil de entender la verdad, vamos a ver un ejemplo, usemos un generadores para calcular los números de la secuencia fibonacci.

function* fibonacci() {
  let [a, b] = [0, 1];
  while (true) {
    yield a;
    [a, b] = [b, a + b];
  }
}

const fib = fibonacci();

Array.from({ length: 10 }).forEach(() => {
  console.log(fib.next().value);
});

Como se ve arriba un generador es una función que se define como function*, el asterisco la convierte en un generador, dentro de esta función tenemos acceso a la palabra clave yield que nos permite devolver un valor (lo que coloquemos a la derecha de yield) pero sin terminar la ejecución de nuestro generador, en vez de esto el generador se pausa hasta que ejecutemos el método next que nos va a permitir seguir con el código hasta el siguiente yield.

Si vemos debajo ejecutamos nuestro generador fibonacci() y guardamos el resultado, la constante fib es un objeto Generator que posee el método next con el cual podemos pedirle un valor al generador. Algo importante es que hasta que no ejecutemos este método el generador se mantiene suspendido y no hace absolutamente nada, esto nos permite tener un ciclo infinito dentro del generador sin problemas.

Después vamos a crear un array de 10 elementos y vamos a iterar por este array y hacer un console.log del valor que devuelve fib.next(), si vemos para acceder al valor usamos la propiedad value, esto es porque next nos devuelve un objeto con la siguiente sintaxis.

{
  value: 1,
  done: false
}

La propiedad value como dijimos es el valor devuelto por nuestro generador al hacer yield mientras que la propiedad done nos indica si el generador ya terminó de ejecutarse, en nuestro caso nunca va a pasar que se termine pues usa un cicle infinito, pero podría pasar que solo se ejecuta una cierta cantidad de yield dentro del generador y eventualmente se acabe como una función normal.

¿Por qué es útil? En ECMAScript 2018 se incluyó a JS los Async Generators. Estos nos permiten crear generadores que sean asíncrono, combinando así Async/Await con yield.

Generadores Asíncronos

Como hicimos antes vamos a ver un ejemplo de uso para entender un poco como funciona.

const createPromise = () => {
  let resolver;
  let rejecter;
  const promise = new Promise((resolve, reject) => {
    resolver = resolve;
    rejecter = reject;
  });
  return { resolver, promise, rejecter };
};

async function* createQueue(callback) {
  while (true) {
    const { resolver, promise } = createPromise();
    const data = yield resolver;
    await Promise.all([callback(data), promise]);
  }
}

La función createPromise simplemente nos permite de forma fácil crear una promesa y acceder tanto a esta como a su resolver y su rejecter. Lo importante acá es nuestro generador asíncrono createQueue. Este va a recibir al momento de ejecutarse una función que llamamos callback y en cada iteración de nuestro ciclo infinito va a crear una promesa y hacer yield del resolver de esta, luego vemos que asigna el resultado de yield a una constante llamada data, esto funciona porque si a la funciónnextle pasamos un valor este es recibido por un generador (tanto síncrono como asíncrono) como resultado delyield`, así podemos pasar valores entre el generador y quién usa el generador.

Los siguiente que hacemos una vez tenemos data es hacer await de ejecutar callback pasándole data y de la promesa. ¿Cómo sirve esto? Cada vez que le pidamos un valor a nuestra cola este nos va a devolver un resolver, nosotros además podemos pasarle información que el generador va a pasar al callback, cuando tanto nuestro callback complete su ejecución como nosotros ejecutemos el resolver recién ahí nuestro generador asíncrono va a ejecutar la siguiente iteración del while.

Veamos como se usa en código.

const sleep = ms => new Promise(r => setTimeout(r, ms));

const queue = createQueue(async data => {
  await sleep(1000); // hacemos que nuestro callback tarde 1s en terminar de ejecutarse
  console.log(data); // después hacemos log de data
});

(await queue.next()).value();

const { value: resolver1 } = await queue.next("Hello");
const { value: resolver2 } = await queue.next("World");

await sleep(500);
resolver1();
await sleep(2000);
resolver2();

Vayamos línea por línea, al principio creamos una pequeña función que recibe un tiempo en mili segundos (ms) y devuelve una promesa que se complete solo después de que este tiempo pase.

Luego vamos a crear nuestra cola, el callback va a ser una función asíncrona que cada vez que se ejecute va a dormir por 1 segundo y después va a hacer log de data, esto nos sirve en nuestro ejemplo para simular que estamos haciendo lógica.

La siguiente línea es probablemente la más rara, lo que hace es esperar (await) a que queue.next() devuelva un valor y acceder a este value y ejecutarlo (el valor es resolver). Esto es necesario porque la primera vez que ejecutamos next prendemos nuestro generador y lo ponemos a funcionar, pero simplemente llega hasta el primer yield y no hace nada, necesitamos completar una vuelta para que podamos empezar a pasar valores al generador asíncrono usando next.

Eso es exactamente lo que hacemos en las siguientes líneas, ejecutamos dos veces seguidas next pasando diferentes valores y esperando a que nos responda con un value los cuales re nombramos como resolver1 y resolver2. Luego esperamos 500ms y ejecutamos el primer resolver, dos segundos después ejecutamos el segundo resolver.

Si copias y pegas el código de arriba en la consola del navegador se puede observar como aparecen los mensajes Hello y World en diferentes tiempo.

¿Para qué más sirve?

Los generadores asíncronos nos pueden servir para muchas cosas, básicamente son la base para implementar Streams, por ejemplo un generador asíncrono podría en Node.js ir leyendo un archivo desde el sistema de archivos e ir pasando partes de información de a poco y solo leer el siguiente cuando manualmente ejecutemos next. Otro caso de uso similar a mantener la paginación de un API que en Frontend puede ser un caso interesante.

Vamos a hacer este generador de paginación, para esto vamos a usar un API de pruebas llamada JSONPlacerholder API, más específicamente vamos a traernos el recurso de comentarios usando la URL https://jsonplaceholder.typicode.com/comments?_page=1 que nos devuelve la página 1 y así podemos pedir las siguiente páginas incrementando dicho número.

Programemos ahora nuestro generador asíncrono.

async function* fetchPaginated(url, pageQuery, initialPage = 1) {
  let page = initialPage;
  while (true) {
    const response = await fetch(`${url}?${pageQuery}=${page}`);
    if (!response.ok) return { error: await response.text() };
    const data = await response.json();
    if (data.length === 0) return data;
    else yield data;
    page += 1;
  }
}

for await (let data of fetchPaginated(
  "https://jsonplaceholder.typicode.com/comments",
  "_page"
)) {
  console.log(data);
}

Si ejecutamos nuestro código en la consola del navegador vamos a ver como de a poco va haciendo log de los comentarios de cada una de las páginas y termina al llegar a la página 50 donde inmediatamente para.

Lo que acabamos de hacer es que al ejecutar fetchPaginated le pasamos la URL del recurso a hacer fetch y la variable para la página que debemos agregar al query string de nuestra URL, la página inicial la dejamos usar el valor por defecto que es 1. Esto nos devuelve una instancia de nuestro generador que va a en cada iteración hacer fetch de la página, si la respuesta es un error va a hacer return de un objeto con el mensaje de error, si no va a obtener la información como JSON y se va a fijar si la data (un array de comentarios) está vació para hacer return o sino hacer yield de data, por último suma 1 a la página actual.

En un generador return funciona al igual que en una función, en el momento en que se ejecuta el generador se termina inmediatamente y ya no sigue procesando valores. Esto nos permite matar el generador cuando hay un error o ya no hay más páginas a las cuales hacerle fetch.

Fuera de nuestro generador hacemos un for..of asíncrono, agregando la palabra clave await. Esto nos permite iterar sobre un generador asíncrono y guardamos value como la variable data la cual luego mostramos en consola.

Podríamos entonces usar nuestro nuevo fetchPaginated para traerse la primer página de comentarios y que cuando el usuario llegue al final del scroll o haga click en un botón se le pida la siguiente página usando next y así hasta terminar.

Palabras finales

Aunque raros de usar, los generadores y más aún los generadores asíncronos pueden ser muy útiles para ejecutar lógica asíncrona repetitiva de forma más simple.