Async JavaScript - 13 - Generators et itérateurs asynchrones

function*, yield, itérateurs paresseux, async generators et for-await-of pour gerer des flux de donnees infinis ou pagines.

Async JavaScript - Generators et itérateurs asynchrones

Ce que tu vas apprendre

  • La syntaxe function* et yield, et pourquoi ca change tout
  • Le protocole itérable/itérateur en détail
  • L'évaluation paresseuse (lazy évaluation) et ses avantages
  • async function* et for-await-of pour les flux asynchrones
  • Un cas concret : paginer une API avec un async generator
  • Les sequences infinies sans exploser la mémoire
  • Le generator comme coroutine (le secret de Redux-Saga)

Prerequisites


La première fois que j'ai vu function* dans du code, j'ai cru a une faute de frappe. Un asterisque colle a function, un yield qui sort de nulle part, et un objet avec une méthode next() en retour. Ca ressemblait a du JavaScript écrit par quelqu'un qui venait de découvrir les coroutines en Python et qui voulait absolument les reproduire. Spoiler : c'est exactement ce qui s'est passe.

function* et yield : les bases

Un generator est une fonction qui peut se mettre en pause et reprendre son exécution. Au lieu de return, on utilise yield pour produire des valeurs une par une.

javascriptfunction* compteur() {
  yield 1;
  yield 2;
  yield 3;
}

const gen = compteur();
console.log(gen.next()); // { value: 1, done: false }
console.log(gen.next()); // { value: 2, done: false }
console.log(gen.next()); // { value: 3, done: false }
console.log(gen.next()); // { value: undefined, done: true }

Appeler compteur() ne l'exécuté pas. Ca retourne un objet itérateur. Chaque appel a next() exécuté le code jusqu'au prochain yield, puis suspend la fonction. L'état local (variables, position dans le code) est preserve entre les appels.

C'est fondamentalement différent d'une fonction classique. Une fonction normale s'exécuté du début a la fin d'un coup. Un generator, lui, c'est une conversation : tu demandes, il répond, tu redemandes.

Le protocole itérateur

Les generators implementent deux protocoles définis par la spec ES2015 :

javascript// Protocole Iterable : l'objet a une methode [Symbol.iterator]
// Protocole Iterator : l'objet a une methode next() qui retourne { value, done }

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

// Ca marche avec for...of
for (const n of fibonacci()) {
  if (n > 100) break;
  console.log(n); // 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
}

// Ca marche avec le spread (attention aux sequences infinies !)
const dixPremiers = [];
for (const n of fibonacci()) {
  if (dixPremiers.length >= 10) break;
  dixPremiers.push(n);
}

Le while (true) ne bloque pas. Comme le generator se met en pause a chaque yield, il ne calcule la valeur suivante que quand on la demande. C'est ca, l'évaluation paresseuse.

Lazy évaluation en pratique

L'avantage concret : tu peux travailler avec des sequences énormes sans tout charger en mémoire.

javascriptfunction* lireFichierLigneParLigne(contenu) {
  const lignes = contenu.split("\n");
  for (const ligne of lignes) {
    yield ligne;
  }
}

function* filtrer(source, predicat) {
  for (const item of source) {
    if (predicat(item)) yield item;
  }
}

function* transformer(source, fn) {
  for (const item of source) {
    yield fn(item);
  }
}

// Pipeline paresseux : rien ne s'execute tant qu'on ne consomme pas
const pipeline = transformer(
  filtrer(
    lireFichierLigneParLigne(grosContenu),
    ligne => ligne.includes("ERROR")
  ),
  ligne => ligne.toUpperCase()
);

// Seules les lignes necessaires sont traitees
for (const ligne of pipeline) {
  console.log(ligne);
}

Compare ca avec la version classique contenu.split("\n").filter(...).map(...) qui créé trois tableaux intermediaires complets en mémoire. Le generator ne materialise qu'une ligne a la fois.

Communication bidirectionnelle

yield n'est pas qu'une sortie. C'est aussi une entree. Tu peux envoyer des valeurs au generator via next(valeur).

javascriptfunction* conversation() {
  const question = yield "Salut, comment tu t'appelles ?";
  const reponse = yield `Enchante ${question}, tu fais quoi ?`;
  yield `${reponse}, pas mal !`;
}

const chat = conversation();
console.log(chat.next().value);         // "Salut, comment tu t'appelles ?"
console.log(chat.next("Alice").value);  // "Enchante Alice, tu fais quoi ?"
console.log(chat.next("Du JS").value);  // "Du JS, pas mal !"

