Regardons Async/Await en JavaScript à l'aide d'exemples

L'auteur de l'article examine des exemples d'Async/Await en JavaScript. Dans l’ensemble, Async/Await est un moyen pratique d’écrire du code asynchrone. Avant l’apparition de cette fonctionnalité, ce code était écrit à l’aide de rappels et de promesses. L'auteur de l'article original révèle les avantages d'Async/Await en analysant divers exemples.

Nous rappelons: pour tous les lecteurs de "Habr" - une remise de 10 000 roubles lors de l'inscription à n'importe quel cours Skillbox en utilisant le code promotionnel "Habr".

Skillbox vous recommande : Cours éducatif en ligne "Développeur Java".

Rappel

Le rappel est une fonction dont l'appel est retardé indéfiniment. Auparavant, les rappels étaient utilisés dans les zones de code où le résultat ne pouvait pas être obtenu immédiatement.

Voici un exemple de lecture asynchrone d'un fichier dans Node.js :

fs.readFile(__filename, 'utf-8', (err, data) => {
  if (err) {
    throw err;
  }
  console.log(data);
});

Des problèmes surviennent lorsque vous devez effectuer plusieurs opérations asynchrones à la fois. Imaginons ce scénario : une requête est faite à la base de données des utilisateurs Arfat, vous devez lire son champ profile_img_url et télécharger une image depuis le serveur someserver.com.
Après le téléchargement, nous convertissons l'image dans un autre format, par exemple de PNG en JPEG. Si la conversion a réussi, une lettre est envoyée à l'e-mail de l'utilisateur. Ensuite, les informations sur l'événement sont saisies dans le fichier transformations.log, indiquant la date.

Il convient de prêter attention au chevauchement des rappels et au grand nombre de }) dans la dernière partie du code. Cela s'appelle Callback Hell ou Pyramid of Doom.

Les inconvénients de cette méthode sont évidents :

  • Ce code est difficile à lire.
  • Il est également difficile de gérer les erreurs, ce qui conduit souvent à une mauvaise qualité du code.

Pour résoudre ce problème, des promesses ont été ajoutées à JavaScript. Ils vous permettent de remplacer l'imbrication profonde des rappels par le mot .then.

L’aspect positif des promesses est qu’elles rendent le code bien plus lisible, de haut en bas plutôt que de gauche à droite. Cependant, les promesses ont aussi leurs problèmes :

  • Vous devez ajouter beaucoup de .then.
  • Au lieu de try/catch, .catch est utilisé pour gérer toutes les erreurs.
  • Travailler avec plusieurs promesses dans une seule boucle n'est pas toujours pratique ; dans certains cas, elles compliquent le code.

Voici un problème qui montrera le sens du dernier point.

Supposons que nous ayons une boucle for qui imprime une séquence de nombres de 0 à 10 à des intervalles aléatoires (0 à n secondes). À l'aide de promesses, vous devez modifier cette boucle pour que les nombres soient imprimés dans l'ordre de 0 à 10. Ainsi, s'il faut 6 secondes pour imprimer un zéro et 2 secondes pour imprimer un un, le zéro doit être imprimé en premier, puis le compte à rebours pour imprimer celui-ci va commencer.

Et bien sûr, nous n'utilisons pas Async/Await ou .sort pour résoudre ce problème. Un exemple de solution se trouve à la fin.

Fonctions asynchrones

L'ajout de fonctions asynchrones dans ES2017 (ES8) a simplifié la tâche de travail avec les promesses. Je note que les fonctions asynchrones fonctionnent « au-dessus » des promesses. Ces fonctions ne représentent pas des concepts qualitativement différents. Les fonctions asynchrones sont conçues comme une alternative au code qui utilise des promesses.

Async/Await permet d'organiser le travail avec du code asynchrone dans un style synchrone.

Ainsi, connaître les promesses facilite la compréhension des principes d’Async/Await.

syntaxe

