Une rapide intro

Bienvenue dans le monde merveilleux des fonctions de générateur et des générateurs asynchrones en JavaScript.

Vous êtes sur le point d’apprendre l’une des fonctions les plus exotiques (selon la majorité des développeurs) de JavaScript.

Commençons !

Les fonctions génératrices en JavaScript

Qu’est-ce qu’une fonction génératrice ?

Une fonction génératrice (ECMAScript 2015) en JavaScript est un type spécial de fonction synchrone qui est capable d’arrêter et de reprendre son exécution à volonté.

Contrairement aux fonctions JavaScript ordinaires, qui sont de type feu et oublie, les fonctions de générateur ont également la capacité de :

  • communiquer avec l’appelant sur un canal bidirectionnel.
  • conserver leur contexte d’exécution (scope) sur les appels suivants.

On peut considérer les fonctions génératrices comme des closures sur des stéroïdes, mais les similitudes s’arrêtent là !

Votre première fonction génératrice

Pour créer une fonction génératrice, on met une étoile * après le mot-clé function:

function* generate() {//}

Note : les fonctions génératrices peuvent aussi prendre la forme d’une méthode de classe, ou d’une expression de fonction. En revanche, les fonctions génératrices de flèches ne sont pas autorisées.

Une fois à l’intérieur de la fonction, nous pouvons utiliser yield pour mettre en pause l’exécution :

function* generate() { yield 33; yield 99;}

yield met en pause l’exécution et renvoie un objet dit Generator à l’appelant. Cet objet est à la fois un itérable, et un itérateur.

Démystifions ces concepts.

Itérables et itérateurs

Un objet itérable en JavaScript est un objet implémentant Symbol.iterator. Voici un exemple minimal d’itérable:

const iterable = { : function() { /* TODO: iterator */ }};

Une fois que nous avons cet objet itérable, nous assignons une fonction à pour retourner un objet itérateur.

Cela semble beaucoup de théorie, mais en pratique, un itérable est un objet sur lequel nous pouvons boucler avec for...of (ECMAScript 2015). Nous en reparlerons dans une minute.

Vous devriez déjà connaître quelques itérables en JavaScript : les tableaux et les chaînes de caractères par exemple sont des itérables :

for (const char of "hello") { console.log(char);}

D’autres itérables connus sont Map et Set. for...of vient également pratique pour l’itération sur les valeurs d’un objet:

const person = { name: "Juliana", surname: "Crain", age: 32};for (const value of Object.values(person)) { console.log(value);}

Juste se rappeler que toute propriété marquée comme enumerable: false n’apparaîtra pas dans l’itération:

const person = { name: "Juliana", surname: "Crain", age: 32};Object.defineProperty(person, "city", { enumerable: false, value: "London"});for (const value of Object.values(person)) { console.log(value);}// Juliana// Crain// 32

Maintenant, le problème avec notre itérable personnalisé est qu’il ne peut pas aller loin seul sans un itérateur.

Les itérateurs sont aussi des objets, mais ils doivent se conformer au protocole des itérateurs. En bref, les itérateurs doivent avoir au moins une méthode next().

next() doit retourner un autre objet, dont les propriétés sont value et done.

La logique de notre méthode next() doit obéir aux règles suivantes :

  • nous retournons un objet avec done: false pour continuer l’itération.
  • nous retournons un objet avec done: true pour arrêter l’itération.

value au lieu de cela, devrait contenir le résultat que nous voulons produire pour le consommateur.

Détendons notre exemple en ajoutant l’itérateur:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Nous avons ici un itérable, qui implémente correctement Symbol.iterator. Nous avons également un itérateur, qui renvoie :

  • un objet dont la forme est { value: x, done: false} jusqu’à ce que count atteigne 3.
  • un objet dont la forme est { value: x, done: true} lorsque count atteint 3.

Cet itérable minimal est prêt à être itéré avec for...of:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};for (const iterableElement of iterable) { console.log(iterableElement);}

Le résultat sera:

123

Lorsque vous connaîtrez mieux les fonctions génératrices, vous verrez que les itérateurs sont la base des objets générateurs.

N’oubliez pas :

  • les itérables sont les objets sur lesquels on itère.
  • les itérateurs sont les choses qui rendent l’itérable…. « bouclable » sur.

En fin de compte, quel est l’intérêt d’un itérable ?

