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 de l'histoire du développement Web.
[Roulement de tambour]
Les promesses sont arrivées en JavaScript !
[Des feux d'artifice explosent, des confettis tombent du ciel, la foule est en délire]
À ce stade, vous vous trouvez dans l'une des situations suivantes :
- Les gens autour de vous applaudissent, mais vous ne comprenez pas pourquoi. Peut-être même que vous ne savez pas ce qu'est une "promesse". Vous hausseriez bien 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 beaucoup de temps pour comprendre pourquoi je devais m'intéresser à ce sujet. Vous souhaitez probablement commencer par le début.
- Vous donnez un coup de poing dans le vide ! Il était temps, non ? Vous avez déjà utilisé ces objets Promise, 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 commencer par la terminologie.
- Vous le saviez déjà et vous vous moquez de ceux qui s'enthousiasment comme s'ils venaient de l'apprendre. Prenez un moment pour vous féliciter, puis accédez directement à la documentation de référence de l'API.
Compatibilité avec les navigateurs et polyfill
Pour que les navigateurs qui ne disposent pas d'une implémentation complète des promesses soient conformes aux spécifications, ou pour ajouter des promesses à d'autres navigateurs et à Node.js, consultez le polyfill (2 ko compressé).
Pourquoi tout ce tapage ?
JavaScript est un langage monothread, 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 un grand nombre d'autres éléments qui diffèrent d'un navigateur à l'autre. Mais généralement, JavaScript se trouve dans la même file d'attente que l'affichage, la mise à jour des styles et la gestion des actions de l'utilisateur (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, vous pouvez conduire et tenir 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 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 éternue.
Vous avez probablement utilisé des événements et des rappels pour contourner ce problème. Voici des é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 Atchoum. 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 de détecter les images qui ont généré une erreur 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 actions 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 réussite/échec asynchrone, l'idéal est d'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 une meilleure dénomination. 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 ressemblent un peu aux é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 a eu lieu plus tôt.
Cette fonctionnalité est extrêmement utile pour les réussites/échecs asynchrones, car vous vous intéressez moins au moment exact où un élément est devenu disponible qu'à la réaction au résultat.
Terminologie des promesses
Domenic Denicola a relu la première version de cet article et m'a attribué un "F" pour la terminologie. Il m'a collé, 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é.
- pending : n'a pas encore été traitée ni refusée
- settled : la demande a été traitée ou refusée.
La spécification utilise également le terme thenable pour décrire un objet de type promesse, car il possède une méthode then. Ce terme me rappelle l'ancien sélectionneur de l'équipe d'Angleterre de football, Terry Venables. Je l'utiliserai donc 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 les promesses JavaScript partagent un comportement standardisé commun appelé Promises/A+. Si vous êtes un utilisateur de jQuery, vous disposez d'un élément similaire appelé Deferreds. Toutefois, les objets Deferred ne sont pas conformes à la spécification Promise/A+, ce qui les rend légèrement différents et moins utiles. Soyez donc prudent. jQuery dispose également d'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 dans l'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 accepte un argument, un rappel avec deux paramètres : resolve et reject. Effectuez une action dans le rappel, éventuellement de manière asynchrone, puis appelez "resolve" si tout a fonctionné, sinon appelez "reject".
Comme pour throw en JavaScript standard, 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 la 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() accepte 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 en tant que "Futures", ont été renommées "Promises", puis ont finalement été déplacées vers JavaScript. Le fait de les avoir en JavaScript plutôt que dans le DOM est une bonne chose, car ils seront disponibles dans des contextes JS autres que le navigateur, tels que Node.js (la question de savoir s'ils les utilisent dans leurs API de base est une autre question).
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/d'échec asynchrones utiliseront des promesses. C'est déjà le cas avec la gestion des quotas, les événements de chargement des polices, ServiceWorker, Web MIDI, Streams, etc.
Compatibilité avec d'autres bibliothèques
L'API JavaScript Promises traite tout ce qui comporte une méthode then() comme une promesse (ou thenable dans le jargon des promesses sigh). Par conséquent, si vous utilisez une bibliothèque qui renvoie une promesse Q, elle fonctionnera correctement avec les nouvelles promesses JavaScript.
Cependant, comme je l'ai mentionné, les Deferreds de jQuery sont un peu… inutiles. Heureusement, vous pouvez les caster en promesses standards, ce qui est recommandé dès que possible :
var jsPromise = Promise.resolve($.ajax('/whatever.json'))
Ici, la fonction $.ajax de jQuery renvoie une valeur Deferred. Comme il dispose d'une méthode then(), Promise.resolve() peut le convertir en promesse JavaScript. Cependant, les objets Deferred 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 valeurs 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 rejets.
Simplification du code asynchrone complexe
Passons maintenant au codage. Imaginons que nous voulions :
- Démarrer un indicateur de chargement
- Récupérer du code 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 la story à la page
- Arrêter la toupie
… mais aussi d'indiquer à l'utilisateur si un problème est survenu en cours de route. Nous devons également arrêter le spinner à ce moment-là, sinon il continuera de tourner, aura le tournis et entrera en collision avec un autre élément de l'interface utilisateur.
Bien sûr, vous n'utiliseriez pas JavaScript pour diffuser un article, car le diffuser au format HTML est plus rapide, mais ce modèle est assez courant lorsqu'il s'agit d'API : plusieurs récupérations de données, puis une action une fois que tout est terminé.
Pour commencer, voyons comment récupérer 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 bon candidat, 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-la 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 formidable, car moins je vois la casse camelCase exaspérante de XMLHttpRequest, plus je suis heureux.
Chaîne
then() n'est pas la fin de l'histoire. Vous pouvez enchaîner les then pour transformer des valeurs ou exécuter des actions asynchrones supplémentaires les unes après les autres.
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
})
Pour un 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 responseType JSON, mais nous pourrions également résoudre ce 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);
})
Étant donné que JSON.parse() n'accepte qu'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 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 les then pour exécuter des actions asynchrones de manière séquentielle.
Lorsque vous renvoyez un élément à 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 un élément de type promesse, le then() suivant l'attend et n'est appelé que lorsque cette promesse est résolue (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 fournit un ensemble d'URL à demander, puis nous demandons la première d'entre elles. C'est là que les promesses se démarquent vraiment 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 les fois suivantes où getChapter est appelé, nous réutilisons la promesse de l'histoire, de sorte que story.json n'est récupéré qu'une seule fois. Vive les promesses !
Gestion des exceptions
Comme nous l'avons vu précédemment, then() accepte deux arguments : un pour la réussite et un pour l'échec (ou "fulfill" et "reject", dans le jargon 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, il s'agit simplement d'un raccourci 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. 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 au then() suivant avec un rappel de rejet (ou catch(), car ils sont équivalents). Avec then(func1, func2), func1 ou func2 sera appelé, mais jamais les deux. Mais avec then(func1).catch(func2), les deux seront appelés si func1 rejette, 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 à un bloc try/catch JavaScript normal. Les erreurs qui se produisent dans un bloc "try" sont immédiatement transmises au bloc catch(). Voici les étapes 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 rejets se produisent lorsqu'une promesse est explicitement rejeté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);
})
Il est donc utile d'effectuer toutes les tâches liées aux promesses dans le rappel du constructeur de promesse, afin que les erreurs soient automatiquement intercepté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 exceptions 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, erreur HTTP 500 ou utilisateur 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. Il passe plutôt au rappel catch. Par conséquent, "Échec de l'affichage du chapitre" sera ajouté à la page si l'une des actions précédentes a échoué.
Comme pour l'instruction try/catch de JavaScript, l'erreur est interceptée et le code suivant continue, de sorte que l'icône de chargement est toujours masquée, ce qui correspond au comportement souhaité. 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équençage : tirer le meilleur parti des deux
Penser de manière asynchrone n'est pas facile. Si vous avez du mal à démarrer, 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'
C'est une bonne idée ! 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 pouvons-nous parcourir les URL des chapitres et les récupérer dans l'ordre ? 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 asynchrone. Nos chapitres apparaîtraient donc dans l'ordre dans lequel ils sont téléchargés, ce qui correspond à peu près à la façon dont Pulp Fiction a été écrit. Ce n'est pas 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 en fonction de 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 un élément de type promesse (qui possède une méthode then()), il crée un véritable Promise qui s'exécute ou est rejeté de la même manière. Si vous transmettez une autre valeur, par exemple Promise.resolve('Hello'), une promesse est créée et se réalise avec cette valeur. Si vous l'appelez sans valeur, comme ci-dessus, elle renvoie "undefined".
Il existe également Promise.reject(val), qui crée une promesse qui rejette 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 à 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.
"sequence" est Promise.resolve() la première fois, 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 est ici une promesse.
Récapitulons :
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 plutôt doués 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 voulons 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 terminées. Vous obtenez un tableau de résultats (quel que soit le résultat 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 plus rapide de quelques secondes que de charger les images une par une, et cela nécessite moins de code que notre premier essai. Les chapitres peuvent être téléchargés 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 chapitre 1 arrive, nous devons l'ajouter à la page. Cela permet à l'utilisateur de commencer à lire avant que le reste des chapitres ne soit arrivé. Lorsque le chapitre 3 arrive, nous ne l'ajoutons pas à la page, car l'utilisateur ne se rendra peut-être pas 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 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 ! La diffusion de l'intégralité du contenu prend le même temps, mais l'utilisateur reçoit le premier élément de contenu plus tôt.
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 plus évident avec des chapitres plus nombreux et plus volumineux.
Effectuer les opérations ci-dessus avec des rappels ou des événements de style Node.js nécessite environ deux fois plus de code, mais surtout, n'est pas aussi facile à suivre. Cependant, ce n'est pas tout. Lorsqu'elles sont combinées avec d'autres fonctionnalités ES6, les promesses deviennent encore plus faciles à utiliser.
Bonus : Capacités étendues
Depuis que j'ai écrit cet article, 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 mon article sur les fonctions asynchrones. Les promesses et les fonctions asynchrones sont largement compatibles avec les principaux navigateurs. Pour en savoir plus, consultez les références MDN sur les promesses et les fonctions asynchrones.
Un grand merci à Anne van Kesteren, Domenic Denicola, Tom Ashworth, Remy Sharp, Addy Osmani, Arthur Evans et Yutaka Hirano, qui ont relu cet article et apporté des corrections/recommandations.
Nous remercions également Mathias Bynens pour avoir mis à jour différentes parties de l'article.