Promesses JavaScript: introduction

Les promesses simplifient les calculs différés et asynchrones. Une promesse représente une opération qui n'est pas encore terminée.

Jake Archibal
Jake Archibal

Développeurs, préparez-vous à un moment charnière dans l'histoire du développement Web.

[Roulements de tambour]

Les promesses sont arrivées en JavaScript !

[Des feux d'artifice explosent, du papier scintillant pleut du haut, la foule s'échappe]

À ce stade, vous appartenez à l'une de ces catégories:

  • Les gens s'encouragent, mais vous ne savez pas de quoi il s'agit. Peut-être ne savez-vous même pas ce qu'est une "promesse". Vous haussez les épaules, mais le poids du papier scintillant pèse sur vos épaules. Si c'est le cas, ne vous en faites pas, il m'a fallu du temps pour comprendre pourquoi je devais m'intéresser à ces sujets. Vous voudrez probablement commencer par le début.
  • Vous donnez un coup de poing ! À peu près l'heure, n'est-ce pas ? Vous avez déjà utilisé ces éléments de la promesse, mais cela vous dérange que toutes les implémentations utilisent une API légèrement différente. Quelle est l'API de la version JavaScript officielle ? Vous souhaiterez probablement commencer par la terminologie.
  • Vous le saviez déjà et vous vous moquez de ceux qui sautent de haut en bas comme si c'était une nouvelle pour eux. Prenez un moment pour profiter de votre propre supériorité, puis accédez directement à la documentation de référence de l'API.

Prise en charge des navigateurs et polyfill

Navigateurs pris en charge

  • 32
  • 12
  • 29
  • 8

Source

Pour que les navigateurs qui n'implémentent pas complètement les promesses soient conformes aux spécifications, ou pour ajouter des promesses à d'autres navigateurs et à Node.js, découvrez le polyfill (2K compressé avec gzip).

De quoi s'agit-il ?