Normalement, il se compose de deux mots-clés : async et wait. Le premier mot transforme la fonction en asynchrone. De telles fonctions permettent l'utilisation de wait. Dans tous les autres cas, l'utilisation de cette fonction générera une erreur.

// With function declaration
 
async function myFn() {
  // await ...
}
 
// With arrow function
 
const myFn = async () => {
  // await ...
}
 
function myFn() {
  // await fn(); (Syntax Error since no async)
}
 

Async est inséré au tout début de la déclaration de fonction, et dans le cas d'une fonction flèche, entre le signe « = » et les parenthèses.

Ces fonctions peuvent être placées dans un objet en tant que méthodes ou utilisées dans une déclaration de classe.

// As an object's method
 
const obj = {
  async getName() {
    return fetch('https://www.example.com');
  }
}
 
// In a class
 
class Obj {
  async getResource() {
    return fetch('https://www.example.com');
  }
}

Attention ! Il convient de rappeler que les constructeurs de classe et les getters/setters ne peuvent pas être asynchrones.

Sémantique et règles d'exécution

Les fonctions asynchrones sont fondamentalement similaires aux fonctions JS standard, mais il existe des exceptions.

Ainsi, les fonctions asynchrones renvoient toujours des promesses :

async function fn() {
  return 'hello';
}
fn().then(console.log)
// hello

Plus précisément, fn renvoie la chaîne hello. Eh bien, puisqu'il s'agit d'une fonction asynchrone, la valeur de la chaîne est enveloppée dans une promesse à l'aide d'un constructeur.

Voici une conception alternative sans Async :

function fn() {
  return Promise.resolve('hello');
}
 
fn().then(console.log);
// hello

Dans ce cas, la promesse est renvoyée « manuellement ». Une fonction asynchrone est toujours enveloppée dans une nouvelle promesse.

Si la valeur de retour est une primitive, la fonction asynchrone renvoie la valeur en l'enveloppant dans une promesse. Si la valeur de retour est un objet de promesse, sa résolution est renvoyée dans une nouvelle promesse.

const p = Promise.resolve('hello')
p instanceof Promise;
// true
 
Promise.resolve(p) === p;
// true
 

Mais que se passe-t-il s’il y a une erreur dans une fonction asynchrone ?

async function foo() {
  throw Error('bar');
}
 
foo().catch(console.log);

S'il n'est pas traité, foo() renverra une promesse avec rejet. Dans cette situation, Promise.reject contenant une erreur sera renvoyé à la place de Promise.resolve.

Les fonctions asynchrones génèrent toujours une promesse, quel que soit ce qui est renvoyé.

Les fonctions asynchrones s'arrêtent à chaque wait .

Attendre affecte les expressions. Ainsi, si l'expression est une promesse, la fonction asynchrone est suspendue jusqu'à ce que la promesse soit remplie. Si l'expression n'est pas une promesse, elle est convertie en promesse via Promise.resolve puis complétée.

// utility function to cause delay
// and get random value
 
const delayAndGetRandom = (ms) => {
  return new Promise(resolve => setTimeout(
    () => {
      const val = Math.trunc(Math.random() * 100);
      resolve(val);
    }, ms
  ));
};
 
async function fn() {
  const a = await 9;
  const b = await delayAndGetRandom(1000);
  const c = await 5;
  await delayAndGetRandom(1000);
 
  return a + b * c;
}
 
// Execute fn
fn().then(console.log);

Et voici une description du fonctionnement de la fonction fn.

  • Après l'avoir appelé, la première ligne est convertie à partir de const a = wait 9 ; dans const a = wait Promise.resolve(9);.
  • Après avoir utilisé Await, l'exécution de la fonction est suspendue jusqu'à ce que a obtienne sa valeur (dans la situation actuelle, elle est 9).
  • delayAndGetRandom(1000) suspend l'exécution de la fonction fn jusqu'à ce qu'elle se termine (après 1 seconde). Cela arrête effectivement la fonction fn pendant 1 seconde.
  • delayAndGetRandom(1000) via la résolution renvoie une valeur aléatoire, qui est ensuite affectée à la variable b.
  • Eh bien, le cas de la variable c est similaire au cas de la variable a. Après cela, tout s'arrête pendant une seconde, mais maintenant delayAndGetRandom(1000) ne renvoie rien car ce n'est pas obligatoire.
  • En conséquence, les valeurs sont calculées à l'aide de la formule a + b * c. Le résultat est enveloppé dans une promesse à l'aide de Promise.resolve et renvoyé par la fonction.

