Itérateurs asynchrones et générateurs en JavaScript
Les boucles for...of parcourent les tableaux et autres itérables une valeur à la fois. for await...of fait de même quand chaque valeur met du temps à être produite (lecture de résultats d'API paginés, streaming d'un gros fichier, consommation d'événements à mesure qu'ils arrivent) sans que vous ayez à jongler à la main avec les promesses et l'état de la boucle. Le mécanisme derrière les deux, ce sont les générateurs, et une fois que vous savez en écrire un, toute une catégorie de code du type « traiter ce flux de choses » devient bien plus simple.
Les générateurs classiques : des fonctions interruptibles
Une fonction génératrice (function*) peut se mettre en pause avec yield et reprendre plus tard, en conservant son état local intact entre les reprises.
function* countTo(n) {
for (let i = 1; i <= n; i++) {
yield i;
}
}
for (const value of countTo(3)) {
console.log(value); // 1, 2, 3
}
Appeler countTo(3) n'exécute pas le corps de la fonction : cela renvoie un itérateur. Chaque appel à .next() (que for...of effectue en coulisses) exécute la fonction jusqu'au prochain yield, renvoie cette valeur et se met de nouveau en pause. C'est tout le mécanisme : une fonction qui peut être reprise, au lieu d'être seulement exécutée de bout en bout.
Les générateurs asynchrones : la même idée, avec de l'attente
Un générateur asynchrone (async function*) peut utiliser await avant de produire une valeur, de sorte que chaque valeur peut dépendre d'une opération asynchrone : requête réseau, requête de base de données, timer.
async function* fetchAllUsers() {
let page = 1;
while (true) {
const response = await fetch(`/api/users?page=${page}`);
const { users, hasMore } = await response.json();
yield* users; // yield each user individually, not the whole page
if (!hasMore) return;
page++;
}
}
for await (const user of fetchAllUsers()) {
console.log(user.name);
}
Le consommateur n'a jamais à savoir qu'une pagination est en cours. Il voit un seul flux d'objets user et les traite un par un ; le générateur cache entièrement la boucle de pagination, les appels fetch et la logique du « ai-je terminé ? ».
Pourquoi ne pas simplement renvoyer un tableau de promesses ?
L'alternative (récupérer toutes les pages d'avance, les passer à Promise.all, aplatir le résultat) fonctionne, mais elle oblige à garder tout le jeu de données en mémoire et à attendre toutes les pages avant de traiter le premier enregistrement. Un générateur asynchrone produit chaque valeur dès qu'elle est prête :
// Eager: waits for everything, holds it all in memory
async function fetchAllUsersEager() {
const pages = await Promise.all(
pageNumbers.map((p) => fetch(`/api/users?page=${p}`).then((r) => r.json())),
);
return pages.flatMap((p) => p.users);
}
// Lazy: starts producing values immediately, one page at a time
async function* fetchAllUsersLazy() {
// ...as above
}
Pour un jeu de 10 000 utilisateurs répartis sur 100 pages, la version anticipée ne peut pas commencer à traiter l'utilisateur n° 1 avant la fin des 100 requêtes. La version à générateur commence à traiter l'utilisateur n° 1 dès l'arrivée de la page 1, et, tout aussi important, un consommateur qui n'a besoin que des 20 premiers utilisateurs peut s'arrêter tôt sans que le générateur ait jamais récupéré les pages 2 à 100.
S'arrêter tôt arrête réellement le travail
Sortir d'une boucle for await...of appelle .return() sur le générateur, ce que le runtime utilise pour exécuter les blocs finally en attente : c'est ainsi que l'on libère proprement des ressources, et pas seulement que l'on cesse d'itérer.
async function* readLines(filePath) {
const fileHandle = await openFile(filePath);
try {
let line;
while ((line = await fileHandle.readLine()) !== null) {
yield line;
}
} finally {
console.log('closing file handle');
await fileHandle.close();
}
}
for await (const line of readLines('access.log')) {
if (line.includes('ERROR')) {
console.log('found it:', line);
break; // triggers the generator's finally block — file still gets closed
}
}
Sans le finally du générateur, une sortie anticipée laisserait le descripteur de fichier ouvert. C'est la même garantie qu'un try/finally ordinaire autour d'une boucle, étendue à une ressource dont la durée de vie couvre plusieurs points await.
Construire votre propre itérable asynchrone
Tout ce qui implémente Symbol.asyncIterator fonctionne avec for await...of : une fonction génératrice n'est pas indispensable, même si c'est généralement la façon la plus simple d'en écrire un. C'est utile pour envelopper des choses qui produisent des valeurs au fil du temps sans être elles-mêmes des générateurs, comme un flux de messages WebSocket :
function messagesFrom(socket) {
return {
[Symbol.asyncIterator]() {
const queue = [];
let resolveNext;
socket.addEventListener('message', (event) => {
if (resolveNext) {
resolveNext({ value: event.data, done: false });
resolveNext = null;
} else {
queue.push(event.data);
}
});
return {
next() {
if (queue.length > 0) {
return Promise.resolve({ value: queue.shift(), done: false });
}
return new Promise((resolve) => { resolveNext = resolve; });
},
};
},
};
}
for await (const message of messagesFrom(socket)) {
console.log('received:', message);
}
Cela transforme une API de type émetteur d'événements en quelque chose que l'on peut parcourir directement avec une boucle, au lieu d'enregistrer un callback et de gérer l'état à la main en dehors de la boucle.
Quand y recourir
Les générateurs asynchrones sont l'outil adapté dès que vous traitez une séquence de valeurs qui arrivent au fil du temps et que vous voulez que le consommateur ignore comment elles arrivent : pagination, réponses en streaming, lectures de fichiers par morceaux, flux de messages WebSocket/SSE. Si vous avez déjà tout le jeu de données en main (par exemple le résultat d'un seul appel fetch), un simple tableau et for...of suffisent, et il n'y a aucune raison d'introduire un générateur. Recourez à async function* précisément quand « produire la valeur suivante » exige en soi d'attendre.