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 Archibald
Jake Archibald

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

[Drumroll begins]

Les promesses sont arrivées dans JavaScript !

[Feux d'artifice, pluie de papier pailleté, la foule est en délire]

À ce stade, vous appartenez à l'une des catégories suivantes :

  • Les gens autour de vous applaudissent, mais vous ne savez pas pourquoi. Vous ne savez peut-être même pas ce qu'est une "promesse". Vous augmenteriez les épaules, mais le poids du papier scintillant pèse sur vos épaules. Si c'est le cas, ne vous inquiétez pas, il m'a fallu du temps pour déterminer pourquoi je devais m'intéresser à ce sujet. Vous allez probablement commencer par le début.
  • Vous frappez l'air. Il était temps, non ? Vous avez déjà utilisé ces éléments de promesse, mais vous êtes gêné par le fait que toutes les implémentations ont une API légèrement différente. Quelle est l'API de la version JavaScript officielle ? Vous devriez 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. Profitez un instant de votre supériorité, puis consultez directement la documentation de référence de l'API.

Prise en charge des navigateurs et polyfill

Navigateurs pris en charge

  • Chrome : 32.
  • Edge : 12.
  • Firefox: 29
  • Safari : 8.

Source

Pour mettre en conformité avec les spécifications des navigateurs dont la mise en œuvre des promesses n'est pas complète, ou ajouter des promesses à d'autres navigateurs et à Node.js, consultez le polyfill (2 000 fichiers compressés avec gzip).

De quoi s'agit-il ?

JavaScript est monothread, ce qui signifie que deux bits de 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 l'application d'une peinture, la mise à jour des styles et la gestion des actions des utilisateurs (comme la mise en surbrillance du texte et l'interaction avec les commandes de formulaire). Toute activité liée à 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 discuter en même temps. La seule fonction bloquante que nous ayons à gérer est l'éternuement, où toute activité en cours doit être suspendue pendant toute la durée de l'éternuement. C'est assez ennuyeux, surtout lorsque vous conduisez et essayez de tenir une conversation. Vous ne voulez pas écrire de code qui est difficile à lire.

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
});

Ce n'est pas du tout un problème. Nous obtenons l'image, ajoutons quelques écouteurs, puis JavaScript peut arrêter son exécution 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 commencions à les écouter. Nous devons donc contourner ce problème à l'aide de 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 détecte pas les images erronées avant que nous ayons pu les écouter. Malheureusement, le DOM ne nous permet pas de le faire. De plus, une seule image est chargée. Les choses se compliquent encore plus si nous voulons savoir quand un ensemble d'images a été chargé.

Les événements ne sont pas toujours la meilleure solution

Les événements sont parfaits pour les choses 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 en ce qui concerne la réussite ou l'échec de l'activité asynchrone, idéalement, vous devez obtenir un résultat semblable à celui-ci:

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 des noms mieux adaptés. Si les éléments d'image HTML disposaient d'une méthode "ready" qui renvoyait une promesse, nous pourrions procéder comme suit :

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 forme la plus simple, les promesses sont un peu comme des écouteurs d'événements, à quelques exceptions près :

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

Cela est extrêmement utile pour le succès/l'échec asynchrone, car vous êtes moins intéressé par l'heure exacte à laquelle un élément est devenu disponible et plus intéressé par la réaction au résultat.

Terminologie liée aux promesses

Domenic Denicola a relu la première version de cet article et m'a attribué la note "F" pour la terminologie. Il m'a mis en retenue, m'a forcé à recopier États et destins 100 fois et a écrit une lettre d'inquiétude à mes parents. Malgré cela, je confonds encore beaucoup de termes, mais voici les bases :

Une promesse peut être:

  • fulfillment : l'action relative à la promesse a été effectuée.
  • rejected : l'action relative à la promesse a échoué.
  • pending (en attente) : n'a pas encore été traité ou refusé.
  • settled : a été traité ou refusé

La spécification utilise également le terme thenable pour décrire un objet semblable à une promesse, en ce sens qu'il possède une méthode then. Ce terme me rappelle l'ancien entraîneur de l'équipe de football d'Angleterre Terry Venables. Je vais donc l'utiliser le moins possible.

Les promesses arrivent dans JavaScript !

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

Les promesses ci-dessus et JavaScript partagent un comportement commun et standardisé appelé Promises/A+. Si vous utilisez jQuery, ils ont un comportement similaire appelé Deferreds. Toutefois, les Deferreds ne sont pas conformes à Promise/A+, ce qui les rend légèrement différents et moins utiles. Faites donc attention. jQuery dispose également d'un type Promise, mais il ne s'agit que d'un sous-ensemble de Deferred et présente les mêmes problèmes.

Bien que les implémentations prometteuses suivent un comportement standardisé, leurs API globales diffèrent. Les promesses JavaScript sont similaires à l'API RSVP.js. Voici comment créer 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 accepte un argument, un rappel avec deux paramètres, "resolve" et "reject". Effectuez une action dans le rappel, peut-être asynchrone, puis appelez "resolve" (résolution) si tout a fonctionné, ou "réjeter".

