Fonctions asynchrones: rendre les promesses conviviales

Les fonctions asynchrones vous permettent d'écrire du code basé sur des promesses comme s'il était synchrone.

Jake Archibal
Jake Archibal

Les fonctions asynchrones sont activées par défaut dans Chrome, Edge, Firefox et Safari, et sont honnêtement merveilleuses. Elles vous permettent d'écrire du code basé sur des promesses comme s'il était synchrone, mais sans bloquer le thread principal. Elles rendent votre code asynchrone moins "intelligent" et plus lisible.

Les fonctions asynchrones fonctionnent comme ceci:

async function myFirstAsyncFunction() {
  try {
    const fulfilledValue = await promise;
  } catch (rejectedValue) {
    // …
  }
}

Si vous utilisez le mot clé async avant la définition d'une fonction, vous pouvez utiliser await dans la fonction. Lorsque vous utilisez l'opérateur await pour une promesse, la fonction est mise en veille de manière non bloquante jusqu'à ce que la promesse se stabilise. Si la promesse se tient, vous en récupérez la valeur. Si la promesse est rejetée, la valeur refusée est générée.

Prise en charge des navigateurs

Navigateurs pris en charge

  • 55
  • 15
  • 52
  • 10.1

Source

Exemple: consigner une extraction

Supposons que vous souhaitiez récupérer une URL et consigner la réponse sous forme de texte. Voici à quoi cela ressemble en utilisant des promesses:

function logFetch(url) {
  return fetch(url)
    .then((response) => response.text())
    .then((text) => {
      console.log(text);
    })
    .catch((err) => {
      console.error('fetch failed', err);
    });
}

Et voici la même chose en utilisant des fonctions asynchrones:

async function logFetch(url) {
  try {
    const response = await fetch(url);
    console.log(await response.text());
  } catch (err) {
    console.log('fetch failed', err);
  }
}

Le nombre de lignes est identique, mais tous les rappels ont disparu. Cela la rend beaucoup plus facile à lire, en particulier pour ceux qui ne connaissent pas bien les promesses.

Valeurs renvoyées asynchrones

Les fonctions asynchrones renvoient toujours une promesse, que vous utilisiez await ou non. Cette promesse se résout avec ce que la fonction asynchrone renvoie, ou rejetée avec ce que la fonction asynchrone génère. Donc, avec:

// wait ms milliseconds
function wait(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function hello() {
  await wait(500);
  return 'world';
}

L'appel de hello() renvoie une promesse qui répond à "world".

async function foo() {
  await wait(500);
  throw Error('bar');
}

L'appel de foo() renvoie une promesse qui est refusée avec Error('bar').

Exemple: diffuser une réponse

Les fonctions asynchrones présentent davantage d'avantages dans des exemples plus complexes. Supposons que vous souhaitiez diffuser une réponse en flux continu en vous déconnectant des fragments et renvoyer la taille finale.

La voici, avec des promesses:

function getResponseSize(url) {
  return fetch(url).then((response) => {
    const reader = response.body.getReader();
    let total = 0;

    return reader.read().then(function processResult(result) {
      if (result.done) return total;

      const value = result.value;
      total += value.length;
      console.log('Received chunk', value);

      return reader.read().then(processResult);
    });
  });
}

Regardez-moi, Jake le "porteur de promesses" Archibald. Vous voyez comment j'appelle processResult() en lui-même pour configurer une boucle asynchrone ? Un texte qui m'a rendu très intelligent. Mais comme dans la plupart des codes "intelligents", vous devez le regarder pendant des années pour comprendre ce qu'il fait, comme l'une de ces images magiques des années 90.

Réessayons avec des fonctions asynchrones:

async function getResponseSize(url) {
  const response = await fetch(url);
  const reader = response.body.getReader();
  let result = await reader.read();
  let total = 0;

  while (!result.done) {
    const value = result.value;
    total += value.length;
    console.log('Received chunk', value);
    // get the next result
    result = await reader.read();
  }

  return total;
}

L'intelligence a disparu. La boucle asynchrone qui m'a donné l'impression d'être suffisant est remplacée par une boucle "when-loop" fiable et ennuyeuse. Le résultat est nettement meilleur. À l'avenir, vous utiliserez des itérateurs asynchrones, qui remplaceraient la boucle while par une boucle for-of, ce qui la rendrait encore plus claire.

Autre syntaxe de fonction asynchrone

Je vous ai déjà montré async function() {}, mais le mot clé async peut être utilisé avec une autre syntaxe de fonction:

Fonctions fléchées

// map some URLs to json-promises
const jsonPromises = urls.map(async (url) => {
  const response = await fetch(url);
  return response.json();
});

Méthodes de l'objet

const storage = {
  async getAvatar(name) {
    const cache = await caches.open('avatars');
    return cache.match(`/avatars/${name}.jpg`);
  }
};

storage.getAvatar('jaffathecake').then(…);

Méthodes de classe

class Storage {
  constructor() {
    this.cachePromise = caches.open('avatars');
  }

  async getAvatar(name) {
    const cache = await this.cachePromise;
    return cache.match(`/avatars/${name}.jpg`);
  }
}

const storage = new Storage();
storage.getAvatar('jaffathecake').then(…);

Attention ! Évitez de diffuser des annonces trop séquentielles

Bien que vous écriviez du code qui semble synchrone, assurez-vous de ne pas manquer l'occasion d'effectuer des tâches en parallèle.

async function series() {
  await wait(500); // Wait 500ms…
  await wait(500); // …then wait another 500ms.
  return 'done!';
}

L'opération ci-dessus prend 1 000 ms, alors que:

async function parallel() {
  const wait1 = wait(500); // Start a 500ms timer asynchronously…
  const wait2 = wait(500); // …meaning this timer happens in parallel.
  await Promise.all([wait1, wait2]); // Wait for both timers in parallel.
  return 'done!';
}

L'opération ci-dessus prend 500 ms, car les deux temps d'attente se produisent en même temps. Voyons un exemple pratique.

Exemple: générer des récupérations dans l'ordre

Supposons que vous souhaitiez récupérer une série d'URL et les enregistrer dès que possible, dans le bon ordre.

Respiration profonde : voici à quoi cela ressemble, avec des promesses :

function markHandled(promise) {
  promise.catch(() => {});
  return promise;
}

function logInOrder(urls) {
  // fetch all the URLs
  const textPromises = urls.map((url) => {
    return markHandled(fetch(url).then((response) => response.text()));
  });

  // log them in order
  return textPromises.reduce((chain, textPromise) => {
    return chain.then(() => textPromise).then((text) => console.log(text));
  }, Promise.resolve());
}

Oui, c'est exact. J'utilise reduce pour enchaîner une séquence de promesses. Je suis très intelligente. Mais ce code est un peu très intelligent qu'il vaut mieux ne pas le faire.

Toutefois, lorsque vous convertissez ce qui précède en fonction asynchrone, il est tentant de choisir un mode trop séquentiel:

Déconseillé (trop séquentiel)
async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}
Cela semble beaucoup plus soigné, mais ma deuxième récupération ne commence pas tant que ma première extraction n'a pas été entièrement lue, et ainsi de suite. C'est beaucoup plus lent que l'exemple de promesses qui effectue les extractions en parallèle. Heureusement, il y a un milieu idéal.
Recommandé : beau et parallèle
function markHandled(...promises) {
  Promise.allSettled(promises);
}

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async (url) => {
    const response = await fetch(url);
    return response.text();
  });

  markHandled(...textPromises);

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}
Dans cet exemple, les URL sont récupérées et lues en parallèle, mais le bit reduce "intelligent" est remplacé par une boucle For standard, ennuyeuse et lisible.

Solution de contournement pour les navigateurs: générateurs

Si vous ciblez des navigateurs compatibles avec les générateurs (ce qui inclut la dernière version de tous les principaux navigateurs), vous pouvez trier les fonctions asynchrones de type polyfill.

Babel s'en chargera pour vous. Voici un exemple via le REPL Babel.

Je vous recommande l'approche de transpilation, car vous pouvez simplement la désactiver une fois que vos navigateurs cibles prennent en charge les fonctions asynchrones, mais si vous ne souhaitez vraiment pas utiliser de transpilateur, vous pouvez utiliser le polyfill de Babel et l'utiliser vous-même. À la place de :

async function slowEcho(val) {
  await wait(1000);
  return val;
}

... vous incluez le polyfill et écrivez:

const slowEcho = createAsyncFunction(function* (val) {
  yield wait(1000);
  return val;
});

Notez que vous devez transmettre un générateur (function*) à createAsyncFunction et utiliser yield au lieu de await. À part cela, cela fonctionne de la même manière.

Solution: régénérateur

Si vous ciblez des navigateurs plus anciens, Babel peut également transpiler des générateurs, ce qui vous permet d'utiliser des fonctions asynchrones jusqu'à IE8. Pour ce faire, vous avez besoin du préréglage es2017 de Babel et du préréglage es2015.

Le résultat n'est pas aussi beau, faites attention aux surcharges de code.

Tout synchroniser !

Une fois que les fonctions asynchrones sont disponibles sur tous les navigateurs, utilisez-les sur chaque fonction renvoyant une promesse. Non seulement ils rendent votre code plus ordonné, mais cela garantit que cette fonction renverra toujours une promesse.

En 2014, je me suis senti(e) très enthousiaste à propos des fonctions asynchrones et je suis ravi de les voir s'afficher, en réalité, dans les navigateurs. Oups !