JavaScript est à thread unique, ce qui signifie que deux bits du script ne peuvent pas s'exécuter en même temps. Ils doivent s'exécuter l'un après l'autre. Dans les navigateurs, JavaScript partage un thread avec une charge d'autres éléments qui diffèrent d'un navigateur à l'autre. Toutefois, JavaScript se trouve généralement dans la même file d'attente que pour peindre, mettre à jour les styles et gérer les actions des utilisateurs (comme la mise en surbrillance du texte et l'interaction avec les commandes de formulaire). L'activité dans l'un de ces éléments retarde les autres.

En tant qu'être humain, vous êtes multithread. Vous pouvez taper avec plusieurs doigts, conduire et maintenir une conversation en même temps. La seule fonction de blocage que nous devons gérer est l'éternuement, où toute activité en cours doit être suspendue le temps de l'éternuement. C'est assez ennuyeux, surtout quand vous conduisez et essayez d'avoir une conversation. Vous ne devez pas écrire un code gênant.

Vous avez probablement utilisé des événements et des rappels pour contourner ce problème. Voici les événements:

var img1 = document.querySelector('.img-1');

img1.addEventListener('load', function() {
  // woo yey image loaded
});

img1.addEventListener('error', function() {
  // argh everything's broken
});

C'est très léger. Nous obtenons l'image, ajoutons quelques écouteurs, puis JavaScript peut arrêter de s'exécuter jusqu'à ce que l'un de ces écouteurs soit appelé.

Malheureusement, dans l'exemple ci-dessus, il est possible que les événements se soient produits avant que nous ne commencions à les écouter. Nous devons donc contourner ce problème en utilisant la propriété "complete" des images:

var img1 = document.querySelector('.img-1');

function loaded() {
  // woo yey image loaded
}

if (img1.complete) {
  loaded();
}
else {
  img1.addEventListener('load', loaded);
}

img1.addEventListener('error', function() {
  // argh everything's broken
});

Cela ne permet pas d'identifier les images erronées avant que nous ayons eu l'occasion de les écouter. Malheureusement, le DOM ne nous permet pas de le faire. Elle charge aussi une image. Les choses deviennent encore plus complexes si nous voulons savoir quand un ensemble d'images a été chargé.

Les événements ne sont pas toujours le meilleur moyen

Les événements sont parfaits pour les événements qui peuvent se produire plusieurs fois sur le même objet (keyup, touchstart, etc.). Avec ces événements, vous ne vous souciez pas vraiment de ce qui s'est passé avant d'associer l'écouteur. Mais lorsqu'il s'agit de succès ou d'échec asynchrones, l'idéal est de ressembler à ceci:

img1.callThisIfLoadedOrWhenLoaded(function() {
  // loaded
}).orIfFailedCallThis(function() {
  // failed
});

// and…
whenAllTheseHaveLoaded([img1, img2]).callThis(function() {
  // all loaded
}).orIfSomeFailedCallThis(function() {
  // one or more failed
});

C'est ce que font les promesses, mais avec une meilleure dénomination. Si les éléments d'image HTML comportaient une méthode "ready" qui renvoyait une promesse, nous pourrions le faire:

img1.ready()
.then(function() {
  // loaded
}, function() {
  // failed
});

// and…
Promise.all([img1.ready(), img2.ready()])
.then(function() {
  // all loaded
}, function() {
  // one or more failed
});

Dans leur état de base, les promesses ressemblent un peu aux écouteurs d'événements, sauf:

  • Une promesse ne peut réussir ou échouer qu'une seule fois. Elle ne peut pas réussir ni échouer deux fois, ni passer du succès à l'échec, ou inversement.
  • Si une promesse a réussi ou échoué et que vous ajoutez ultérieurement un rappel de réussite/échec, le rappel approprié est appelé, même si l'événement a eu lieu plus tôt.

Cela est extrêmement utile en cas de réussite ou d'échec asynchrone, car vous êtes moins intéressé par l'heure exacte à laquelle un élément est disponible, et plus intéressé par la réaction au résultat.

Terminologie liée à la promesse

Domenic Denicola a lu la première ébauche de cet article et m'a noté "F" pour la terminologie. Il m'a mis en détention, m'a forcé à copier States and Fates 100 fois, et a écrit une lettre inquiète à mes parents. Malgré cela, j'ai encore beaucoup de termes mélangés, mais voici les bases:

Une promesse peut être:

  • fulfillment : action liée à la promesse réussie
  • rejected : l'action liée à la promesse a échoué.
  • pending : l'opération n'a pas encore été traitée ni refusée
  • settled : traitement ou refus

La spécification utilise également le terme thenable pour décrire un objet de type promesse, dans la mesure où il possède une méthode then. Ce terme me rappelle l'ancien manager de football anglais Terry Venables. Je vais donc l'utiliser le moins possible.

Les promesses arrivent en JavaScript !

Les promesses existent depuis un certain temps sous la forme de bibliothèques, telles que:

Les promesses ci-dessus et JavaScript partagent un comportement commun et standardisé appelé Promises/A+. Si vous utilisez jQuery, elles ont un comportement similaire appelé Deferreds. Cependant, les éléments Deferred ne sont pas compatibles avec Promise/A+, ce qui les rend légèrement différents et moins utiles. Attention : jQuery possède également un type Promise, mais il ne s'agit que d'un sous-ensemble de Deferred qui présente les mêmes problèmes.

Bien que les implémentations de promesses suivent un comportement standardisé, leurs API globales diffèrent. Dans l'API, les promesses JavaScript sont similaires à RSVP.js. Voici comment établir une promesse:

var promise = new Promise(function(resolve, reject) {
  // do a thing, possibly async, then…

  if (/* everything turned out fine */) {
    resolve("Stuff worked!");
  }
  else {
    reject(Error("It broke"));
  }
});

Le constructeur de promesse utilise un argument, un rappel avec deux paramètres, "resolve" (Résoudre) et "Rejet" (refus). Effectuez une opération dans le rappel, peut-être asynchrone, puis appelez la méthode "resolve" si tout a bien fonctionné. Sinon, vous appelez la méthode "refus".

Comme throw dans l'ancien JavaScript brut, il est courant, mais pas obligatoire, d'être rejeté avec un objet Error. L'avantage des objets Error est qu'ils capturent une trace de la pile, ce qui rend les outils de débogage plus utiles.

Voici comment vous respectez cette promesse:

promise.then(function(result) {
  console.log(result); // "Stuff worked!"
}, function(err) {
  console.log(err); // Error: "It broke"
});

then() utilise deux arguments : un rappel pour un cas de réussite et un autre pour le cas d'échec. Les deux sont facultatifs. Vous ne pouvez donc ajouter un rappel qu'en cas de réussite ou d'échec.

Les promesses JavaScript ont commencé dans le DOM sous le nom "Futures", renommées "Promises", puis sont finalement transférées vers JavaScript. Il est intéressant de les avoir en JavaScript plutôt qu'avec le DOM, car ils seront disponibles dans des contextes JS hors navigateur, tels que Node.js. (L'utilisation de ces éléments dans leurs API principales est une autre question.)

