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.

[Début des roulements de tambour]

Les promesses sont arrivées en JavaScript !

[Un feu d'artifice explose, une pluie de papier scintillant s'écoule d'en haut, la foule se déchaîne]

À ce stade, vous entrez dans l'une de ces catégories:

  • Les gens vous encouragent, mais vous n'êtes pas sûr de comprendre de quoi il s'agit. 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 voudrez probablement commencer par le début.
  • Quel talent ! Il était temps, n'est-ce pas ? Vous avez déjà utilisé ces promesses, mais cela vous ennuie que toutes les implémentations disposent d'une API légèrement différente. Quelle est l'API pour la version JavaScript officielle ? Vous voudrez 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 consultez 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 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 saisir du texte avec plusieurs doigts et piloter une conversation en même temps. La seule fonction de blocage que nous devons gérer est "éternuer", où toute activité en cours doit être suspendue pendant 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 un code imperceptible.

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 éternel. Nous récupérons l'image et ajoutons quelques écouteurs. JavaScript peut cesser 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 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, cela revient à charger 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 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 comportaient une méthode "ready" qui renvoyait une promesse, nous pourrions procéder ainsi:

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 élémentaire, les promesses ressemblent un peu aux écouteurs d'événements, à l'exception des éléments suivants:

  • Une promesse ne peut réussir ou échouer qu'une seule fois. Elle ne peut pas réussir ou échouer deux fois, ni passer de la réussite à l'échec, ni 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 précédemment.

Cela est extrêmement utile en cas de réussite ou d'échec asynchrone, car vous êtes moins intéressé par le moment exact où un produit est devenu disponible, mais plus par la réaction au résultat.

Terminologie liée aux promesses

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 États et destins 100 fois et a écrit une lettre inquiète à mes parents. Malgré cela, une grande partie de la terminologie reste mélangée, 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é.
  • réglé : a été traité ou refusé.

La spécification utilise également le terme thenable pour décrire un objet semblable à une 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 !

Des 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, ils ont un comportement similaire appelé Deferreds. Cependant, les objets différés ne sont pas conformes aux promesses de type Promise/A+, ce qui les rend subtilement différents et moins utiles. Attention, jQuery possède également un type de promesse, 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 semblables dans l'API à Répondre.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 utilise un argument, un rappel avec deux paramètres, "resolve" et "refuser". Effectuez une action dans le rappel (éventuellement asynchrone), puis appelez "resolve" (résoudre) si tout a fonctionné, ou "réjeter".

Comme throw dans le code JavaScript classique, il est courant, mais pas obligatoire, de rejeter 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 pouvez tenir cette promesse:

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

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

Les promesses JavaScript ont débuté dans le DOM sous le nom de "Futures", puis sont renommées "Promes" (Promesses) avant de passer à JavaScript. Les avoir en JavaScript plutôt que dans le DOM est une bonne chose, car ils seront disponibles dans des contextes JS autres que les navigateurs, tels que Node.js (une autre question leur permettra de savoir s'ils les utilisent ou non dans leurs API principales).

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 asynchrones de réussite ou d'échec utiliseront des promesses. Cela se produit déjà avec la gestion des quotas, les événements de chargement de police, ServiceWorker, Web MIDI, Streams, etc.

Compatibilité avec d'autres bibliothèques

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

Cependant, comme je vous l'ai dit, les fichiers différés de jQuery ne sont pas très utiles. 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, le champ $.ajax de jQuery renvoie une valeur Deferred. Comme il dispose d'une méthode then(), Promise.resolve() peut la transformer en promesse JavaScript. Cependant, il arrive que les fichiers différés transmettent plusieurs arguments à leurs rappels, par exemple:

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

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

Alors que JS promet d'ignorer tous les éléments, sauf le premier:

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 respecte pas la convention de transmission d'objets Error (erreur) dans les rejets.

Code asynchrone complexe simplifié

Passons à la programmation. Supposons que nous voulions:

  1. Lancer une icône de chargement pour indiquer le 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 l'histoire à la page
  6. Arrêter l'icône de chargement

... mais aussi indiquer à l'utilisateur si quelque chose s'est mal passé en cours de route. Nous devons également arrêter l'icône de chargement à ce stade pour qu'elle continue de tourner, qu'elle ait des vertiges et qu'elle plante sur une autre interface utilisateur.

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

Pour commencer, voyons comment 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 maintenant effectuer des requêtes HTTP sans saisir manuellement XMLHttpRequest, ce qui est très bien, car moins il me faut voir la casse de chameau agaçante 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 des valeurs

Vous pouvez transformer les 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 en texte brut. Nous pourrions modifier notre fonction get pour utiliser le fichier JSON responseType, mais nous pourrions également le résoudre dans le champ 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 même créer très facilement une fonction getJSON():

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

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

Mettre des actions asynchrones en file d'attente

Vous pouvez également enchaîner des then pour exécuter des actions asynchrones dans l'ordre.

Lorsque vous renvoyez un élément à partir d'un rappel then(), c'est un peu magique. Si vous renvoyez une valeur, l'élément then() suivant est appelé avec cette valeur. Toutefois, si vous renvoyez un élément semblable à une promesse, le then() suivant l'attend et n'est appelé que lorsque cette promesse aboutit (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, 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 pas story.json tant que getChapter n'est pas appelé, mais lors de l'appel de getChapter suivant, nous réutilisons la promesse de l'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 de 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 sont appelés, 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. Prenez les mesures suivantes:

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 dans un mot clé "try" sont immédiatement transmises au bloc catch(). Voici l'organigramme ci-dessus (car j'adore les organigrammes):

Suivez les lignes bleues pour les promesses qui sont tenues ou les lignes rouges pour celles qui sont refusées.

Exceptions et promesses JavaScript

Les refus se produisent lorsqu'une promesse est explicitement refusé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 toute votre tâche liée à la promesse 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);
})