Ces pauses rappellent peut-être les générateurs de l'ES6, mais il y a quelque chose à faire vos raisons.

Résoudre le problème

Eh bien, regardons maintenant la solution au problème mentionné ci-dessus.

La fonction finishMyTask utilise Await pour attendre les résultats d'opérations telles que queryDatabase, sendEmail, logTaskInFile et autres. Si l’on compare cette solution avec celle où les promesses étaient utilisées, les similitudes deviendront évidentes. Cependant, la version Async/Await simplifie grandement toutes les complexités syntaxiques. Dans ce cas, il n'y a pas un grand nombre de rappels et de chaînes comme .then/.catch.

Voici une solution avec sortie de nombres, il existe deux options.

const wait = (i, ms) => new Promise(resolve => setTimeout(() => resolve(i), ms));
 
// Implementation One (Using for-loop)
const printNumbers = () => new Promise((resolve) => {
  let pr = Promise.resolve(0);
  for (let i = 1; i <= 10; i += 1) {
    pr = pr.then((val) => {
      console.log(val);
      return wait(i, Math.random() * 1000);
    });
  }
  resolve(pr);
});
 
// Implementation Two (Using Recursion)
 
const printNumbersRecursive = () => {
  return Promise.resolve(0).then(function processNextPromise(i) {
 
    if (i === 10) {
      return undefined;
    }
 
    return wait(i, Math.random() * 1000).then((val) => {
      console.log(val);
      return processNextPromise(i + 1);
    });
  });
};

Et voici une solution utilisant des fonctions asynchrones.

async function printNumbersUsingAsync() {
  for (let i = 0; i < 10; i++) {
    await wait(i, Math.random() * 1000);
    console.log(i);
  }
}

Traitement des erreurs

Les erreurs non gérées sont enveloppées dans une promesse rejetée. Cependant, les fonctions asynchrones peuvent utiliser try/catch pour gérer les erreurs de manière synchrone.

async function canRejectOrReturn() {
  // wait one second
  await new Promise(res => setTimeout(res, 1000));
 
// Reject with ~50% probability
  if (Math.random() > 0.5) {
    throw new Error('Sorry, number too big.')
  }
 
return 'perfect number';
}

canRejectOrReturn() est une fonction asynchrone qui réussit (« nombre parfait ») ou échoue avec une erreur (« Désolé, nombre trop grand »).

