Comment utiliser les promesses

Les promesses (ou promises en anglais) sont une brique fondamentale pour la programmation asynchrone en JavaScript. Une promesse est un objet renvoyé par une fonction asynchrone et qui représente l'état courant de l'opération. Au moment où la promesse est renvoyée à l'appelant, l'opération n'est généralement pas terminée, mais la promesse fournit des méthodes pour gérer la réussite ou l'échec de l'opération.

Prérequis : Notions informatiques élémentaires, compréhension raisonnable des notions fondamentales de JavaScript, notamment de la gestion des évènements.
Objectifs : Comprendre comment utiliser les promesses en JavaScript.

Dans l'article précédent, nous avons abordé l'utilisation des fonctions de rappel (callbacks) pour implémenter des fonctions asynchrones. Avec cette méthode, on appelle la fonction asynchrone en lui passant une fonction de rappel en argument. La première fonction renvoie sa valeur de retour immédiatement et appelle la fonction de rappel lorsque l'opération est terminée.

Avec une API fonctionnant avec des promesses, la fonction asynchrone démarre l'opération et renvoie un objet Promise. On peut alors attacher des gestionnaires à cette promesse et les gestionnaires seront exécutés lors du succès ou de l'échec de l'opération.

Utiliser l'API fetch()

Note : Dans cet article, nous étudierons les promesses en copiant des fragments de code dans la console JavaScript du navigateur. Pour ceci :

  1. Ouvrez un nouvel onglet dans votre navigateur et allez sur la page https://example.org.
  2. Dans cet onglet, ouvrez la console JavaScript des outils de développement du navigateur.
  3. Lorsqu'un exemple est présent dans cet article, copiez le contenu dans la console. Vous devrez recharger la page à chaque exemple pour éviter que la console affiche une erreur sur la redéclaration de fetchPromise().

Dans cet exemple, nous téléchargerons le fichier JSON situé à l'adresse https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json, et nous afficherons des informations à son sujet.

Pour cela, nous allons envoyer une requête HTTP au serveur. Lors d'une requête HTTP, on envoie un message à un serveur distant qui nous renvoie une réponse en retour. Ici, nous enverrons une requête pour obtenir le fichier JSON du serveur. Vous vous souvenez des requêtes HTTP utilisant l'API XMLHttpRequest dans l'article précédent ? Ici, nous utiliserons à la place l'API fetch(), qui est un remplacement moderne de XMLHttpRequest et qui utilise les promesses.

Copiez ce qui suit dans la console JavaScript de votre navigateur :

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((reponse) => {
  console.log(`Réponse reçue : ${reponse.status}`);
});

console.log("Requête initiée…");

