Introduction pratique aux générateurs asynchrones en JavaScript

Les générateurs vous permettent de faire multitâche coopératif en vous permettant de mettre en pause et de reprendre les fonctions à volonté. En d’autres termes, ce sont des fonctions qui produisent une séquence itérable de valeurs à la demande, au lieu de les générer toutes en même temps. Ce genre de calcul est mémoire efficace car il ne nécessite pas que toutes les valeurs soient stockées en mémoire en même temps.
Imaginez que vous deviez regrouper un milliard de lignes à partir d’un fichier texte. Cette fonction :
- Charger le contenu du fichier en mémoire
- Analyser le contenu
- Agréger les lignes
- Renvoie le résultat agrégé
Cela utiliserait beaucoup de mémoire et vous devrez tout attendre au préalable. Un autre inconvénient est que vous risquez de manquer de mémoire et de planter. Maintenant, imaginez si vous pouviez traiter chaque ligne au fur et à mesure de son arrivée, sans attendre le chargement de l’intégralité du fichier. Vous pourriez peut-être le traiter en petits morceaux et envoyer des mises à jour de progression à l’utilisateur ou à la fonction appelante.
C’est ici fonctions du générateur entrent en jeu, et ils peuvent être synchrones ou asynchrones.
Un exemple simple
Commençons par une simple fonction de générateur synchrone qui produit des nombres de 1 à 3 :
function* countUp(max) {
console.log("Generator started!");
for (let i = 1; i <= max; i++) {
yield i;
}
console.log("Generator finished!");
}
const counter = countUp(3);
console.log(counter.next().value);
console.log(counter.next().value);
console.log(counter.next().value);
console.log(counter.next().value);
Le function* la syntaxe définit une fonction génératrice. Lorsque vous l’appelez, il n’exécute pas immédiatement le corps de la fonction. Au lieu de cela, il renvoie un objet générateur conforme à la fois aux itérable et protocoles d’itérateur. Cet objet est ensuite utilisé pour contrôler le processus de collecte des valeurs.
Le yield Le mot-clé est utilisé pour mettre en pause et restituer une valeur. Il attend, se souvenant de son emplacement exact jusqu’à ce que vous demandiez la valeur suivante. Quand tu appelles next()le générateur reprend l’exécution jusqu’à ce qu’il atteigne le prochain yield ou complète.
Le next() La méthode renvoie un objet avec deux propriétés : value (la valeur obtenue) et done (un booléen indiquant si le générateur est terminé). Cela le rend incroyablement efficace en termes de mémoire. Vous n’avez pas besoin de conserver en mémoire le milliard de nombres. Vous demandez simplement une ligne (ou un nombre limité de lignes), utilisez-la puis demandez la suivante.
L’exemple appelle manuellement next() pour obtenir chaque valeur. Cependant, vous pouvez également utiliser un for...of boucle pour parcourir automatiquement les valeurs :
async function iterateWithDelay(gen) {
for (const value of gen) {
console.log("Processing value:", value);
await new Promise((resolve) => setTimeout(resolve, 100));
console.log("Value processing complete:", value);
}
}
const delayedCounter = countUp(5);
iterateWithDelay(delayedCounter);
Dans cet exemple, le for...of la boucle appelle automatiquement next() sur le générateur jusqu’à ce que ce soit terminé. Vous ne verrez pas de undefined valeur à la fin car la boucle s’arrête lorsque done est true.
Générateurs asynchrones
Que se passe-t-il si la valeur suivante n’est pas encore prête ? Que se passe-t-il si vous devez attendre qu’il soit téléchargé ou extrait d’une base de données ? C’est là qu’interviennent les générateurs asynchrones. Ils sont le mélange parfait de :
- Générateurs synchrones: Ils vous donnent les valeurs une par une (
yieldouyield*). - Asynchrone/Attente: Ils peuvent attendre la fin des tâches asynchrones (comme une requête réseau) avant de continuer.
Pensez aux générateurs asynchrones, comme si vous attendiez la sortie d’un nouvel épisode d’une émission chaque semaine. Vous demandez le prochain épisode, mais vous devez l’attendre. Pendant que vous attendez, votre application n’est pas gelée ; il peut faire autre chose. Cela les rend idéaux pour travailler avec des fichiers ou d’autres sources de données asynchrones.
En raison de la nature asynchrone des flux de données, les générateurs asynchrones constituent un outil élégant pour transformer les flux de données (où les entrées et les sorties sont des flux). Ils vous permettent de créer une logique de traitement de flux modulaire, économe en mémoire et hautement lisible.
Je vais le démontrer avec un simple pipeline ETL (Extract, Transform, Load) qui traite un flux de données de vente à partir d’un fichier CSV.
Imaginez que vous ayez un fichier CSV contenant des données de ventes qui ressemble à ceci :
orderID,product,quantity,unitPrice,country
1,Laptop,2,1200,USA
2,Mouse,5,15,USA
3,Keyboard,10,25,Canada
4,Webcam,20,5,UK
5,Monitor,1,300,Canada
6,USB-Cable,8,5,USA
Votre tâche consiste à créer un pipeline qui peut :
Extrait: Lit le fichier CSV ligne par ligne sous forme de flux.
Transformer:
- Calcule un
totalPricepour chaque vente (quantity * unitPrice) - Enrichit les données en ajoutant un
regionbasé sur lecountry(simulant un appel API asynchrone) - Filtre les ventes dont le prix total est inférieur à 50 $
- Calcule un
Charger: Enregistre les données finales traitées sur la console.
L’ensemble de ce processus se déroulera un enregistrement à la fois, sans jamais charger l’ensemble de données complet en mémoire. Vous allez créer une fonction génératrice distincte pour chaque étape du pipeline. Cela le rend incroyablement modulaire : il est facile d’ajouter, de supprimer ou de réorganiser des étapes.
Tout d’abord, créons une fonction génératrice qui lit un fichier CSV ligne par ligne et rendements chaque ligne en tant qu’objet. Nous utiliserons le fs module pour lire le fichier sous forme de flux et le readline module pour le traiter ligne par ligne.
const fs = require("fs");
const readline = require("readline");
const { setTimeout } = require("timers/promises");
async function* processCsvStream(filePath) {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity,
});
const iterator = rl[Symbol.asyncIterator]();
const header = (await iterator.next()).value.split(",");
for await (const line of rl) {
const values = line.split(",");
const row = header.reduce((obj, key, index) => {
obj[key] = values[index];
return obj;
}, {});
yield row;
}
}
La première ligne du fichier CSV est traitée comme l’en-tête, utilisé pour créer un objet pour chaque ligne suivante. Chaque ligne est générée sous forme d’objet, ce qui nous permet de la traiter une par une. Le rl l’objet prend en charge le protocole itérable asynchrone, nous pouvons donc utiliser for await...of pour lire et traiter chaque ligne.
Les protocoles itérateur asynchrone et itérable sont similaires aux protocoles itérable et itérateur synchrones, sauf que chaque valeur de retour des appels aux méthodes itérateurs est enveloppée dans une promesse. Les objets supportant ces protocoles ont les méthodes
[Symbol.asyncIterator](),next(),return()etthrow()qui renvoie des promesses pour les objets avec les propriétésvalueetdone. Voir le Documentation MDN pour plus de détails.
Transformateurs (enrichissement et filtrage des données)
Ensuite, nous allons créer trois fonctions de transformateur. Le premier calculera le totalPricele second enrichira les données avec un region basé sur le countryet le troisième filtrera les ventes inférieures à un certain seuil.
async function* addTotalPrice(source) {
for await (const row of source) {
row.quantity = parseInt(row.quantity, 10);
row.unitPrice = parseFloat(row.unitPrice);
row.totalPrice = row.quantity * row.unitPrice;
yield row;
}
}
const getRegionForCountry = async (country) => {
const regions = {
USA: "North America",
Canada: "North America",
UK: "Europe",
};
await setTimeout(100);
return regions[country] || "Unknown";
};
async function* addRegion(source) {
for await (const row of source) {
row.region = await getRegionForCountry(row.country);
yield row;
}
}
async function* filterLowValueSales(source, minValue) {
for await (const row of source) {
if (row.totalPrice >= minValue) {
yield row;
}
}
}
Assembler et exécuter le pipeline
Enfin, nous pouvons assembler le pipeline en enchaînant ces fonctions de générateur asynchrone. Voici comment exécuter l’intégralité du processus ETL :
async function runPipeline() {
const csvContent =
"orderID,product,quantity,unitPrice,country\n1,Laptop,2,1200,USA\n2,Mouse,5,15,USA\n3,Keyboard,10,25,Canada\n4,Webcam,20,5,UK\n5,Monitor,1,300,Canada\n6,USB-Cable,8,5,USA";
fs.writeFileSync("./sales_data.csv", csvContent);
console.log("🚀 Starting ETL Pipeline...");
const rawRows = processCsvStream("./sales_data.csv");
const withTotalPrice = addTotalPrice(rawRows);
const withRegion = addRegion(withTotalPrice);
const finalDataStream = filterLowValueSales(withRegion, 50);
console.log("\n--- Processed High-Value Sales ---");
for await (const row of finalDataStream) {
console.log(row);
}
console.log("\n✅ Pipeline finished.");
}
runPipeline();
C’est ici que la magie opère ! Nous enchaînons les fonctions du générateur de manière claire et déclarative. Le for await...of la boucle à la fin extrait les données à travers tout le pipeline à la demande. Lorsque vous exécutez ce code, vous verrez les ventes traitées de grande valeur enregistrées dans la console, chacune enrichie d’un totalPrice et region. L’ensemble du processus est efficace et modulaire, démontrant la puissance des générateurs asynchrones dans la gestion des données en streaming. Le résultat ressemblera à ceci :
🚀 Starting ETL Pipeline...
--- Processed High-Value Sales ---
{
orderID: '1',
product: 'Laptop',
quantity: 2,
unitPrice: 1200,
country: 'USA',
totalPrice: 2400,
region: 'North America'
}
{
orderID: '2',
product: 'Mouse',
quantity: 5,
unitPrice: 15,
country: 'USA',
totalPrice: 75,
region: 'North America'
}
{
orderID: '3',
product: 'Keyboard',
quantity: 10,
unitPrice: 25,
country: 'Canada',
totalPrice: 250,
region: 'North America'
}
{
orderID: '4',
product: 'Webcam',
quantity: 20,
unitPrice: 5,
country: 'UK',
totalPrice: 100,
region: 'Europe'
}
{
orderID: '5',
product: 'Monitor',
quantity: 1,
unitPrice: 300,
country: 'Canada',
totalPrice: 300,
region: 'North America'
}
✅ Pipeline finished.
Vous devriez remarquer le léger retard lors de la diffusion et du traitement des lignes, simulant des scénarios réels dans lesquels les données pourraient provenir d’un réseau ou d’une lecture de fichier lente.
La partie amusante ? Vous pouvez facilement remplacer la source de données ou ajouter des étapes de transformation supplémentaires sans modifier la structure globale. Cela fait des générateurs asynchrones un outil puissant pour créer des pipelines de traitement de données flexibles et efficaces en JavaScript.
Conclusion
Les générateurs asynchrones sont un moyen puissant de traiter des séquences de données paresseusement (une pièce à la fois) et de manière asynchrone (attendre si nécessaire), conduisant à un code plus efficace, composable et lisible.
Tout au long de cet article, nous avons exploré comment ils résolvent les défis courants du développement Web moderne :
- Efficacité de la mémoire : En traitant les données à la demande, vous pouvez gérer des ensembles de données volumineux, voire des milliards de lignes, sans faire planter votre application en raison de contraintes de mémoire.
- Code asynchrone lisible : Les générateurs asynchrones vous permettent d’écrire une logique de traitement de flux complexe qui se lit comme un code simple et synchrone, vous libérant ainsi de l’enfer des rappels.
- Code modulaire et composable : L’exemple ETL a montré comment créer des pipelines de traitement de données propres et composables en chaînant des générateurs. Chaque étape est une unité autonome, ce qui rend votre code plus facile à tester, à maintenir et à raisonner.
- Évaluation paresseuse : Vous calculez uniquement ce dont vous avez besoin, quand vous en avez besoin.
Que vous lisiez des fichiers volumineux, consommais des données en temps réel à partir d’une API ou construisiez des workflows de transformation de données complexes, les générateurs asynchrones offrent une solution robuste et élégante. Ils vous encouragent à penser en termes de flux, ce qui constitue un paradigme fondamental pour créer des applications évolutives et résilientes.
Alors la prochaine fois que vous serez confronté à une tâche gourmande en données, envisagez de recourir à fonctions du générateur. Vous pourriez être surpris de voir à quel point votre code devient plus propre et plus efficace ! Plus besoin de se battre avec les tampons ou la contre-pression : il suffit de traiter les données de manière fluide et fluide.
Source link