Comme throw dans le bon vieux JavaScript, il est courant, mais pas obligatoire, de rejeter avec un objet Error. L'avantage des objets Error est qu'ils capturent une trace de pile, ce qui rend les outils de débogage plus utiles.

Voici comment utiliser cette promesse :

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

then() prend deux arguments, un rappel pour un cas de réussite et un autre pour le cas d'échec. Les deux sont facultatifs. Vous pouvez donc ajouter un rappel uniquement pour le cas de réussite ou d'échec.

Les promesses JavaScript ont commencé dans le DOM sous le nom de "Futures", ont été rebaptisées "Promises" et ont finalement été transférées vers JavaScript. Les utiliser en JavaScript plutôt que dans le DOM est une bonne solution, car ils seront disponibles dans des contextes JavaScript autres que les navigateurs, tels que Node.js (une autre question leur permettra de savoir si elles les utilisent dans leurs API principales).

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

Compatibilité avec d'autres bibliothèques

L'API JavaScript Promises traitera tout élément avec une méthode then() comme une promesse (ou thenable en langage de promesse soupir). Par conséquent, si vous utilisez une bibliothèque qui renvoie une promesse Q, ce n'est pas un problème, elle fonctionnera bien avec les nouvelles promesses JavaScript.

Cependant, comme je l'ai mentionné, les Deferreds de jQuery sont un peu… inutiles. Heureusement, vous pouvez les appliquer à des promesses standards, ce qui vaut la peine d'être effectué dès que possible:

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

Ici, $.ajax de jQuery renvoie un Deferred. Comme il dispose d'une méthode then(), Promise.resolve() peut le transformer en promesse JavaScript. Toutefois, les 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 JavaScript 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 cela vous donne accès à ce que vous voulez. Sachez également que jQuery ne suit pas la convention consistant à transmettre des objets d'erreur dans les refus.

Code asynchrone complexe simplifié

Très bien, commençons à coder. Disons que nous voulons :

  1. Démarrer une animation de chargement
  2. Récupérez du code JSON pour une histoire, ce qui nous donne le titre et les URL de chaque chapitre.
  3. Ajouter un titre à la page
  4. Explorez chaque chapitre
  5. Ajouter la story à la page
  6. Arrêter la roue de sélection

… mais indiquez également à l'utilisateur si un problème est survenu. Nous devons également arrêter la roue de sélection à ce stade, sinon elle continuera de tourner, de tourner et de s'écraser sur une autre interface utilisateur.

Bien entendu, vous n'utiliserez pas JavaScript pour diffuser une histoire, car le rendu en HTML est plus rapide, mais ce modèle est assez courant lorsque vous travaillez avec des API : plusieurs récupérations de données, puis effectuez une action une fois que tout est terminé.

Commençons par récupérer des données à partir du réseau :

La requête XMLHttpRequest est prometteuse

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();
  });
}

Utilisons-le maintenant :

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

Nous pouvons désormais envoyer des requêtes HTTP sans saisir manuellement XMLHttpRequest, ce qui est excellent, car moins je dois voir la casse camel 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 des valeurs ou exécuter des actions asynchrones supplémentaires l'une après l'autre.

Transformer les 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
})

Pour illustrer la pratique, revenons à :

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

La réponse est au format JSON, mais nous la recevons actuellement en texte brut. Nous pourrions modifier notre fonction get pour utiliser le responseType JSON, mais nous pourrions également résoudre le problème dans le monde des promesses :

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

Comme 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 de manière séquentielle.

Lorsque vous renvoyez quelque chose à partir d'un rappel then(), c'est un peu magique. Si vous renvoyez une valeur, le then() suivant est appelé avec cette valeur. Toutefois, si vous renvoyez quelque chose qui ressemble à une promesse, le then() suivant l'attend et n'est appelé que lorsque cette promesse est résolue (réussie/échouée). 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, 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 une méthode de raccourci pour obtenir les 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 pas story.json tant que getChapter n'est pas appelé, mais la ou les prochaines fois que getChapter est appelé, nous réutilisons la promesse d'histoire. story.json n'est donc récupéré qu'une seule fois. Waouh, promesses !

Gestion des exceptions

Comme nous l'avons vu précédemment, then() utilise deux arguments, un pour la réussite, l'autre pour l'échec (ou pour traiter et rejeter, comme promis):

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);
})

catch() n'a rien de spécial. Il s'agit simplement d'un 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 est équivalent à:

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 passent au then() suivant avec un rappel de refus (ou catch(), car il s'agit de l'équivalent). Avec then(func1, func2), func1 ou func2 sera appelé, mais jamais les deux. Mais avec then(func1).catch(func2), les deux sont appelés en cas de refus de func1, car ce sont des étapes distinctes de la chaîne. Tenez compte des points 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 try/catch JavaScript normale. Les erreurs qui se produisent dans un "try" sont immédiatement transférées vers le bloc catch(). Voici l'organigramme ci-dessus (car j'adore les organigrammes):

Suivez les lignes bleues pour les promesses qui sont remplies ou les lignes rouges pour celles qui sont rejetées.

Exceptions et promesses JavaScript

