Les promesses simplifient les calculs différés et asynchrones. Une promesse représente une opération qui n'est pas encore terminée.
Développeurs, préparez-vous à un moment charnière dans l'histoire du développement Web.
[Début du roulement de tambour]
Les promesses sont arrivées dans JavaScript !
[Des feux d'artifice explosent, des confettis pailletés tombent du ciel, la foule est en délire]
À ce stade, vous appartenez à l'une des catégories suivantes :
- Les gens vous acclament, mais vous ne savez pas pourquoi. Vous ne savez peut-être même pas ce qu'est une "promesse". Vous hausseriez les épaules, mais le poids du papier pailleté pèse sur vos épaules. Si c'est le cas, ne vous inquiétez pas, il m'a fallu des années pour comprendre pourquoi je devais m'intéresser à ces choses. Vous devriez probablement commencer par le début.
- Vous faites un geste de joie ! Il était temps, non ? Vous avez déjà utilisé ces éléments Promise, mais vous êtes agacé par le fait que toutes les implémentations ont une API légèrement différente. Quelle est l'API pour la version JavaScript officielle ? Nous vous recommandons de commencer par la terminologie.
- Vous le saviez déjà et vous vous moquez de ceux qui sautent de joie comme si c'était une nouveauté. Prenez un instant pour vous féliciter, puis rendez-vous directement dans la documentation de référence de l'API.
Prise en charge des navigateurs et polyfill
Pour mettre à niveau les navigateurs qui ne disposent pas d'une implémentation complète des promesses ou ajouter des promesses à d'autres navigateurs et à Node.js, consultez le polyfill (2 ko compressé).
De quoi s'agit-il ?
JavaScript est un langage à thread unique, ce qui signifie que deux scripts 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 de nombreux autres éléments qui diffèrent d'un navigateur à l'autre. Mais en général, JavaScript se trouve dans la même file d'attente que la peinture, la mise à jour des styles et la gestion des actions utilisateur (comme la mise en surbrillance de texte et l'interaction avec les commandes de formulaire). Une activité dans l'un de ces éléments retarde les autres.
En tant qu'être humain, vous êtes multitâche. Vous pouvez taper avec plusieurs doigts, vous pouvez conduire et tenir une conversation en même temps. La seule fonction de blocage à laquelle nous devons faire face est l'éternuement, où toute activité en cours doit être suspendue pendant la durée de l'éternuement. C'est assez ennuyeux, surtout lorsque vous conduisez et que vous essayez de tenir une conversation. Vous ne voulez pas écrire de code qui fasse éternuer.
Vous avez probablement utilisé des événements et des rappels pour contourner ce problème. Voici quelques exemples d'é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 éternuement. Nous obtenons l'image, ajoutons quelques écouteurs, puis JavaScript peut arrêter l'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 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 de détecter les images qui ont généré une erreur avant que nous ayons eu la possibilité de les écouter. Malheureusement, le DOM ne nous permet pas de le faire. De plus, cela charge une image. 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'avoir associé l'écouteur. Mais en ce qui concerne la réussite/l'échec asynchrones, l'idéal est d'avoir quelque chose comme 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 avaient une méthode "ready" qui renvoyait une promesse, nous pourrions faire ceci :
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, sauf que :
- Une promesse ne peut réussir ou échouer qu'une seule fois. Il ne peut pas réussir ni é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 a eu lieu plus tôt.
C'est extrêmement utile pour les succès/échecs asynchrones, car vous êtes moins intéressé par le moment exact où quelque chose est devenu disponible et plus intéressé par la réaction au résultat.
Terminologie des 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 100 fois États et destins et a écrit une lettre inquiète à mes parents. Malgré cela, je confonds encore beaucoup de termes, mais voici les bases :
Une promesse peut être :
- fulfilled : l'action liée à la promesse a réussi.
- rejected : l'action liée à la promesse a échoué.
- En attente : la demande n'a pas encore été traitée ni refusée.
- settled (réglé) : la commande a été traitée ou refusée.
La spécification utilise également le terme thenable pour décrire un objet qui ressemble à une promesse, dans la mesure où 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, telles que :
Les promesses ci-dessus et JavaScript partagent un comportement commun et standardisé appelé Promises/A+. Si vous utilisez jQuery, il existe une fonctionnalité similaire appelée Deferreds. Toutefois, les Deferreds ne sont pas conformes à Promise/A+, ce qui les rend subtilement différents et moins utiles. Soyez donc vigilant. jQuery possède également un type Promise, mais il ne s'agit que d'un sous-ensemble de Deferred et il présente les mêmes problèmes.
Bien que les implémentations de promesses suivent un comportement standardisé, leurs API globales diffèrent. Les promesses JavaScript sont semblables à RSVP.js en termes d'API. 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 prend un argument, un rappel avec deux paramètres, "resolve" et "reject". Effectuez une action dans le rappel, peut-être de manière asynchrone, puis appelez "resolve" si tout a fonctionné, ou "reject" dans le cas contraire.
Comme pour throw
en JavaScript pur, il est d'usage, 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 en cas de réussite et un autre en 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", qui a été remplacé par "Promises", avant d'être finalement intégré à JavaScript. Le fait qu'ils soient en JavaScript plutôt que dans le DOM est un avantage, car ils seront disponibles dans des contextes JS non liés au navigateur, tels que Node.js (la question de savoir s'ils les utilisent dans leurs API principales est une autre affaire).
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 asynchrones de réussite/échec utiliseront des promesses. C'est déjà le cas avec Quota Management, Font Load Events, ServiceWorker, Web MIDI, Streams et plus encore.
Compatibilité avec d'autres bibliothèques
L'API JavaScript Promises traitera tout élément doté d'une méthode then()
comme une promesse (ou thenable
dans le langage des promesses soupir). Par conséquent, si vous utilisez une bibliothèque qui renvoie une promesse Q, tout ira bien, elle fonctionnera correctement avec les nouvelles promesses JavaScript.
Toutefois, comme je l'ai mentionné, les Deferreds de jQuery sont un peu… inutiles. Heureusement, vous pouvez les caster en promesses standards, ce qui vaut la peine d'être fait dès que possible :
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
Ici, $.ajax
de jQuery renvoie un Deferred. Comme il possède une méthode then()
, Promise.resolve()
peut le transformer en promesse JavaScript. Toutefois, il arrive que les deferreds transmettent 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 tout sauf la première :
jsPromise.then(function(response) {
// ...
}, function(xhrObj) {
// ...
})
Heureusement, c'est généralement ce que vous souhaitez ou, du moins, cela vous donne accès à ce que vous souhaitez. Sachez également que jQuery ne suit pas la convention de transmission des objets Error dans les rejets.
Simplifier le code asynchrone complexe
Bien, codons quelques éléments. Disons que nous voulons :
- Démarrer un spinner pour indiquer le chargement
- Récupérer du JSON pour une histoire, ce qui nous donne le titre et les URL de chaque chapitre
- Ajouter un titre à la page
- Récupérer chaque chapitre
- Ajouter l'article à la page
- Arrêter le sélecteur
… mais indiquez également à l'utilisateur si un problème est survenu en cours de route. Nous devrons également arrêter le spinner à ce moment-là, sinon il continuera de tourner, aura le vertige et se heurtera à une autre UI.
Bien sûr, vous n'utiliserez pas JavaScript pour diffuser une histoire, car le HTML est plus rapide, mais ce modèle est assez courant lorsque vous utilisez des API : plusieurs récupérations de données, puis une action lorsque tout est terminé.
Pour commencer, traitons la récupération des données à partir du réseau :
Promisifier XMLHttpRequest
Les anciennes API seront mises à jour pour utiliser des promesses, si cela est possible de 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 effectuer des requêtes HTTP sans saisir manuellement XMLHttpRequest
, ce qui est une excellente chose, car moins je vois la casse mixte exaspérante de XMLHttpRequest
, plus ma vie sera heureuse.
Chaîne
then()
n'est pas la fin de l'histoire. Vous pouvez enchaîner les then
pour transformer des valeurs ou exécuter d'autres actions asynchrones les unes après les autres.
Transformer les valeurs
Vous pouvez transformer des valeurs en renvoyant simplement 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 prendre un exemple concret, 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 JSON responseType
, mais nous pourrions également résoudre le problème dans le domaine des promesses :
get('story.json').then(function(response) {
return JSON.parse(response);
}).then(function(response) {
console.log("Yey JSON!", response);
})
Comme JSON.parse()
prend un seul argument et renvoie une valeur transformée, nous pouvons utiliser 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 récupère 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 prochain then()
est appelé avec cette valeur. Toutefois, si vous renvoyez quelque chose qui ressemble à une promesse, le prochain then()
l'attend et n'est appelé que lorsque cette promesse est réglée (réussie ou é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 effectuons une requête asynchrone vers story.json
, qui nous fournit 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 simples modèles de rappel.
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 de l'histoire. story.json
n'est donc récupéré qu'une seule fois. Vive les promesses !
Gestion des exceptions
Comme nous l'avons vu précédemment, then()
prend deux arguments, l'un pour le succès et l'autre pour l'échec (ou "fulfill" et "reject", dans le langage des promesses) :
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, c'est juste du sucre pour then(undefined, func)
, mais c'est plus lisible. Notez que les deux exemples de code ci-dessus ne se comportent pas de la même manière. Le second é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 rejets de promesse passent à la then()
suivante avec un rappel de rejet (ou catch()
, car c'est équivalent). Avec then(func1, func2)
, func1
ou func2
seront appelés, mais 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. 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 normale JavaScript try/catch. Les erreurs qui se produisent dans un bloc "try" sont immédiatement transmises au bloc catch()
. Voici le processus ci-dessus sous forme d'organigramme (parce que j'adore les organigrammes) :
Suivez les lignes bleues pour les promesses qui se réalisent 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 promesses, afin que les erreurs soient automatiquement détectées et deviennent des rejets.
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, 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, ainsi que le rappel qui ajoute chapter1.html à la page. Au lieu de cela, il passe au rappel de capture. Par conséquent, le message "Échec de l'affichage du chapitre" sera ajouté à la page si l'une des actions précédentes a échoué.
Comme pour try/catch en JavaScript, l'erreur est détectée et le code suivant continue, de sorte que le spinner est toujours masqué, ce qui est ce que nous voulons. Le code ci-dessus 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 utiliser catch()
simplement à des fins de journalisation, sans récupérer l'erreur. Pour ce faire, il suffit de relancer 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 les voulons tous. Faisons en sorte que cela se produise.
Parallélisme et séquencement : tirer le meilleur parti des deux
Penser de manière asynchrone n'est pas facile. Si vous avez du mal à 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 est synchrone et bloque le navigateur pendant le téléchargement. Pour que cela fonctionne de manière asynchrone, nous utilisons then()
pour que les choses se produisent 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 parcourir les URL des chapitres et les récupérer dans l'ordre ? Cette méthode 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 les opérations asynchrones. Nos chapitres apparaîtraient donc dans l'ordre de téléchargement, ce qui correspond à la façon dont Pulp Fiction a été écrit. Nous ne sommes pas dans Pulp Fiction, alors corrigeons cela.
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 donnez. 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 qui ressemble à une promesse (avec une méthode then()
), il crée un véritable Promise
qui est résolu/rejeté de la même manière. Si vous transmettez une autre valeur, par exemple Promise.resolve('Hello')
, il crée une promesse qui se réalise avec cette valeur. Si vous l'appelez sans valeur, comme ci-dessus, il est rempli avec "undefined".
Il existe également Promise.reject(val)
, qui crée une promesse qui est rejetée avec la valeur que vous lui donnez (ou indé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 de variable "sequence" distincte. Notre rappel de réduction est appelé pour chaque élément du tableau.
La première fois, "sequence" est Promise.resolve()
, mais pour le reste des appels, "sequence" correspond à ce que nous avons renvoyé lors de l'appel précédent. array.reduce
est très utile pour réduire un tableau à une seule valeur, qui dans ce cas est une promesse.
Récapitulatif :
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 synchrone. Mais nous pouvons faire mieux. Pour le moment, notre page se télécharge comme suit :
Les navigateurs sont assez efficaces pour télécharger plusieurs éléments à la fois. Nous perdons donc en performances en téléchargeant les 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 se réalise lorsque toutes les promesses sont exécutées avec succès. Vous obtenez un tableau de résultats (quels que soient les résultats des promesses) 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 être quelques secondes plus rapide que le chargement un par un, et cela nécessite moins de code que notre première tentative. Les chapitres peuvent se télécharger dans n'importe quel ordre, mais ils s'affichent à l'écran dans le bon ordre.
Toutefois, nous pouvons encore améliorer les performances perçues. Lorsque le premier chapitre arrive, nous devons l'ajouter à la page. L'utilisateur peut ainsi commencer à lire avant que le reste des chapitres ne soit disponible. Lorsque le chapitre 3 arrive, nous ne l'ajoutons pas à la page, car l'utilisateur peut ne pas se rendre compte qu'il manque le chapitre 2. Lorsque le chapitre 2 arrive, nous pouvons ajouter les chapitres 2 et 3, etc.
Pour ce faire, nous récupérons le fichier 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à, le meilleur des deux ! Le temps nécessaire pour diffuser l'intégralité du contenu est le même, mais l'utilisateur reçoit la première partie du contenu plus tôt.
Dans cet exemple trivial, tous les chapitres arrivent à peu près en même temps, mais l'avantage de les afficher un par un sera exagéré avec des chapitres plus nombreux et plus longs.
Faire ce qui précède avec des rappels ou des événements de style Node.js nécessite environ le double de code, mais surtout n'est pas aussi facile à suivre. Cependant, ce n'est pas la fin de l'histoire pour les 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, la possibilité d'utiliser les 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 mon article sur les fonctions asynchrones. Les principaux navigateurs sont largement compatibles avec les promesses et les fonctions async. Pour en savoir plus, consultez les références MDN sur les promesses et les fonctions asynchrones.
Merci beaucoup à 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 la mise à jour de différentes parties de l'article.