Nous avons maintenant une boucle standard, fiable for...of qui fonctionne virtuellement pour presque n’importe quelle structure de données, personnalisée ou native, en JavaScript.

Pour utiliser for...ofsur votre structure de données personnalisée, vous devez :

  • implémenter Symbol.iterator.
  • fournir un objet itérable.

C’est tout !

Ressources supplémentaires :

  • Les itérateurs vont itérer par Jake Archibald.

Les itérables sont étalables et déstructurables

En plus de for...of, on peut aussi utiliser l’étalement et la déstructuration sur des itérables finis.

Considérons à nouveau l’exemple précédent:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Pour extraire toutes les valeurs, nous pouvons étaler l’itérable dans un tableau:

const values = ;console.log(values); // 

Pour extraire juste un couple de valeurs à la place, nous pouvons déstructurer l’itérable en tableau. Ici, nous obtenons la première et la deuxième valeur de l’itérable:

const = iterable;console.log(first); // 1console.log(second); // 2

Ici, au lieu de cela, nous obtenons la première, et la troisième:

const = iterable;console.log(first); // 1console.log(third); // 3

Tournons maintenant à nouveau notre attention sur les fonctions de générateur.

Extraire des données d’une fonction de générateur

Une fois qu’une fonction de générateur est en place, nous pouvons commencer à interagir avec elle. Cette interaction consiste à :

  • obtenir des valeurs du générateur, étape par étape.
  • éventuellement renvoyer des valeurs au générateur.

Pour extraire des valeurs d’un générateur, nous pouvons utiliser trois approches :

  • appeler next() sur l’objet itérateur.
  • l’itération avec for...of.
  • l’étalement et la déstructuration de tableau.

Une fonction générateur ne calcule pas tous ses résultats en une seule étape, comme le font les fonctions régulières.

Si nous reprenons notre exemple, pour obtenir des valeurs du générateur, nous pouvons tout d’abord chauffer le générateur :

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();

Ici go devient notre objet itérable/itérateur, résultat de l’appel de generate.

(Rappelez-vous, un objet Generator est à la fois un itérable et un itérateur).

À partir de maintenant, nous pouvons appeler go.next() pour faire avancer l’exécution :

function* generate() { yield 33; yield 99;}const go = generate();// Consume the generatorconst { value: firstStep } = go.next(); // firstStep is 33const { value: secondStep } = go.next(); // secondStep is 99

Ici, chaque appel à go.next() produit un nouvel objet. Dans l’exemple, nous déstructurons la propriété value de cet objet.

Les objets renvoyés par l’appel à next() sur l’objet itérateur ont deux propriétés :

  • value : la valeur pour l’étape courante.
  • done : un booléen indiquant s’il y a plus de valeurs dans le générateur, ou non.

Nous avons implémenté un tel objet itérateur dans la section précédente. En utilisant un générateur, l’objet itérateur est déjà là pour vous.

next() fonctionne bien pour extraire des données finies d’un objet itérateur.

Pour itérer sur des données non finies, nous pouvons utiliser for...of. Voici un générateur sans fin:

function* endlessGenerator() { let counter = 0; while (true) { counter++; yield counter; }}// Consume the generatorfor (const value of endlessGenerator()) { console.log(value);}

Comme vous pouvez le remarquer, il n’y a pas besoin d’initialiser le générateur quand on utilise for...of.

Enfin, nous pouvons aussi étaler et déstructurer l’objet générateur. Voici l’étalement:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // 

Voici la déstructuration:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Destructuringconst = go;console.log(first); // 1console.log(second); // 2

Il est important de noter que les générateurs s’épuisent une fois que vous consommez toutes leurs valeurs.

Si vous répartissez les valeurs d’un générateur, il n’y a plus rien à retirer ensuite :

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // // Exhaustconst = go;console.log(first); // undefinedconsole.log(second); // undefined

Les générateurs fonctionnent aussi dans l’autre sens : ils peuvent accepter des valeurs ou des commandes de l’appelant comme nous le verrons dans une minute.

Revenir aux fonctions de générateur

Les objets de l’itérateur, l’objet résultant d’un appel de fonction de générateur, exposent les méthodes suivantes :

  • next()
  • return()
  • throw()

Nous avons déjà vu next(), qui aide à tirer des objets d’un générateur.

Son utilisation ne se limite pas seulement à extraire des données, mais aussi à envoyer des valeurs au générateur.