Traitement des erreurs en pratique

Avec notre histoire et nos chapitres, nous pouvons utiliser catch pour présenter 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), tous les rappels de réussite suivants sont ignorés, y compris celui de getJSON(), qui tente d'analyser la réponse au format JSON et ignore le rappel qui ajoute chapitre1.html à la page. Au lieu de cela, il passe au rappel "catch". Par conséquent, la mention "Échec de l'affichage du chapitre" sera 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 interceptée et le code suivant se poursuit. L'icône de chargement est donc toujours masquée, comme nous le 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, renvoyez simplement l'erreur. Nous pouvons le faire dans la 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équencement: tirer le meilleur parti des deux

Penser asynchrone n'est pas facile. Si vous avez du mal à vous aligner, 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 s'agit d'une synchronisation qui verrouille le navigateur pendant le téléchargement. Pour que ce fonctionnement soit asynchrone, nous utilisons then() afin que les opérations se déroulent les unes après les autres.

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 faire une boucle dans les URL des chapitres et les récupérer 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 le mode asynchrone. Par conséquent, les chapitres s'affichent dans l'ordre dans lequel ils sont téléchargés, ce qui correspond à la manière dont Pulp Fiction a été écrite. Ce n'est pas de la pulp fiction, alors résolvons-le.

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 à la valeur que vous lui attribuez. Si vous lui transmettez une instance de Promise, elle la renverra 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 semblable à une promesse (dispose d'une méthode then()), cela crée un Promise authentique qui répond/rejette 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, elle sera renvoyée avec la valeur "undefined".

Il existe également Promise.reject(val), qui crée une promesse qui rejette la valeur que vous lui attribuez (ou n'est pas définie).

Nous pouvons organiser 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())

La procédure est la même que dans l'exemple précédent, mais vous n'avez pas besoin de la variable "séquence" distincte. Notre rappel de réduction est appelé pour chaque élément du tableau. La première fois, la "séquence" est Promise.resolve(), mais pour les autres appels, "séquence" correspond à ce que nous avons renvoyé de 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.

Réunissons 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à, nous avons une version entièrement asynchrone de la version de synchronisation. Mais nous pouvons faire mieux. Pour le moment, notre page est en cours de téléchargement comme ceci:

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 voulons les télécharger tous en même temps, puis les traiter une fois qu'ils sont tous disponibles. Heureusement, il existe une API pour cela:

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

Promise.all prend un ensemble de promesses et crée une promesse qui est tenue lorsqu'elles sont toutes réalisées avec succès. Vous obtenez un tableau de résultats (quelles que soient les promesses que vous avez effectuées) 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';
})

En fonction de la connexion, cela peut être plus rapide que le chargement un par un, et cela nécessite moins de code que notre première tentative. Vous pouvez télécharger les chapitres dans n'importe quel ordre, mais ils apparaissent à l'écran dans le bon ordre.

Toutefois, nous pouvons tout de même améliorer les performances perçues. Lorsque le premier chapitre arrive, nous devrions l'ajouter à la page. Cela permet à l'utilisateur de commencer à lire avant que les autres chapitres soient arrivés. À l'arrivée du chapitre 3, nous ne l'ajoutons pas à la page, car l'utilisateur peut ne pas se rendre compte qu'il manque le chapitre 2. À l'arrivée du chapitre deux, nous pouvons ajouter les chapitres deux et trois, etc.

Pour ce faire, nous récupérons le code JSON de tous les 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 reçoit le premier contenu plus tôt.

Dans cet exemple simple, tous les chapitres arrivent à peu près au même moment, mais l'avantage de les afficher un par un sera exagéré avec des chapitres plus nombreux, plus grands.

Faire ce qui précède avec des rappels ou des événements de style Node.js permet de doubler le code, mais surtout, ce n'est pas aussi facile à suivre. Cependant, les promesses ne s'arrêtent pas là. Associées à d'autres fonctionnalités ES6, elles deviennent encore plus simples.

Bonus: capacités étendues

Depuis que j'ai rédigé cet article, 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, consultez l'article my async functions article. Les promesses et les fonctions asynchrones sont largement acceptées dans les principaux navigateurs. Pour en savoir plus, consultez la documentation de référence sur Promise et la fonction asynchrone de MDN.

Merci beaucoup à Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans et Yutaka Hirano qui ont relu cela et apporté des corrections/recommandations.

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