Bien qu'il s'agisse d'une fonctionnalité JavaScript, le DOM n'a pas peur de les utiliser. En fait, toutes les nouvelles API DOM avec des méthodes de réussite/échec asynchrones utiliseront des promesses. Cela se produit déjà avec la gestion des quotas, les événements de chargement de police, ServiceWorker, Web MIDI, les flux, etc.

Compatibilité avec d'autres bibliothèques

JavaScript promet que l'API traitera tout ce qui est associé à une méthode then() comme une promesse (ou thenable dans un soupir prononcé). Par conséquent, si vous utilisez une bibliothèque qui renvoie une promesse Q, ce n'est pas un problème. Elle fonctionnera correctement avec les nouvelles promesses JavaScript.

Comme je l'ai déjà dit, les paramètres différés de jQuery sont un peu... inutiles. Heureusement, vous pouvez les caster en promesses standards, ce qui peut être utile dès que possible:

var jsPromise = Promise.resolve($.ajax('/whatever.json'))

Ici, l'élément $.ajax de jQuery renvoie un élément Deferred. Comme elle possède une méthode then(), Promise.resolve() peut la transformer en promesse JavaScript. Toutefois, les éléments différés transmettent parfois plusieurs arguments à leurs rappels, par exemple:

var jqDeferred = $.ajax('/whatever.json');

jqDeferred.then(function(response, statusText, xhrObj) {
  // ...
}, function(xhrObj, textStatus, err) {
  // ...
})

Alors que les promesses JS ignorent toutes les promesses, sauf la première:

jsPromise.then(function(response) {
  // ...
}, function(xhrObj) {
  // ...
})

Heureusement, c'est généralement ce que vous voulez, ou du moins, vous donne accès à ce que vous voulez. Sachez également que jQuery ne suit pas la convention de transmission d'objets Error dans les rejets.

Code asynchrone complexe simplifié

Bien, codons quelques choses. Disons que nous voulons:

  1. Lancer une icône de chargement pour indiquer le chargement
  2. Récupérez un fichier JSON pour une histoire, qui nous donne le titre et les URL de chaque chapitre
  3. Ajouter un titre à la page
  4. Récupérer chaque chapitre
  5. Ajouter l'histoire à la page
  6. Arrêter l'icône de chargement

... mais aussi dire à l'utilisateur si quelque chose s'est mal passé en cours de route. Nous devrons également arrêter l'icône de chargement à ce stade, sinon elle continuera à tourner, étourdira et plantera sur une autre interface utilisateur.

Bien entendu, vous n'utiliseriez pas JavaScript pour livrer une histoire, la diffusion au format HTML est plus rapide, mais ce modèle est assez courant dans le cas des API: plusieurs récupérations de données, puis une action une fois l'opération terminée.

Pour commencer, parlons de la récupération de données à partir du réseau:

Proposer XMLHttpRequest

Les anciennes API seront mises à jour pour utiliser des promesses, si cela est possible d'une manière rétrocompatible. XMLHttpRequest est un candidat idéal, mais en attendant, écrivons une fonction simple pour effectuer une requête GET:

function get(url) {
  // Return a new promise.
  return new Promise(function(resolve, reject) {
    // Do the usual XHR stuff
    var req = new XMLHttpRequest();
    req.open('GET', url);

    req.onload = function() {
      // This is called even on 404 etc
      // so check the status
      if (req.status == 200) {
        // Resolve the promise with the response text
        resolve(req.response);
      }
      else {
        // Otherwise reject with the status text
        // which will hopefully be a meaningful error
        reject(Error(req.statusText));
      }
    };

    // Handle network errors
    req.onerror = function() {
      reject(Error("Network Error"));
    };

    // Make the request
    req.send();
  });
}