Considérez le générateur suivant:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}

Il liste un paramètre string. Nous fournissons cet argument à l’initialisation:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");

Dès que nous appelons next() pour la première fois sur l’objet itérateur, l’exécution commence et produit « A » :

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // A

À ce stade, nous pouvons reparler au générateur en fournissant un argument pour next:

go.next("b");

Voici le listing complet :

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // B

À partir de maintenant, nous pouvons alimenter des valeurs à yield chaque fois que nous avons besoin d’une nouvelle chaîne de caractères en majuscules :

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // Bconsole.log(go.next("c").value); // Cconsole.log(go.next("d").value); // D

Si à tout moment nous voulons revenir complètement de l’exécution, nous pouvons utiliser return sur l’objet itérateur:

const { value } = go.return("stop it");console.log(value); // stop it

Cela arrête l’exécution.

En plus des valeurs, vous pouvez également lancer une exception dans le générateur. Voir « Gestion des erreurs pour les fonctions de générateur ».

Cas d’utilisation des fonctions de générateur

La plupart des développeurs (moi y compris) considèrent les fonctions de générateur comme une fonctionnalité JavaScript exotique qui a peu ou pas d’application dans le monde réel.

Cela pourrait être vrai pour le travail moyen de front-end, où un saupoudrage de jQuery, et un peu de CSS peuvent faire l’affaire la plupart du temps.

En réalité, les fonctions de générateur brillent vraiment dans tous ces scénarios où les performances sont primordiales.

En particulier, elles sont bonnes pour :

  • travailler avec de gros fichiers et des ensembles de données.
  • travailler les données en back-end, ou en front-end.
  • générer des séquences infinies de données.
  • computer une logique coûteuse à la demande.

Les fonctions génératrices sont également la brique de base pour des modèles asynchrones sophistiqués avec des fonctions génératrices asynchrones, notre sujet pour la prochaine section.

Fonctions génératrices asynchrones en JavaScript

Qu’est-ce qu’une fonction génératrice asynchrone ?

Une fonction génératrice asynchrone (ECMAScript 2018) est un type spécial de fonction asynchrone qui est capable d’arrêter et de reprendre son exécution à volonté.

La différence entre les fonctions génératrices synchrones et les fonctions génératrices asynchrones est que ces dernières renvoient un résultat asynchrone, basé sur des promesses, à partir de l’objet itérateur.

Comme les fonctions génératrices, les fonctions génératrices asynchrones sont capables :

  • de communiquer avec l’appelant.
  • de conserver leur contexte d’exécution (scope) sur les appels suivants.

Votre première fonction de générateur asynchrone

Pour créer une fonction de générateur asynchrone, nous déclarons une fonction de générateur avec l’étoile *, préfixée par async :

async function* asyncGenerator() { //}

Une fois à l’intérieur de la fonction, nous pouvons utiliser yield pour mettre en pause l’exécution :

async function* asyncGenerator() { yield 33; yield 99;}

Ici, yield met en pause l’exécution et renvoie un objet dit Generator à l’appelant.

Cet objet est à la fois un itérable, et un itérateur.

Récapitulons ces concepts pour voir comment ils s’intègrent au terrain asynchrone.

Itérables et itérateurs asynchrones

Un itérable asynchrone en JavaScript est un objet implémentant Symbol.asyncIterator.

Voici un exemple minimal :

const asyncIterable = { : function() { /* TODO: iterator */ }};

Une fois que nous avons cet objet itérable, nous assignons une fonction à pour retourner un objet itérateur.

L’objet itérateur doit se conformer au protocole des itérateurs avec une méthode next() (comme l’itérateur synchrone).

Développons notre exemple en ajoutant l’itérateur :

const asyncIterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return Promise.resolve({ value: count, done: false }); } return Promise.resolve({ value: count, done: true }); } }; }};

Cet itérateur est similaire à celui que nous avons construit dans les sections précédentes, cette fois la seule différence est que nous enveloppons l’objet de retour avec Promise.resolve.

À ce stade, nous pouvons faire quelque chose de ce genre :

const go = asyncIterable();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));// 1// 2

Ou avec for await...of:

async function consumer() { for await (const asyncIterableElement of asyncIterable) { console.log(asyncIterableElement); }}consumer();// 1// 2// 3

Les itérables et les itérateurs asynchrones sont la base des fonctions génératrices asynchrones.