Voici ce qu'on fait :

  1. On appelle l'API fetch() et on affecte la valeur de retour à la variable fetchPromise.
  2. Juste après, on affiche la variable fetchPromise dans la console. Cela devrait afficher quelque chose comme : Promise { <state>: "pending" }, qui indique qu'on a un objet Promise dont l'état est "pending" (c'est-à-dire, en cours). Cet état indique que l'opération de récupération du fichier est toujours en cours.
  3. On passe une fonction de gestion à la méthode then() de la promesse. Lorsque l'opération de récupération a réussi (si elle a réussi), la promesse appellera le gestionnaire en lui passant un objet Response, qui contient la réponse du serveur.
  4. On affiche un message dans la console indiquant que la requête a été lancée.

Voici à quoi devrait ressembler l'affichage complet dans la console :

Promise { <state>: "pending" }
Requête initiée…
Réponse reçue : 200

On notera que Requête initiée… est affiché avant d'avoir reçu une réponse. À la différence d'une fonction synchrone, fetch() produit sa valeur de retour alors que la requête est toujours en cours, ce qui permet à notre programme de rester réactif. La réponse affiche le code de statut HTTP 200 (OK), ce qui indique que la requête a réussi.

Ça ressemble beaucoup à l'exemple du précédent article où nous avions ajouté des gestionnaires d'évènements à l'objet XMLHttpRequest. Ici, à la place, on passe un gestionnaire à la méthode then() de la promesse renvoyée par l'opération.

Enchaîner les promesses

Avec l'API fetch(), une fois l'objet Response obtenu, il faut appeler une autre fonction pour en récupérer les données. Ici, on veut récupérer les données sous forme JSON et on peut appeler la méthode json() (en-US) de l'objet Response. Il s'avère que json() est également asynchrone. On a donc le cas où il faut appeler deux fonctions asynchrones à la suite.

Après avoir rechargé la page, copiez ce qui suit dans la console :

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((reponse) => {
  const jsonPromise = reponse.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

Dans cet exemple, comme auparavant, on ajoute un gestionnaire à la promesse renvoyée par fetch() grâce à then(). Dans cette version, le gestionnaire appelle response.json() puis passe un nouveau gestionnaire then() à la promesse renvoyée par response.json().

Cela devrait afficher "baked beans" (qui est le nom (name) du premier produit du fichier products.json).

Mais attendez ! Dans l'article précédent, nous avions dit qu'appeler une fonction de rappel dans une autre fonction de rappel créait de plus en plus de niveaux de code imbriqués. Et même que cela pouvait engendrer un callback hell qui rendrait le code moins compréhensible. N'y a-t-il pas le même risque avec les appels à then() ?

Bien entendu, il peut y avoir ce risque, mais les promesses disposent d'une caractéristique élégante : then() renvoie lui-même une promesse qui sera réalisée avec le résultat de la fonction qu'on lui a passée. Cela signifie qu'on peut (et en fait qu'on devrait) réécrire l'exemple précédent comme ceci :

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((reponse) => {
    return reponse.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

Ici, au lieu d'appeler le deuxième then() au sein du gestionnaire pour le premier then(), on peut renvoyer la promesse renvoyée par json(), puis appeler le deuxième then() sur cette valeur de retour. C'est ce qu'on appelle le chainage de promesses et cela signifie qu'on peut éviter d'avoir des niveaux croissants d'imbrication des appels de fonctions asynchrones.

Avant de passer à la prochaine étape, il reste quelque chose à ajouter. Il faut vérifier que le serveur a accepté notre requête et a été en mesure d'y répondre avant de tenter de lire la réponse. Pour cela, nous allons vérifier le code de statut de la réponse et lever une exception si ce n'était pas « OK » :

js
const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((reponse) => {
    if (!reponse.ok) {
      throw new Error(`Erreur HTTP : ${reponse.status}`);
    }
    return reponse.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

Intercepter les erreurs

Cela nous amène au dernier sujet : comment gérer les erreurs ? L'API fetch() peut déclencher une exception pour de nombreuses raisons (par exemple parce qu'il n'y a pas d'accès réseau ou encore parce que l'URL était malformée) et nous déclenchons également une erreur par nous-même si le serveur renvoie une erreur.

Dans l'article précédent, nous avons vu que la gestion d'erreur peut s'avérer délicate avec les fonctions de rappel imbriquées, où il fallait gérer les erreurs à chaque niveau d'imbrication.

Pour la gestion des erreurs, les objets Promise fournissent une méthode catch(). Elle se comporte un peu comme then() : on l'appelle en lui passant une fonction de gestion en argument. Si le gestionnaire passé à then() est appelé lorsque l'opération asynchrone a réussi, le gestionnaire passé à catch() est, quant à lui, appelé lorsque l'opération asynchrone échoue.

Si on ajoute catch() à la fin de la chaîne des promesses, il sera appelé dès qu'un des appels de fonction asynchrone échoue. Ainsi, il est possible d'implémenter une opération composée de plusieurs appels successifs de fonctions asynchrones et de gérer toutes les erreurs à un seul endroit.

Rechargez la page et essayez la version qui suit fetch(). On a ajouté un gestionnaire d'erreur grâce à catch(), et on a aussi modifié l'URL afin de faire échouer la requête.

js
const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((reponse) => {
    if (!reponse.ok) {
      throw new Error(`Erreur HTTP : ${reponse.status}`);
    }
    return reponse.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`Impossible de récupérer les produits : ${error}`);
  });

Essayez d'exécuter cette version, vous devriez avoir une erreur affichée dans la console avec le gestionnaire passé à catch().

Terminologie des promesses

Les promesses ont une terminologie spécifique qu'il est préférable de clarifier.

Pour commencer, une promesse peut être dans l'un de ces trois états :

En cours (pending en anglais)

La promesse a été créée et la fonction asynchrone associée n'a pas encore terminé son exécution, on ne sait pas si elle a réussi ou non. Il s'agit dans notre exemple de l'état de la promesse quand elle est renvoyée par l'appel à fetch(), et que la requête HTTP est toujours en cours.

Tenue (ou réussie ou fulfilled en anglais)

La fonction asynchrone a réussi. Lorsque la promesse est tenue, le gestionnaire passé à sa méthode then() est appelé.

Rompue (ou échouée ou rejected en anglais)

La fonction asynchrone a échoué. Lorsqu'une promesse est rompue, c'est le gestionnaire passé à sa méthode catch() qui est appelé.

On notera ici que la réussite ou l'échec dépend de l'API en question. Ainsi, l'API fetch() considèrera qu'une requête a réussi dans le cas où le serveur renvoie une erreur HTTP comme 404 Not Found, mais qu'elle a échoué si une erreur réseau a empêché l'envoi de la requête.

On utilise parfois le terme réglée (settled en anglais) pour désigner les deux cas de réussite et d'échec (autrement dit, pour dire que l'opération asynchrone n'est plus en cours).

On dit d'une promesse qu'elle est résolue (resolved) si elle est réglée ou si elle suit l'état d'une autre promesse.

L'article Parlons un peu de notre façon de parler des promesses (en anglais) fournit plus d'explications sur les détails de cette terminologie.

Combiner plusieurs promesses

Une chaîne de promesse est utile lorsque notre opération se compose de plusieurs fonctions asynchrones et que chacune de ces fonctions doit démarrer à la suite de l'autre. Il existe toutefois d'autres façons de combiner les appels de fonctions asynchrones et l'API Promise fournit différentes méthodes utilitaires pour ça.

Parfois, on a besoin que toutes les promesses soient tenues, mais leur exécution ne dépend pas l'une de l'autre. Dans une telle situation, il est plus efficace de lancer toutes les promesses en même temps puis de recevoir une notification lorsqu'elles ont toutes été tenues. La méthode Promise.all() est l'outil adéquat pour ça. Elle prend comme argument un tableau de promesses et renvoie une seule promesse.

La promesse renvoyée par Promise.all() est :

  • Tenue lorsque toutes les promesses du tableau ont été tenues. Dans ce cas, le gestionnaire then() est appelé avec un tableau contenant toutes les réponses, dans le même ordre que le tableau des promesses passé à all()
  • Rompue si au moins une des promesses du tableau a été rompue. Dans ce cas, le gestionnaire catch() est appelé avec l'erreur levée par la promesse du tableau qui a été rompue.

Ainsi :

js
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((reponses) => {
    for (const reponse of reponses) {
      console.log(`${reponse.url} : ${reponse.status}`);
    }
  })
  .catch((error) => {
    console.error(`Erreur de récupération : ${error}`);
  });

Ici, on lance trois requêtes fetch() vers trois URL différentes. Si elles réussissent toutes les trois, on affiche le code de statut de chaque. Si l'une d'elles échoue, on affiche l'erreur dans la console.

Avec les URL fournies, toutes les requêtes devraient réussir, même si pour la deuxième, le serveur renvoie 404 (Not Found) et non 200 (OK) (en effet, le fichier demandé n'existe pas sur le serveur). Voici ce qui devrait donc s'afficher dans la console :

https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json : 200
https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found : 404
https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json : 200

Si on essaye le même code avec une URL mal-formée, comme avec :

js
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((reponses) => {
    for (const reponse of reponses) {
      console.log(`${reponse.url}: ${reponse.status}`);
    }
  })
  .catch((error) => {
    console.error(`Échec de récupération : ${error}`);
  });

On peut alors s'attendre à ce que le gestionnaire catch() soit exécuté et qu'il y ait un message d'erreur dans la console :

Échec de récupération : TypeError: Failed to fetch

Il arrive aussi qu'on ait plusieurs promesses et que la réussite d'une seule suffise, quelle que soit la promesse qui réussit. Dans ce cas, on pourra utiliser Promise.any(). Elle fonctionne comme Promise.all(), mais elle est tenue dès qu'une des promesses du tableau a été tenue et rompue uniquement si toutes les promesses du tableau sont rompues :

js
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((reponse) => {
    console.log(`${reponse.url}: ${reponse.status}`);
  })
  .catch((error) => {
    console.error(`Erreur de récupération : ${error}`);
  });

On notera que dans ce cas, on ne peut pas prédire la requête qui sera terminée en premier.

Nous avons vu ici uniquement deux fonctions de Promise qui permettent de combiner plusieurs promesses. Pour en apprendre plus sur les autres méthodes, vous pouvez consulter la page Promise de la référence JavaScript.

async et await

Le mot-clé async fournit une façon plus simple de travailler avec du code asynchrone utilisant les promesses. En ajoutant async au début d'une fonction, cette dernière devient une fonction asynchrone :

js
async function maFonction() {
  // Voici une fonction asynchrone
}

Dans une fonction asynchrone, on peut utiliser le mot-clé await avant un appel à une fonction renvoyant une promesse. De cette façon, le code patiente jusqu'à ce que la promesse soit réglée et la valeur de résolution de la promesse est fournie comme valeur de retour, ou alors la valeur d'échec déclenche une erreur.

Cela permet d'écrire du code utilisant des fonctions asynchrones mais qui ressemble à du code synchrone. On pourrait par exemple réécrire notre exemple avec fetch() comme ceci :

js
async function fetchProducts() {
  try {
    // Après cette ligne, notre fonction attendra que l'appel à `fetch()`
    // soit réglé, soit on aura un objet Response dans la variable reponse
    // soit on aura une erreur qui sera levée
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`Erreur HTTP : ${response.status}`);
    }
    // Après cette ligne, notre fonction attendra que l'appel à
    // `response.json()` soit réglé et la variable json contiendra
    // un objet JSON ou alors une erreur sera levée
    const json = await response.json();
    console.log(json[0].name);
  } catch (error) {
    console.error(`Impossible d'obtenir les produits : ${error}`);
  }
}

fetchProducts();

On utilise ici await fetch() plutôt que de récupérer une promesse. La fonction appelante récupère un objet Response directement, comme si fetch() était une fonction synchrone !

On peut même utiliser un bloc try…catch pour la gestion d'erreurs, de la même façon qu'on peut le faire lorsqu'on utilise du code synchrone.

Attention, ceci ne fonctionne qu'au sein des fonctions asynchrones. Les fonctions asynchrones renvoient toujours une promesse et on ne peut donc pas faire ceci :

js
async function fetchProducts() {
  try {
    const reponse = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!reponse.ok) {
      throw new Error(`Erreur HTTP : ${reponse.status}`);
    }
    const json = await reponse.json();
    return json;
  } catch (error) {
    console.error(`Impossible d'obtenir les produits : ${error}`);
  }
}

const json = fetchProducts();
console.log(json[0].name); // json est un objet Promise, ça ne fonctionnera pas

À la place, il faut plutôt écrire comme ceci :

js
async function fetchProducts() {
  try {
    const reponse = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!reponse.ok) {
      throw new Error(`Erreur HTTP : ${reponse.status}`);
    }
    const json = await reponse.json();
    return json;
  } catch (error) {
    console.error(`Impossible d'obtenir les produits : ${error}`);
  }
}

const jsonPromise = fetchProducts();
jsonPromise.then((json) => console.log(json[0].name));

Vous utiliserez probablement les fonctions asynchrones là où vous pourriez utiliser les chaînes de promesse. Elles permettent de travailler avec les promesses de façon plus intuitive.

Il faut garder en tête que, comme les chaînes de promesses, await implique que les opérations asynchrones s'exécutent les unes à la suite des autres. C'est utile lorsque le résultat d'une opération dépend de celui de la précédente, mais si ce n'est pas le cas, l'utilisation de Promise.all() ou d'autres méthodes pourra s'avérer plus performante.

Conclusion

Les promesses sont à la base de la programmation asynchrone en JavaScript moderne. Elles permettent d'exprimer et de raisonner avec des ensembles d'opérations asynchrones sans avoir à utiliser des niveaux d'imbrication de fonctions de rappels. De plus, la façon de gérer les erreurs se rapproche de ce qu'on peut avoir avec l'instruction synchrone try…catch.

Les mots-clés async et await permettent quant à eux de construire simplement une opération qui consiste en une suite d'appels de fonctions asynchrones, sans avoir à recourir à des chaînes de promesses et en écrivant du code qui ressemble à du code synchrone.

Les promesses fonctionnent pour tous les navigateurs récents. Les seuls environnements où leur prise en charge est problématique sont : Opera Mini et IE11 ainsi que les versions antérieures.

Nous n'avons pas couvert toutes les fonctionnalités des promesses dans cet article, nous avons seulement vu les aspects les plus utiles et les plus intéressants. Lorsque vous commencerez à en savoir plus sur les promesses, vous rencontrerez d'autres fonctionnalités et d'autres techniques.

De nombreuses API web modernes utilisent les promesses, comme WebRTC, l'API Web Audio, l'API de flux média (Media Streams API), et bien d'autres.

Voir aussi