Maintenant, utilisons-le:

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.error("Failed!", error);
})

Nous pouvons désormais effectuer des requêtes HTTP sans saisir manuellement XMLHttpRequest, ce qui est très bien, car moins je dois voir l'enchaînement furtif en camel cases de XMLHttpRequest, plus ma vie sera heureuse.

Chaîne

then() n'est pas la fin de l'histoire. Vous pouvez enchaîner des then pour transformer les valeurs ou exécuter des actions asynchrones supplémentaires l'une après l'autre.

Transformer des valeurs

Vous pouvez transformer des valeurs simplement en renvoyant la nouvelle valeur:

var promise = new Promise(function(resolve, reject) {
  resolve(1);
});

promise.then(function(val) {
  console.log(val); // 1
  return val + 2;
}).then(function(val) {
  console.log(val); // 3
})

À titre d'exemple pratique, revenons à:

get('story.json').then(function(response) {
  console.log("Success!", response);
})

La réponse est au format JSON, mais nous la recevons actuellement sous forme de texte brut. Nous pourrions modifier notre fonction get pour utiliser la méthode JSON responseType, mais nous pourrions également résoudre ce problème en faisant des promesses arrivées:

get('story.json').then(function(response) {
  return JSON.parse(response);
}).then(function(response) {
  console.log("Yey JSON!", response);
})

Étant donné que JSON.parse() accepte un seul argument et renvoie une valeur transformée, nous pouvons créer un raccourci:

get('story.json').then(JSON.parse).then(function(response) {
  console.log("Yey JSON!", response);
})

En fait, nous pourrions créer une fonction getJSON() très facilement:

function getJSON(url) {
  return get(url).then(JSON.parse);
}

getJSON() renvoie toujours une promesse qui extrait une URL, puis analyse la réponse au format JSON.

Mettre en file d'attente des actions asynchrones

Vous pouvez également enchaîner des then pour exécuter des actions asynchrones les unes après les autres.

Lorsque vous renvoyez un élément à partir d'un rappel then(), c'est magique. Si vous renvoyez une valeur, l'élément then() suivant est appelé avec cette valeur. Toutefois, si vous renvoyez un élément de type promesse, l'élément then() suivant l'attend et n'est appelé que lorsque cette promesse se stabilise (réussite/échec). Exemple :

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  console.log("Got chapter 1!", chapter1);
})

Ici, nous envoyons une requête asynchrone à story.json, ce qui nous donne un ensemble d'URL à demander, puis nous demandons la première d'entre elles. C'est à ce moment-là que les promesses commencent vraiment à se démarquer des modèles de rappel simples.

Vous pouvez même créer un raccourci pour accéder aux chapitres:

var storyPromise;

function getChapter(i) {
  storyPromise = storyPromise || getJSON('story.json');

  return storyPromise.then(function(story) {
    return getJSON(story.chapterUrls[i]);
  })
}

// and using it is simple:
getChapter(0).then(function(chapter) {
  console.log(chapter);
  return getChapter(1);
}).then(function(chapter) {
  console.log(chapter);
})

Nous ne téléchargeons story.json qu'une fois que getChapter est appelé, mais la prochaine fois que getChapter est appelé, nous réutilisons la promesse d'histoire. Par conséquent, story.json n'est extrait qu'une seule fois. Super promesses !

Gestion des exceptions