Tournons maintenant à nouveau notre attention sur eux.

Extraction des données d’un générateur asynchrone

Les fonctions de générateur asynchrone ne calculent pas tous leurs résultats en une seule étape, comme le font les fonctions régulières.

Au lieu de cela, nous tirons des valeurs étape par étape.

Après avoir examiné les itérateurs asynchrones et les itérables, il ne devrait pas être surprenant de voir que pour tirer des Promesses d’un générateur asynchrone, nous pouvons utiliser deux approches :

  • appeler next() sur l’objet itérateur.
  • l’itération asynchrone avec for await...of.

Dans notre exemple initial nous pouvons faire:

async function* asyncGenerator() { yield 33; yield 99;}const go = asyncGenerator();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));

La sortie de ce code est:

3399

L’autre approche utilise l’itération asynchrone avec for await...of. Pour utiliser l’itération async, nous enveloppons le consommateur avec une fonction async.

Voici l’exemple complet:

async function* asyncGenerator() { yield 33; yield 99;}async function consumer() { for await (const value of asyncGenerator()) { console.log(value); }}consumer();

for await...of fonctionne bien pour extraire des flux de données non finis.

Voyons maintenant comment renvoyer les données au générateur.

Retourner aux fonctions de générateur asynchrone

Considérez le générateur asynchrone suivant:

async function* asyncGenerator(string) { while (true) { string = yield string.toUpperCase(); }}

Comme la machine à majuscules sans fin de l’exemple de générateur, nous pouvons fournir un argument à next():

async function* asyncEndlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}async function consumer() { const go = await asyncEndlessUppercase("a"); const { value: firstStep } = await go.next(); console.log(firstStep); const { value: secondStep } = await go.next("b"); console.log(secondStep); const { value: thirdStep } = await go.next("c"); console.log(thirdStep);}consumer();

Ici, chaque étape envoie une nouvelle valeur dans le générateur.

La sortie de ce code est:

ABC

Même s’il n’y a rien d’intrinsèquement asynchrone dans toUpperCase(), vous pouvez sentir le but de ce motif.

Si à tout moment nous voulons sortir de l’exécution, nous pouvons appeler return() sur l’objet itérateur:

 const { value } = await go.return("stop it"); console.log(value); // stop it

En plus des valeurs, vous pouvez aussi lancer une exception dans le générateur. Voir « Gestion des erreurs pour les générateurs asynchrones ».

Cas d’utilisation des itérables asynchrones et des fonctions de générateur asynchrones

Si les fonctions de générateur sont bonnes pour travailler de manière synchrone avec de gros fichiers et des séquences infinies, les fonctions de générateur asynchrones permettent un tout nouveau terrain de possibilités pour JavaScript.

En particulier, l’itération asynchrone facilite la consommation de flux lisibles. L’objet Response dans Fetch expose body comme un flux lisible avec getReader(). Nous pouvons envelopper un tel flux avec un générateur asynchrone, et plus tard itérer sur lui avec for await...of.

Async iterators and generators de Jake Archibald a un tas de beaux exemples.

D’autres exemples de flux sont les flux de requêtes avec Fetch.

Au moment de la rédaction, il n’y a pas d’API de navigateur implémentant Symbol.asyncIterator, mais la spécification Stream va changer cela.

Dans Node.js la récente API Stream joue joliment avec les générateurs asynchrones et l’itération asynchrone.

À l’avenir, nous serons en mesure de consommer et de travailler de manière transparente avec des flux inscriptibles, lisibles et transformateurs du côté client.

Ressources supplémentaires:

  • L’API Stream.

Conclusion

Termes et concepts clés que nous avons couverts dans ce post:

ECMAScript 2015:

  • itérable.
  • itérateur.

Ce sont les blocs de construction des fonctions de générateur.

ECMAScript 2018:

  • itérable asynchrone.
  • itérateur asynchrone.

Ce sont plutôt les blocs de construction des fonctions de générateur asynchrone.

Une bonne compréhension des itérables et des itérateurs peut vous mener loin. Ce n’est pas que vous travaillerez avec des fonctions de générateur et des fonctions de générateur asynchrone tous les jours, mais ils sont une compétence agréable à avoir dans votre ceinture d’outils.

Et vous ? Les avez-vous déjà utilisées ?

Merci de votre lecture et restez à l’écoute de ce blog !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.