async function foo() {
  try {
    await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Puisque l'exemple ci-dessus s'attend à ce que canRejectOrReturn s'exécute, son propre échec entraînera l'exécution du bloc catch. En conséquence, la fonction foo se terminera soit par undefined (lorsque rien n'est renvoyé dans le bloc try), soit par une erreur détectée. Par conséquent, cette fonction n'échouera pas car le try/catch gérera la fonction foo elle-même.

Voici un autre exemple:

async function foo() {
  try {
    return canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Il convient de prêter attention au fait que dans l'exemple, canRejectOrReturn est renvoyé par foo. Foo dans ce cas se termine par un nombre parfait ou renvoie une erreur (« Désolé, nombre trop grand »). Le bloc catch ne sera jamais exécuté.

Le problème est que foo renvoie la promesse transmise par canRejectOrReturn. Ainsi, la solution à foo devient la solution à canRejectOrReturn. Dans ce cas, le code sera composé de seulement deux lignes :

try {
    const promise = canRejectOrReturn();
    return promise;
}

Voici ce qui se passe si vous utilisez wait et return ensemble :

async function foo() {
  try {
    return await canRejectOrReturn();
  } catch (e) {
    return 'error caught';
  }
}

Dans le code ci-dessus, foo se terminera avec succès avec à la fois un nombre parfait et une erreur détectée. Il n'y aura aucun refus ici. Mais foo reviendra avec canRejectOrReturn, pas avec undefined. Assurons-nous-en en supprimant la ligne return wait canRejectOrReturn() :

try {
    const value = await canRejectOrReturn();
    return value;
}
// …

Erreurs et pièges courants

Dans certains cas, l'utilisation d'Async/Await peut entraîner des erreurs.

Attendre oublié

Cela arrive assez souvent - le mot-clé wait est oublié avant la promesse :

async function foo() {
  try {
    canRejectOrReturn();
  } catch (e) {
    return 'caught';
  }
}

Comme vous pouvez le voir, il n'y a ni attente ni retour dans le code. Par conséquent, foo sort toujours avec undefined sans délai d'une seconde. Mais la promesse sera tenue. S'il génère une erreur ou un rejet, alors UnhandledPromiseRejectionWarning sera appelé.

Fonctions asynchrones dans les rappels

Les fonctions asynchrones sont assez souvent utilisées dans .map ou .filter comme rappels. Un exemple est la fonction fetchPublicReposCount(username), qui renvoie le nombre de référentiels ouverts sur GitHub. Disons qu'il y a trois utilisateurs dont nous avons besoin des métriques. Voici le code de cette tâche :

const url = 'https://api.github.com/users';
 
// Utility fn to fetch repo counts
const fetchPublicReposCount = async (username) => {
  const response = await fetch(`${url}/${username}`);
  const json = await response.json();
  return json['public_repos'];
}

Nous avons besoin de comptes ArfatSalman, octocat, norvig. Dans ce cas on fait :

const users = [
  'ArfatSalman',
  'octocat',
  'norvig'
];
 
const counts = users.map(async username => {
  const count = await fetchPublicReposCount(username);
  return count;
});

Cela vaut la peine de prêter attention à Await dans le rappel .map. Ici, counts est un tableau de promesses, et .map est un rappel anonyme pour chaque utilisateur spécifié.

Utilisation trop cohérente de wait

Prenons ce code comme exemple :

async function fetchAllCounts(users) {
  const counts = [];
  for (let i = 0; i < users.length; i++) {
    const username = users[i];
    const count = await fetchPublicReposCount(username);
    counts.push(count);
  }
  return counts;
}

Ici, le numéro de repo est placé dans la variable count, puis ce numéro est ajouté au tableau counts. Le problème avec le code est que jusqu'à ce que les données du premier utilisateur arrivent du serveur, tous les utilisateurs suivants seront en mode veille. Ainsi, un seul utilisateur est traité à la fois.

Si, par exemple, il faut environ 300 ms pour traiter un utilisateur, alors pour tous les utilisateurs, c'est déjà une seconde ; le temps passé dépend linéairement du nombre d'utilisateurs. Mais comme l’obtention du nombre de repo ne dépend pas les unes des autres, les processus peuvent être parallélisés. Cela nécessite de travailler avec .map et Promise.all :

async function fetchAllCounts(users) {
  const promises = users.map(async username => {
    const count = await fetchPublicReposCount(username);
    return count;
  });
  return Promise.all(promises);
}

Promise.all reçoit un tableau de promesses en entrée et renvoie une promesse. Ce dernier, une fois que toutes les promesses du tableau sont terminées ou au premier rejet, est terminé. Il peut arriver qu'ils ne démarrent pas tous en même temps - afin d'assurer un démarrage simultané, vous pouvez utiliser p-map.

Conclusion

Les fonctions asynchrones deviennent de plus en plus importantes pour le développement. Eh bien, pour une utilisation adaptative des fonctions asynchrones, cela vaut la peine d'utiliser Itérateurs asynchrones. Un développeur JavaScript doit bien connaître cela.

Skillbox vous recommande :

Source: habr.com

Ajouter un commentaire