Comme nous l'avons vu précédemment, then() utilise deux arguments : un pour la réussite, un pour l'échec (ou l'acceptation et le rejet, en prononçant vos paroles) :

get('story.json').then(function(response) {
  console.log("Success!", response);
}, function(error) {
  console.log("Failed!", error);
})

Vous pouvez également utiliser catch():

get('story.json').then(function(response) {
  console.log("Success!", response);
}).catch(function(error) {
  console.log("Failed!", error);
})

Il n'y a rien de spécial à propos de catch(). C'est juste du sucre pour then(undefined, func), mais il est plus lisible. Notez que les deux exemples de code ci-dessus ne se comportent pas de la même manière. Ce dernier équivaut à:

get('story.json').then(function(response) {
  console.log("Success!", response);
}).then(undefined, function(error) {
  console.log("Failed!", error);
})

La différence est subtile, mais extrêmement utile. Les refus de promesses sont reportés au then() suivant avec un rappel de refus (ou catch(), car il est équivalent). Avec then(func1, func2), func1 ou func2 sera appelé, jamais les deux. Toutefois, avec then(func1).catch(func2), les deux seront appelés si func1 est refusé, car il s'agit d'étapes distinctes de la chaîne. Prenez les éléments suivants:

asyncThing1().then(function() {
  return asyncThing2();
}).then(function() {
  return asyncThing3();
}).catch(function(err) {
  return asyncRecovery1();
}).then(function() {
  return asyncThing4();
}, function(err) {
  return asyncRecovery2();
}).catch(function(err) {
  console.log("Don't worry about it");
}).then(function() {
  console.log("All done!");
})

Le flux ci-dessus est très semblable à la méthode try/catch JavaScript normale. Les erreurs qui se produisent au cours d'un "try" sont immédiatement envoyées au bloc catch(). Voici ce qui précède sous forme d’organigramme (car j'adore les organigrammes):

Suivez les lignes bleues pour les promesses qui se respectent, ou les lignes rouges pour celles qui sont refusées.

Exceptions et promesses JavaScript

Les rejets se produisent lorsqu'une promesse est explicitement rejetée, mais également implicitement si une erreur est générée dans le rappel du constructeur:

var jsonPromise = new Promise(function(resolve, reject) {
  // JSON.parse throws an error if you feed it some
  // invalid JSON, so this implicitly rejects:
  resolve(JSON.parse("This ain't JSON"));
});