Les refus se produisent lorsqu'une promesse est explicitement refusée, mais aussi 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 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 l'extraction de story.chapterUrls[0] échoue (par exemple, http 500 ou l'utilisateur est hors connexion), tous les rappels de succès suivants sont ignorés, y compris celui de getJSON() qui tente d'analyser la réponse au format JSON, et le rappel qui ajoute chapter1.html à la page est également ignoré. Au lieu de cela, il passe au rappel de capture. Par conséquent, le message "Échec de l'affichage du chapitre" s'affichera sur la page si l'une des actions précédentes a échoué.

Comme dans 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, ce qui est ce que nous souhaitons. Le code ci-dessus devient une version asynchrone non bloquante des éléments suivants:

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 utiliser la méthode catch() simplement à des fins de journalisation, sans récupérer l'erreur. Pour ce faire, il 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 à extraire un chapitre, mais nous voulons tous les avoir. Allons-y.

Parallélisme et séquençage : tirer le meilleur parti des deux

Penser de manière asynchrone n'est pas facile. Si vous ne parvenez pas à vous lancer, 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 il se synchronise et verrouille le navigateur pendant le téléchargement. Pour que cela fonctionne de manière 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 pouvons-nous 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 compatible avec l'async, de sorte que nos chapitres s'affichent dans l'ordre dans lequel ils sont téléchargés, ce qui est essentiellement la façon dont Pulp Fiction a été écrit. Ce n'est pas Pulp Fiction, alors résolvons ce problème.

Créer une séquence

Nous voulons transformer notre tableau chapterUrls en séquence de promesses. Pour ce faire, vous pouvez 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 à la valeur que vous lui donnez. Si vous lui transmettez une instance de Promise, il la renverra simplement (Remarque : il s'agit d'un changement de la spécification que certaines implémentations ne respectent pas encore). Si vous lui transmettez quelque chose de semblable à une promesse (dispose d'une méthode then()), cela crée un Promise authentique qui répond/refuse de la même manière. Si vous transmettez une autre valeur, par exemple : Promise.resolve('Hello'), cela crée une promesse qui répond à cette valeur. Si vous l'appelez sans valeur, comme ci-dessus, la valeur "undefined" est renvoyée.

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())

Cette opération est identique à celle de l'exemple précédent, mais ne nécessite pas la variable "sequence" distincte. Notre rappel de réduction est appelé pour chaque élément du tableau. "sequence" est Promise.resolve() la première fois, mais pour le reste des appels, "sequence" est ce que nous avons renvoyé depuis l'appel précédent. array.reduce est très utile pour réduire un tableau à une valeur unique, ce qui, dans ce cas, est une promesse.

Synthèse:

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. Pour le moment, le téléchargement de notre page se présente comme suit :

Les navigateurs sont assez efficaces pour télécharger plusieurs éléments à la fois. Par conséquent, nous perdons des performances en téléchargeant des chapitres les uns après les autres. Nous souhaitons les télécharger tous en même temps, puis les traiter une fois qu'ils sont tous 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 sont terminées. Vous obtenez un tableau de résultats (quels que soient les résultats des promesses remplies) dans l'ordre des 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';
})

En fonction de la connexion, cela peut être plus rapide que le chargement une par une, et cela nécessite moins de code que notre première tentative. Les chapitres peuvent être téléchargés dans n'importe quel ordre, mais ils s'affichent à l'écran dans le bon ordre.

Nous pouvons toutefois améliorer les performances perçues. Lorsque le premier chapitre sera disponible, nous devrions l'ajouter à la page. Cela permet à l'utilisateur de commencer à lire avant que le reste des chapitres ne soit disponible. Lorsque le chapitre 3 sera disponible, nous ne l'ajouterons pas à la page, car l'utilisateur risque de ne pas se rendre compte que le chapitre 2 est manquant. Lorsque le deuxième chapitre arrive, nous pouvons ajouter les chapitres 2 et 3, etc.

Pour ce faire, nous extrayons le code JSON de 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à, vous avez le meilleur des deux ! Il faut le même temps pour diffuser l'intégralité du contenu, mais l'utilisateur reçoit le premier élément de contenu plus rapidement.

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

Effectuer les opérations ci-dessus avec des événements ou rappels de style Node.js double le code, mais surtout, n'est pas aussi facile à suivre. Toutefois, ce n'est pas la fin de l'histoire des promesses. Lorsqu'elles sont combinées à d'autres fonctionnalités ES6, elles deviennent encore plus faciles à utiliser.

Bonus : fonctionnalités étendues

Depuis que j'ai écrit cet article pour la première fois, la possibilité d'utiliser des promesses s'est considérablement développée. 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, consultez l'article Mes fonctions asynchrones. Les principaux navigateurs sont largement compatibles avec les promesses et les fonctions asynchrones. Pour en savoir plus, consultez les pages de référence sur les promesses et les fonctions asynchrones de MDN.

Merci beaucoup à Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans et Yutaka Hirano pour avoir relu cet article et apporté des corrections et des recommandations.

Merci également à Mathias Bynens pour avoir mis à jour différentes parties de l'article.