Le premier next() n'envoie rien (la valeur est ignoree). A partir du deuxieme, la valeur passee a next() devient la valeur de retour du yield précédent. Ca parait tordu au début, mais c'est exactement comme ca que Redux-Saga orchestre des effets asynchrones.

Async generators et for-await-of

Les generators synchrones produisent des valeurs immédiatement. Les async generators produisent des Promises. C'est la combinaison parfaite pour les flux de donnees asynchrones.

javascriptasync function* fetchPages(baseUrl) {
  let page = 1;
  let hasMore = true;

  while (hasMore) {
    const response = await fetch(`${baseUrl}?page=${page}`);
    const data = await response.json();

    yield data.items;

    hasMore = data.hasNextPage;
    page++;
  }
}

// Consommer avec for-await-of
for await (const items of fetchPages("https://api.example.com/users")) {
  for (const user of items) {
    afficherUtilisateur(user);
  }
}

Le for-await-of attend automatiquement chaque Promise produite par le generator. Pas besoin de .then() ou d'await explicite sur chaque itération. Le code lit comme du synchrone, mais il pagine une API de facon paresseuse.

Cas concret : pagination complète avec contrôle

En production, tu veux souvent plus de contrôle. Voici un pattern robuste avec AbortController :

javascriptasync function* paginate(url, options = {}) {
  const { signal, maxPages = Infinity } = options;
  let cursor = null;
  let pageCount = 0;

  while (pageCount < maxPages) {
    signal?.throwIfAborted();

    const params = new URLSearchParams();
    if (cursor) params.set("cursor", cursor);

    const response = await fetch(`${url}?${params}`, { signal });
    if (!response.ok) {
      throw new Error(`HTTP ${response.status} sur ${url}`);
    }

    const data = await response.json();
    yield data.results;

    cursor = data.nextCursor;
    pageCount++;

    if (!cursor) return; // Plus de pages
  }
}

// Utilisation
const controller = new AbortController();

try {
  for await (const batch of paginate("/api/products", {
    signal: controller.signal,
    maxPages: 10,
  })) {
    await traiterBatch(batch);
  }
} catch (err) {
  if (err.name !== "AbortError") throw err;
}

Sequences infinies sans exploser la mémoire

Les generators sont parfaits pour les sequences potentiellement infinies. Comme je l'ai montre avec Fibonacci plus haut, le while (true) est parfaitement acceptable parce que le generator ne calcule qu'a la demande.

javascriptfunction* entiers(debut = 0) {
  let n = debut;
  while (true) {
    yield n++;
  }
}

function* prendre(source, n) {
  let count = 0;
  for (const item of source) {
    if (count >= n) return;
    yield item;
    count++;
  }
}

// Les 5 carres parfaits a partir de 100
const carres = prendre(
  transformer(
    filtrer(entiers(10), n => Math.sqrt(n) === Math.floor(Math.sqrt(n))),
    n => n * n
  ),
  5
);

console.log([...carres]); // [100, 121, 144, 169, 196]

Le generator comme coroutine

Historiquement, avant async/await, les generators etaient utilises pour simuler le code asynchrone. La librairie co de TJ Holowaychuk a popularise cette technique.

javascript// Ce que faisait co (simplifie)
function run(generatorFn) {
  const gen = generatorFn();

  function step(valeur) {
    const result = gen.next(valeur);
    if (result.done) return Promise.resolve(result.value);

    return Promise.resolve(result.value).then(
      val => step(val),
      err => gen.throw(err)
    );
  }

  return step();
}

// On pouvait ecrire du code "async" avec yield
run(function* () {
  const user = yield fetch("/api/user").then(r => r.json());
  const posts = yield fetch(`/api/posts/${user.id}`).then(r => r.json());
  console.log(posts);
});

Redux-Saga utilise encore ce pattern en 2026. Les generators comme coroutines permettent de tester les effets asynchrones sans les exécuter : tu verifies juste que le generator yield les bons descripteurs d'effets. C'est elegant, meme si ca ajoute une couche d'abstraction non negligeable.

Résumé

Les generators (function* / yield) permettent de créer des fonctions suspendables qui produisent des valeurs a la demande. Le protocole itérateur les rend compatibles avec for...of et le spread. L'évaluation paresseuse évité de materialiser des collections entières en mémoire. Les async generators (async function* / for-await-of) etendent ce concept aux flux asynchrones, ce qui est parfait pour la pagination d'API ou le traitement de streams. La communication bidirectionnelle via next(valeur) ouvre la porte aux coroutines, technique encore utilisee par Redux-Saga.

Retrouve d'autres articles sur l'asynchrone et JavaScript sur paltemps.fr.


Navigation : Precedent : 12 - Concurrence et parallélisme | Suivant : 14 - requestAnimationFrame et requestIdleCallback


Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.