jsonPromise.then(function(data) {
  // This never happens:
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Cela signifie qu'il est utile d'effectuer tout le travail lié aux promesses dans le rappel du constructeur de la promesse, afin que les erreurs soient automatiquement détectées et deviennent des refus.

Il en va de même pour les erreurs générées dans les rappels then().

get('/').then(JSON.parse).then(function() {
  // This never happens, '/' is an HTML page, not JSON
  // so JSON.parse throws
  console.log("It worked!", data);
}).catch(function(err) {
  // Instead, this happens:
  console.log("It failed!", err);
})

Gestion des erreurs en pratique

Avec notre histoire et nos chapitres, nous pouvons utiliser Catch pour afficher une erreur à l'utilisateur:

getJSON('story.json').then(function(story) {
  return getJSON(story.chapterUrls[0]);
}).then(function(chapter1) {
  addHtmlToPage(chapter1.html);
}).catch(function() {
  addTextToPage("Failed to show chapter");
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Si la récupération de story.chapterUrls[0] échoue (par exemple, si http 500 ou l'utilisateur est hors connexion), elle ignore tous les rappels de réussite suivants, y compris celui de getJSON() qui tente d'analyser la réponse au format JSON et ignore également le rappel qui ajoute chap.html à la page. Au lieu de cela, il passe au rappel "catch". Par conséquent, la mention "Failed to show chapitre" (Échec de l'affichage du chapitre) est ajoutée à la page si l'une des actions précédentes a échoué.

Comme pour la méthode try/catch de JavaScript, l'erreur est détectée et le code suivant se poursuit. L'icône de chargement est donc toujours masquée, comme nous le souhaitons. Ce qui précède devient une version asynchrone non bloquante de:

try {
  var story = getJSONSync('story.json');
  var chapter1 = getJSONSync(story.chapterUrls[0]);
  addHtmlToPage(chapter1.html);
}
catch (e) {
  addTextToPage("Failed to show chapter");
}
document.querySelector('.spinner').style.display = 'none'

Vous pouvez exécuter la commande catch() uniquement à des fins de journalisation, sans pour autant récupérer l'erreur. Pour ce faire, il vous suffit de renvoyer l'erreur. Nous pourrions le faire dans notre méthode getJSON():

function getJSON(url) {
  return get(url).then(JSON.parse).catch(function(err) {
    console.log("getJSON failed for", url, err);
    throw err;
  });
}

Nous avons réussi à récupérer un chapitre, mais nous voulons tous les chapitres. Faisons en sorte que cela se produise.

Parallélisme et séquencement: tirer le meilleur parti des deux

Ce n’est pas facile de penser « asynchrone ». Si vous rencontrez des difficultés, essayez d'écrire le code comme s'il était synchrone. Dans ce cas :

try {
  var story = getJSONSync('story.json');
  addHtmlToPage(story.heading);

  story.chapterUrls.forEach(function(chapterUrl) {
    var chapter = getJSONSync(chapterUrl);
    addHtmlToPage(chapter.html);
  });

  addTextToPage("All done");
}
catch (err) {
  addTextToPage("Argh, broken: " + err.message);
}

document.querySelector('.spinner').style.display = 'none'

Ça marche ! Mais c'est la synchronisation qui verrouille le navigateur pendant le téléchargement. Pour rendre ce fonctionnement asynchrone, nous utilisons then() pour que les choses se produisent l'une après l'autre.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // TODO: for each url in story.chapterUrls, fetch & display
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Mais comment parcourir les URL des chapitres et les extraire dans l'ordre ? Cela ne fonctionne pas:

story.chapterUrls.forEach(function(chapterUrl) {
  // Fetch chapter
  getJSON(chapterUrl).then(function(chapter) {
    // and add it to the page
    addHtmlToPage(chapter.html);
  });
})

forEach n'est pas sensible à l'asynchrone. Par conséquent, les chapitres s'affichent dans l'ordre dans lequel ils sont téléchargés, ce qui correspond essentiellement à l'écriture de Pulp Fiction. Ce n'est pas Pulp Fiction, résolvons ce problème.

Créer une séquence

Nous voulons transformer notre tableau chapterUrls en une séquence de promesses. Pour ce faire, nous pouvons utiliser then():

// Start off with a promise that always resolves
var sequence = Promise.resolve();

// Loop through our chapter urls
story.chapterUrls.forEach(function(chapterUrl) {
  // Add these actions to the end of the sequence
  sequence = sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
})

C'est la première fois que nous voyons Promise.resolve(), qui crée une promesse qui se résout en fonction de la valeur que vous lui donnez. Si vous lui transmettez une instance de Promise, elle la renvoie simplement (remarque:il s'agit d'une modification de la spécification que certaines implémentations ne suivent pas encore). Si vous lui transmettez quelque chose de type promesse (comporte une méthode then()), il crée un Promise authentique qui répond/rejette de la même manière. Si vous transmettez une autre valeur, Promise.resolve('Hello'), il crée une promesse qui respecte cette valeur. Si vous l'appelez sans valeur, comme ci-dessus, il renvoie "undefined".

Il existe également Promise.reject(val), qui crée une promesse qui est rejetée avec la valeur que vous lui donnez (ou non définie).

Nous pouvons nettoyer le code ci-dessus à l'aide de array.reduce:

// Loop through our chapter urls
story.chapterUrls.reduce(function(sequence, chapterUrl) {
  // Add these actions to the end of the sequence
  return sequence.then(function() {
    return getJSON(chapterUrl);
  }).then(function(chapter) {
    addHtmlToPage(chapter.html);
  });
}, Promise.resolve())

C'est le même que dans l'exemple précédent, mais la variable "Sequence" distincte n'est pas nécessaire. Notre rappel "réduction" est appelé pour chaque élément du tableau. La valeur du champ "Sequence" est Promise.resolve() la première fois, mais pour le reste des appels, "sequence" correspond à l'élément renvoyé de l'appel précédent. array.reduce est vraiment utile pour réduire un tableau à une seule valeur, ce qui, dans le cas présent, est une promesse.

Récapitulons le tout:

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  return story.chapterUrls.reduce(function(sequence, chapterUrl) {
    // Once the last chapter's promise is done…
    return sequence.then(function() {
      // …fetch the next chapter
      return getJSON(chapterUrl);
    }).then(function(chapter) {
      // and add it to the page
      addHtmlToPage(chapter.html);
    });
  }, Promise.resolve());
}).then(function() {
  // And we're all done!
  addTextToPage("All done");
}).catch(function(err) {
  // Catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  // Always hide the spinner
  document.querySelector('.spinner').style.display = 'none';
})

Et voilà, une version entièrement asynchrone de la version de synchronisation. Mais nous pouvons faire mieux. Actuellement, notre page est téléchargée comme ceci:

Les navigateurs sont particulièrement efficaces pour télécharger plusieurs éléments à la fois. Nous perdons donc les performances en téléchargeant les chapitres les uns après les autres. Ce que nous voulons faire, c'est les télécharger tous en même temps, puis les traiter une fois qu'ils sont arrivés. Heureusement, il existe une API pour cela:

Promise.all(arrayOfPromises).then(function(arrayOfResults) {
  //...
})

Promise.all prend un tableau de promesses et crée une promesse qui s'exécute lorsque toutes les deux se terminent avec succès. Vous obtenez un tableau de résultats (quelles que soient les promesses tenues) dans le même ordre que les promesses que vous avez transmises.

getJSON('story.json').then(function(story) {
  addHtmlToPage(story.heading);

  // Take an array of promises and wait on them all
  return Promise.all(
    // Map our array of chapter urls to
    // an array of chapter json promises
    story.chapterUrls.map(getJSON)
  );
}).then(function(chapters) {
  // Now we have the chapters jsons in order! Loop through…
  chapters.forEach(function(chapter) {
    // …and add to the page
    addHtmlToPage(chapter.html);
  });
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened so far
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Selon la connexion, cela peut prendre quelques secondes plus vite que le chargement individuellement, et c'est moins de code que notre première tentative. Les chapitres peuvent être téléchargés dans n'importe quel ordre, mais ils apparaissent à l'écran dans le bon ordre.

Toutefois, nous pouvons toujours améliorer les performances perçues. Lorsque le chapitre 1 arrive, nous devrions l'ajouter à la page. Cela permet à l'utilisateur de commencer à lire avant la fin des chapitres. Lorsque le chapitre 3 arrive, nous ne l'ajouterons pas à la page, car l'utilisateur ne réalisera peut-être pas que le chapitre 2 est manquant. À l'arrivée du chapitre deux, nous pouvons ajouter les chapitres deux et trois, etc.

Pour ce faire, nous récupérons le fichier JSON pour tous nos chapitres en même temps, puis nous créons une séquence pour les ajouter au document:

getJSON('story.json')
.then(function(story) {
  addHtmlToPage(story.heading);

  // Map our array of chapter urls to
  // an array of chapter json promises.
  // This makes sure they all download in parallel.
  return story.chapterUrls.map(getJSON)
    .reduce(function(sequence, chapterPromise) {
      // Use reduce to chain the promises together,
      // adding content to the page for each chapter
      return sequence
      .then(function() {
        // Wait for everything in the sequence so far,
        // then wait for this chapter to arrive.
        return chapterPromise;
      }).then(function(chapter) {
        addHtmlToPage(chapter.html);
      });
    }, Promise.resolve());
}).then(function() {
  addTextToPage("All done");
}).catch(function(err) {
  // catch any error that happened along the way
  addTextToPage("Argh, broken: " + err.message);
}).then(function() {
  document.querySelector('.spinner').style.display = 'none';
})

Et voilà, le meilleur des deux ! La diffusion de tout le contenu prend le même temps, mais l'utilisateur obtient le premier contenu plus tôt.

Dans cet exemple anodin, tous les chapitres arrivent à peu près au même moment, mais l'avantage d'en afficher un à la fois sera exagéré avec plus de chapitres plus grands.

Effectuer la procédure ci-dessus avec des rappels ou événements de style Node.js revient à doubler le code, mais surtout, elle n'est pas aussi facile à suivre. Cependant, ce n'est pas la fin de l'histoire des promesses, lorsqu'elles sont encore plus simples lorsqu'elles sont combinées à d'autres fonctionnalités ES6.

Bonus: capacités étendues

Depuis que j'ai écrit cet article à l'origine, la possibilité d'utiliser des promesses s'est considérablement étendue. Depuis Chrome 55, les fonctions asynchrones permettent d'écrire du code basé sur des promesses comme s'il était synchrone, mais sans bloquer le thread principal. Pour en savoir plus à ce sujet, consultez l'article my async functions article. Les promesses et les fonctions asynchrones sont largement prises en charge dans les principaux navigateurs. Pour en savoir plus, consultez la documentation de référence de MDN sur Promise et fonction asynchrone.

Un grand merci à Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans et Yutaka Hirano qui ont relu ce document et apporté des corrections/recommandations.

Merci également à Mathias Bynens pour avoir mis à jour certaines parties de cet article.