L'implémentation de la spécification ECMAScript 2015 sur laquelle JavaScript est fondé a conduit à introduire un nouvel objet standard en JavaScript :
Promise
. Il s'agit bien d'un objet standard, c'est-à-dire propre au langage comme String
et non au navigateur, comme XMLHttpRequest
.
Cet objet permet de faciliter et de sécuriser l'écriture d'un appel à une fonction asynchrone, c'est-à-dire à une fonction qui rend la main sans avoir encore retourné son résultat, s'engageant à signaler au programme qui l'a appelée quand ce résultat sera disponible, par exemple en appelant une fonction que le programme lui a fournie - une callback.
Tout développeur JavaScript a très probablement déjà utilisé ce type de fonction, notamment pour charger des fichiers via l'objet
XMLHttpRequest
, ou pour déclencher une action après expiration d'un timer programmé via window.setTimeout ()
.
Avec l'objet
Promise
, l'asynchronisme a en quelque sorte été dégagé des objets qui viennent d'être cités pour accéder au rang d'aspect fonctionnel fondamental de JavaScript, au même titre que l'héritage basé sur les prototypes ou les closures. Partant tout développeur JavaScript doit maîtriser l'asynchronisme.
Pour autant, qui met le nez dans la spécification ne peut être que rapidement rebuté par l'opacité de cette dernière. Fort heureusement, il existe de nombreuses autres sources auxquelles se référer. Apportons ici une modeste contribution avec une présentation détaillée du système des promises destinée à qui découvre le sujet.
Mise à jour du 06/10/2021 : Correction d'une erreur mineure à la fin du propos sur le chaînage.
A écouter en lisant l'article... |
Avertissement
Le code figurant dans cet article emploie intensivement des fonctions fléchées. Pour rappel :
// Accolades si plusieurs instructions var f = (i) => { i + 1; return (i); }; // Appel classique par la suite f (42); // Pas d'accolades si une instruction qui constitue la valeur retournée var f = (i) => i ++; // Pas de parenthèses si un seul paramètre var f = i => i ++; // IIFE (retourne 42 immédiatement) (i => { i ++; return (i); }) (41) // Idem (i => i ++) (41)
Une fonction fléchée n'a pas de
this
: toute référence à this
dans son corps se traduit par une recherche d'un variable nommée this
dans son contexte local, à défaut dans un de ses contextes englobants :
function Banner (message) { this.message = message; this.displayNormal = function () { // Dans la fonction de l'IIFE, this est l'objet window ! (function f () { console.log (this.message); }) (); } this.displayArrow = function () { // Dans la fonction de l'IIFE, this est l'objet Banner (() => console.log (this.message)) (); } } // Affiche undefined car window n'a pas de propriété .message new Banner ("Normal function").displayNormal (); // Affiche "Arrow function" new Banner ("Arrow function").displayArrow ();
Au début, je n'étais pas grand fan des fonctions fléchées : une nouvelle syntaxe pour les fonctions, qui va venir tout compliquer ? Toutefois, l'essayer, c'est l'adopter. A l'usage, l'écriture des fonctions est grandement facilitée, même si la lecture peut s'avérer un peu plus délicate, du moins le temps de s'habituer. Par ailleurs, on constate que - comme le dit Douglas Crockford en prétendant qu'il ne faut pas tout utiliser dans JavaScript, mais seulement ses good parts, dont
this
n'est pas - qu'on peut se passer de
this
dans une fonction de base.
Le code emploie aussi intensivement les closures. Pour rappel, une closure est un ensemble de variables qui restent accessibles dans une fonction alors que ces variables ne figurent pas dans le contexte local ni dans le contexte global de cette dernière (ce ne sont pas des arguments, ni des variables locales, ni des variables globales) :
function f (something) { return (function () { console.log (something); }); }; var g = f ("Saved in closure!"); // Affiche "Saved in closure!" g ();
Très pratique pour simplifier la syntaxe lorsqu'il s'agit de créer une closure sur un objet pour que cet objet puisse être référencé via
this
par un gestionnaire d'événément qui n'est pas factorisé sous la forme d'une méthode (dans le cas contraire, utiliser .bind ()
ou .apply ()
selon les besoins fera l'affaire) :
function MyObject (value) { this.value = value; tag = document.getElementById ("tagButton"); tag.addEventListener ("click", e => alert (this.value)); tag.addEventListener ("click", (function (o) { return function (e) { alert (o.value); }; }) (this)); }
Il a déjà été question des closures en JavaScript sur ce site, dans cet article portant sur la gestion des événements.
Bibliographie sélective
Cet article est le fruit d'un bon nombre de lectures. J'ai retenu ici les références qui m'ont semblé les plus utiles. Il s'agit essentiellement des ouvrages de deux auteurs : Kyle Simpson et Axel Rauschmayer. Le premier est très connu, le second est vraiment à découvrir.
Les références pas particulièrement lisibles, que les autres références bibliographiques permettent de comprendre :
- Event Loops dans la spécification de HTML5.
- Promise Objects dans ECMAScript 2015 Language Specification
Sur l'event loop, donc les tasks et les microtasks :
- In The Loop par Jake Archibald lors de la JSConf.Asia 2018.
Sur les variantes observées dans l'ordre d'exécution des tasks et des microtasks selon le navigateur :
- Tasks, microtasks, queues and schedules par Jake Archibald.
Sur l'asynchronisme :
- Asynchrony: Now & Later dans You dont' know JS: Async & Performance par Kyle Simpson.
- Asynchronous programming (background) dans Exploring ES6 par Axel Rauschmayer.
Sur les promises :
- Promise for asynchronous programming dans Exploring ES6 par Axel Rauschmayer.
- Callbacks dans You dont' know JS: Async & Performance par Kyle Simpson.
- Promises dans You dont' know JS: Async & Performance par Kyle Simpson.
- Using Promises sur MDN.
Autres sources intéressantes :
-
Promises, async/await sur Javascript.info, pour les deux schémas montrant les deux utilisations de
.then ()
.
Au commencement : les fonctions asynchrones
Comme l'écrit Kyle Simpson au tout début de You dont' know JS: Async & Performance :
One of the most important and yet often misunderstood parts of programming in a language like JavaScript is how to express and manipulate program behavior spread out over a period of time.
Cela tient au fait que pour un développeur, il n'apparaît pas d'emblée intéressant de savoir comment un programme JavaScript est exécuté, c'est-à-dire comment le runtime JS - le moteur JavaScript - fonctionne. Pourtant, il est indispensable d'en savoir plus sur le sujet avant d'aborder les promises.
Le runtime JS est un programme qui exécute du code à la demande. Il est appelé par l'event loop - boucle infinie de gestion des événements - du navigateur, en premier lieu pour exécuter le programme contenu dans une balise
<script>
rencontrée durant l'interprétation du contenu d'une page Web, mais encore pour exécuter une callback à l'expiration d'un timer créé par window.setTimeout ()
, ou pour exécuter un gestionnaire d'événement ajouté par addEventListener ()
lorsque l'utilisateur entreprend l'action correspondante.
Appelons "task" le code que le runtime JS exécute à un instant donné.
Le principe de "run-to-completion" impose le runtime JS n'est jamais interrompu durant l'exécution d'une task.
Ajoutons à cela qu'à de très rares exceptions, un appel de fonction est toujours synchrone, ce qui signifie que la fonction appelée est exécutée immédiatement, afin la fin de la task. Ce sont des fonctions des API qui permettent des appels asynchrones, comme
window.setTimeout ()
ou XMLHttpRequest.send ()
: ces appels entraînent plus tard la création des événements adéquats pour exécuter une callback.
En JavaScript, jusqu'à ECMAScript 2017, il n'existait pas de possibilité de créer une fonction asynchrone en dehors de ces appels à des fonctions des API. Ou alors, il fallait détourner de telles fonctions pour n'utiliser que leur capacité à faire ajouter un événement dans l'event loop :
function makeAsyncCallTo (f) { // Revient à window.setTimeout (f, 0) window.setTimeout (f); } var flag = false; makeAsyncCallTo (() => console.log (`1st async call: ${flag}`)); makeAsyncCallTo (() => console.log (`2nd async call: ${flag}`)); flag = true;
1st async call: true 2nd async call: true
Le fait que la valeur affichée soit
true
alors que flag
a été passé à true
APRES les appels aux fonctions montre bien que ces dernières ont été exécutées après la fin de la task, que rien n'est venu interrompre en application du principe de "run-to-completion", même pas l'expiration d'un délai implicitement fixé à 0
.
A l'occasion de l'introduction de l'objet
Promise
dans ECMAScript 2015, une nouvelle notion est apparue : celle de job queue. Ici, il faut clarifier le vocabulaire : ce que les auteurs de la spécification ECMAScript 2015 appellent "job" en faisant abstraction du contexte dans lequel fonctionne le runtime JS, les développeurs Web l'appelle "microtask" en tenant compte du contexte du navigateur Web où ce runtime JS fonctionne.
Une microtask est une task qui doit être exécutée aussitôt que possible par l'event loop, c'est-à-dire avant la task suivante s'il s'en trouve. La possibilité pour une task de créer une microtask, c'est ce qui fait dire ici à Kyle Simpson que :
It’s kinda like saying, “oh, here’s this other thing I need to do later, but make sure it happens right away before anything else can happen.”
The event loop queue is like an amusement park ride: once you finish the ride, you have to go to the back of the line to ride again. But the Job queue is like finishing the ride, cutting in line, and getting right back on.
Cela ne signifie pas que les microtasks s'insèrent dans la file des tasks. En fait, elles s'insèrent dans une file distincte. Ce qui fait dire à Kyle Simpson que :
Jobs are kind of like the spirit of the setTimeout(..0) hack, but implemented in such a way as to have a much more well-defined and guaranteed ordering: later, but as soon as possible.
Dans un navigateur, les règles sont donc que :
- une task n'est exécutée qu'après une autre (principe de "run-to-completion") ;
- une microtask crée par une task est exécutée après cette task, avant la task suivante ;
- les microtasks sont exécutées les unes après les autres.
Au-delà, la manière dont le navigateur peut ou non intercaler des tasks pour assurer des fonctions essentielles est hors de propos ici.
Ces règles impliquent que toutes les microtasks créées par une tasks sont exécutées avant la task suivante.
Pour terminer sur cette présentation de l'asynchronisme en JavaScript, rajoutons un point qui fera sens plus tard, car il fait référence au système des promises.
Pour l'heure, les microtasks sont résevés au système des promises. Ainsi,
window.setTimeout ()
avec un délai nul ne permet que de rajouter une task, pas une microtask.
L'exécution des fulfillers d'une promise est programmée sous la forme de microtasks. Par exemple... :
console.log ("Start"); window.setTimeout (() => console.log ("Timeout")); var p = new Promise ((resolve, reject) => resolve ()); p.then (result => console.log ("Fulfiller")); console.log ("End");
...produit :
Start End Fulfiller Timeout
La promesse des promises
Soit une API exposant plusieurs fonctions asynchrones, qui seront pour l'occasion simulés par des timers :
asyncGetSomeDate ()
doit retourner un tableau de valeurs ;asyncComputeSomething ()
doit en calculer la somme.
Et dans le programme principal, un fonction synchrone
syncDisplaySomeResult ()
affiche la somme.
Par défaut, il est possible d'écrire cela :
// API function asyncGetSomeData (n, callback) { console.log ("Getting some data..."); window.setTimeout (() => { var i, numbers; numbers = new Array (); for (i = 0; i != n; i ++) numbers.push (i); callback (numbers); }, 2000); } function asyncComputeSomething (numbers, callback) { console.log ("Computing something..."); window.setTimeout (() => { var i, s; s = 0; for (i = 0; i != numbers.length; i ++) s += numbers[i]; callback (s); }, 2000); } function syncDisplaySomeResult (sum) { console.log (`And the result is: ${sum}`); } // Utilisation de l'API asyncGetSomeData (10, (numbers) => { asyncComputeSomething (numbers, (sum) => { syncDisplaySomeResult (sum); }) });
Le système des promises permet d'écrire cela :
// API function asyncGetSomeData (n) { return (new Promise ((resolve, reject) => { console.log ("Getting some data..."); window.setTimeout (() => { var i, numbers; numbers = new Array (); for (i = 0; i != n; i ++) numbers.push (i); resolve (numbers); }, 2000); })); } function asyncComputeSomething (numbers) { return (new Promise ((resolve, reject) => { console.log ("Computing something..."); window.setTimeout (() => { var i, s; s = 0; for (i = 0; i != numbers.length; i ++) s += numbers[i]; resolve (s); }, 2000); })); } function syncDisplaySomeResult (sum) { console.log (`And the result is: ${sum}`); } // Utilisation de l'API asyncGetSomeData (10).then (asyncComputeSomething).then (syncDisplaySomeResult);
Comme il est possible de le constater, en faisant disparaître toute mention à des callbacks le système des promises permet de simplifier non seulement l'écriture du code utilisant l'API, mais aussi celle du code de l'API elle-même, quoique ce soit dans une moindre mesure. La promesse des promises, c'est donc d'échapper au "callback hell" en permettant d'écrire une composition d'appels synchrones ou asynchrones sous une forme très proche de son écriture formelle qui serait, dans le cas précédent :
syncDisplaySomeResult (asyncComputeSomething (asyncGetSomeData (10)))
En fait, il y a plus à dire sur les apports du système des promises sur le système des callbacks. Quelque chose de plus essentiel même, qui permet de mieux comprendre que le système des promises soit appelé ainsi.
Comme Kyle Simpson l'explique dans le chapitre Callbacks de son livre, appeler une fonction asynchrone d'un tiers qui devra en retour appeler une callback, c'est implicitement attendre que la callback soit appelée une fois et une seule, dans certaines circonstances. Bref, c'est passer un contrat implicite avec ce tiers à l'occasion d'une inversion de contrôle - on passe le contrôle de l'exécution à un tiers, en attendant qu'il le rende.
Or la découverte des termes de contrat peut s'effectuer aux dépens. Kyle Simpson prend l'exemple d'un site marchand qui appellerait une fonction d'un service de paiement, lequel devrait en retour appeler une callback du site marchand quand le paiement a été effectué. Si les développeurs du site marchand s'amusent à faire un test appelant plusieurs fois la callback avant le paiement, et que cela n'a pas été anticipé par les développeurs du site : problème! Ce qui fait écrire à l'auteur :
You're probably slowly starting to realize that you're going to have to invent an awful lot of ad hoc logic in each and every single callback that's passed to a utility you're not positive you can trust.
Now you realize a bit more completely just how hellish "callback hell" is.
Le système des promises apporte une solution. Une promise ce n'est pas seulement, comme on le verra, un objet qui signalera lorsqu'il disposera du résultat retourné par une fonction appelée de manière asynchrone. C'est un objet le signalera au bon moment, et qui le ne signalera qu'une fois.
Pour reprendre l'exemple précédent, dans une fonction telle que
asyncGetSomeData ()
, la promise retournée ne changera pas d'état tant que resolve ()
n'aura pas été appelé. Et si par la suite la promise est de nouveau utilisée, le code qui appelle resolve ()
ne sera pas exécuté de nouveau, car la promise ayant été résolue, elle conserve le résultat et y donne accès immédiatement. Ainsi... :
var p = asyncGetSomeData (10); p.then (asyncComputeSomething).then (syncDisplaySomeResult); p.then (asyncComputeSomething).then (syncDisplaySomeResult);
...va générer la sortie suivante :
Getting some data... Computing something... Computing something... And the result is: 45 And the result is: 45
"Getting some data..." n'est bien affiché qu'une fois.
Comprendre les promises
Avant toute chose, il convient de préciser que le fonctionnement interne du système des promises est très complexe, et qu'il est parfaitement possible de l'utiliser sans maîtriser ce fonctionnement, pour la bonne raison que c'est un système qui vise à masquer la complexité. Comme on l'a vu, il ne vise qu'à simplifier l'écriture de compositions d'appels synchrones ou asynchrones, en permettant dans ce dernier cas d'échapper au "callback hell".
Maintenant, qu'est-ce qu'une promise ? La définition donnée dans la spécification ECMAScript 2015 :
A Promise is an object that is used as a placeholder for the eventual results of a deferred (and possibly asynchronous) computation.
Comme toujours, rien n'est clair dans la spécification, et ce n'est que la première phrase ! En fait, pour comprendre le système des promises, le mieux est de procéder par reverse-engineering, c'est-à-dire de partir de l'usage qu'on en fait, en cherchant éventuellement à le réécrire par polyfill, comme le propose ici Axel Rauschmayer.
Ce n'est qu'enfin qu'il convient de revenir sur la définition, car une fois que le système des promises est assimilé, elle se révèle effectivement particulièrement pertinente.
D'ailleurs, quand il en vient à expliquer le fonctionnement interne des promises, Axel Rauschmayer écrit :
In this section, we will approach Promises from a different angle: Instead of learning how to use the API, we will look at a simple implementation of it. This different angle helped me greatly with making sense of Promises.
Avant même de considérer qu'un promise présente un intérêt pour gérer les appels asynchrones, il faut partir de la manière dont elle fonctionne dans le cas d'une fonction
f ()
de base, c'est-à-dire synchrone.
Une promise est un objet créé par
new
sur une fonction désignée comme l'executor :
var p = new Promise (f);
La création de la promise sur
f ()
entraîne immédiatement l'exécution de f ()
, qui reçoit en paramètre deux fonctions : resolve ()
et reject ()
. Chacune de ses fonctions prend un unique paramètre. Lors de son exécution, f ()
décide de les appeler pour témoigner au système - qui gère la promise - de sa réussite ou de son échec. A cette occasion f ()
fournit un résultat via resolve ()
ou une raison via reject ()
- on verra que ce résultat peut être rien, une valeur ou une promise :
function f (resolve, reject) { if ... resolve (result); else reject (reason); }
Le code de
resolve ()
et de reject ()
est inaccessible : ce sont des fonctions créées par le système.
La promise dispose notamment de méthodes
.then ()
et .catch ()
. Lorsqu'elle est appelée, resolve ()
appelle tous les fulfillers ajoutés par .then ()
. Pour sa part, lorsqu'elle est appelée, reject ()
appelle tous les rejecters ajoutés par .then ()
ou par .catch ()
, car .catch (rejecter)
revient à .then (undefined, rejecter)
. Si un fulfiller est appelé, il reçoit le résultat en paramètre ; et si un rejecter est appelé, il reçoit la raison en paramètre :
function onFulfilled (result) {} function onRejected (reason) {} p.then (onFulfilled, onRejected); /* // Ecriture équivalente : p.then (onFulfilled); p.catch (onRejected); */
Noter que dans la littérature, la signature de
.then ()
est souvent notée .then (resolve, reject)
. Mais comme Kyle Simpson le souligne, cela introduit une confusion entre la méthode Promise.resolve ()
et le fulfiller. Il suggère de noter plutôt .then (fulfilled, rejected)
. Retenons le point, mais notons plutôt ici .then (fullfiller, rejecter)
.
Il est important de noter que l'appel aux fulfillers et au rejecters est asynchrone et non synchrone. Nous y reviendrons.
Il est possible d'ajouter plusieurs fulfillers et plusieurs rejecters. Ils sont appelés dans l'ordre dans lequel ils ont été ajoutés :
p = new Promise (f); p.then (onFulfilled1, onReject1); p.then (onFulfilled2); p.catch (onReject2); p.then (onFulfilled3, onReject3);
De combien de temps dispose-t-on à partir de la création de la promise pour ajouter des fulfillers ou des rejecters ? Cela n'a aucune importance, car par définition, un fulfiller doit être appelé après que
f ()
a rapporté avoir réussi, et un rejecter après que f ()
a rapporté avoir échoué.
Noter que c'est ici qu'on comprend l'intérêt d'un promise, car le comportement qui vient d'être décrit se produit que
f ()
soit synchrone ou asynchrone. Pour le démontrer on transforme généralement f ()
en fonction asynchrone en lui faisant appeler resolve ()
ou reject ()
après expiration d'un timer programmé par window.setTimeout ()
, qui rend la main immédiatement :
function f (resolve, reject) { window.setTimeout (function () { if ... resolve (result); else reject (reason); }, 4000); }
Pour rajouter un intérêt supplémentaire, une API comportant des fonctions asynchrones les exposera sous la forme de fonctions qui retournent des promises. Autrement dit, ce n'est pas une fonction telle que
f ()
qui sera pas exposée, mais une fonction retournant une promise créée sur f ()
, f ()
assurant le service à proprement parler, sur ce modèle :
function asyncF (someParameters) { return (new Promise ((resolve, reject) => { // Ici, du code exécuté de manière asynchrone // comme un appel à window.setTimeout () })); }
Par exemple, une fonction d'une API pour charger un fichier :
// API function asyncLoadTXT (url) { return (new Promise ((resolve, reject) => { var request; request = new XMLHttpRequest (); request.overrideMimeType ("text/plain"); request.onreadystatechange = () => { if (request.readyState != 4) return; if (request.status != 200) reject (`HTTP error ${request.status} : ${request.statusText}`); else resolve (request.responseText); } request.open ("GET", url); request.send (); })); } // De la sorte, le client de l'API peut utiliser la fonction ainsi asyncLoadTXT ("helloWorld.txt").then (result => console.log (result), reason => console.log (reason));
Mais comment le système peut savoir que
f ()
a réussi ou échoué ? Tout bêtement car f ()
appelle resolve ()
ou reject ()
selon le cas, comme cela vient d'être dit. Le système ne devine rien. Ces fonctions lui permettent de conserver la trace de la réussite ou de l'échec de f ()
, c'est-à-dire tenir à jour l'état de la promise, avant d'appeler - de manière asynchrone - en conséquence les fulfillers ou les rejecters déjà ajoutés à ce stade.
C'est qu'une promise peut avoir trois états. A sa création, elle est dans l'état pending. Si
f ()
appelle resolve ()
, elle passe dans l'état fulfilled. Et si f ()
appelle reject ()
, elle passe dans l'état rejected. Noter que malheureusement, l'objet Promise
ne comprend pas de méthode ni de propriété pour connaître l'état d'une promise - toutefois, dans Firefox, un console.log ()
permet de visualiser cet état.
C'est pourquoi, lorsque
f ()
appelle resolve ()
- même raisonnement pour reject ()
-, resolve ()
appelle - de manière asynchrone - tous les fulfillers ajoutés à ce stade. Si un fulfiller est ajouté par la suite, f ()
ayant signalé qu'elle a réussi, le fulfiller est immédiatement appelé - toujours de manière asynchrone. Il est possible de le démontrer en faisant de f ()
une fonction asynchrone :
function onFulfilled1 () { console.log (`onFulfilled1 () after: ${Date.now () - date} ms`); } function onFulfilled2 () { console.log (`onFulfilled2 () after: ${Date.now () - date} ms`); } function f (resolve, reject) { window.setTimeout (() => { resolve (); }, 2000); } // t = 0 var date = Date.now (); var p = new Promise (f); // onFulfilled1 () appelée au bout de t = 2 000 ms une fois que la promesse est fulfilled p.then (onFulfilled1); window.setTimeout (() => { // onFulfilled2 () appelée au bout de t = 4 000 ms car la promesse est déjà fulfilled quand ce fulfiller est ajouté p.then (onFulfilled2); }, 4000);
Des appels consécutifs à
resolve ()
ou reject ()
ne produisent rien : une fois que f ()
a signalé avoir réussi ou échoué, ce résultat est définitif pour le système, et l'état de la promise ne peut donc être modifié :
function f (resolve, reject) { resolve (); // Aucun effet reject (); } var p = new Promise (f); p.then (() => { console.log ("Success!") }); // Jamais exécutée p.catch (() => { console.log ("Failure!") });
Il est maintenant temps de préciser ce point essentiel : un fulfiller ou un rejecter est appelé de manière asynchrone. Cela signifie qu'il est appelé après la fin de l'exécution du programme en cours.
Pour le démontrer, il faut faire preuve d'un peu de subtilité. En effet, le problème est que l'executor est exécuté à la création de la promise. Or un fulfiller ou un rejecter ne peut être ajouté qu'après la création de la promise. Dans ces conditions, comment montrer que lorsque l'executor appelle
resolve ()
, resolve ()
n'exécute pas le fulfiller immédiatement, mais programme son exécution ?
Pour cela, il faut introduire une temporisation dans l'executor : du fait d'un timeout, ce dernier n'appellera
resolve ()
qu'après avoir laissé le temps d'ajouter le fulfiller. Le fulfiller affiche la valeur d'un boolén isFulfilled, par défaut à false
, mais passé à true
après l'appel à resolve ()
. Si jamais resolve ()
exécutait le fulfiller de manière synchrone, ce dernier afficherait false
. Or il affiche true
:
var isFulfilled = false; function f (resolve, reject) { window.setTimeout (function () { // resolve () appelé alors que isFulfilled est à false resolve (); // isFulfilled passé à true APRES l'appel à resolve () isFulfilled = true; }, // Donne le temps de rajouter un fulfiller tant que la promise est pending 4000); } var p = new Promise (f); // Pour afficher l'état de la promise, qui apparaît pending console.log (p); // A expiration du délai, le fulfiller affiche true ! p.then (result => console.log (isFulfilled));
Et si le fulfiller est ajouté alors que la promise est fulfilled ? Il est plus simple de montrer que le fulfiller est, ici encore, appelé de manière asynchrone :
var isFulfilled = false; function f (resolve, reject) { resolve (); } var p = new Promise (f); // Pour afficher l'état de la promise, qui apparaît fulfilled console.log (p); // A la fin de ce programme, le fulfiller affichera true ! p.then (result => console.log (isFulfilled)); // isFulfilled passé à true APRES l'appel à resolve () isFulfilled = true;
Bref, dans un programme ou un sous-programme qui crée une promise et qui y ajoute des fulfillers et des rejecters, il est certain que tous ces fulfillers et rejecters ne seront appelés qu'une fois l'exécution du programme terminée d'abord, et qu'une fois que la promise aura été résolue ou rejetée ensuite.
C'est ce qui rend complexe la réalisation d'un polyfill, car comment en JavaScript est-il possible d'appeler une fonction de manière asynchrone si elle n'est pas prévue pour cela ? Rien ne le permet par défaut dans le langage. Il faudrait détourner des fonctions asynchrones des API, ce qui semble affreux. Pourtant, c'est bien ce qu'on observe dans les polyfills, qui utilisent
windows.setTimeout ()
pour parvenir à leurs fins.
Un autre moyen pour créer une promise
Il est possible de créer une promise autrement qu'avec le constructeur
Promise ()
, en utilisant plutôt la méthode .resolve ()
de l'objet Promise
. Cette méthode permet de créer une promise non plus sur une fonction, mais sur une valeur, ou sur une promise, ou sur un thenable comme il en sera question plus loin :
valeur | Promise fulfilled dont le résultat est la valeur |
promise | La promise elle-même, inchangée |
Noter que dans le cas d'une valeur, les deux écritures suivantes sont donc équivalentes :
p = new Promise ((resolve, reject) => resolve (666)); p = Promise.resolve (666);
Et dans le cas d'un thenable, cet objet est donc supposé disposer d'une méthode
.then ()
prenant un fulfiller et un rejecter en paramètre. L'objet est alors transformé en promise :
var o = { then: (onFulfilled, onRejected) => onFulfilled (666) }; var p = Promise.resolve (o); // Affiche 666 p.then (result => console.log (result)); // Affiche true console.log (p instanceof Promise);
L'objet
Promise
dispose pareillement d'une méthode .reject ()
qui retourne une promise rejected pour la raison fournie :
var p = Promise.reject (13);
Catch me if you catch can
Pour rappel, un rejecter peut être ajouté à une promise en étant fourni en second paramètre à
.then ()
... :
p.then (null, reason => console.log (reason));
... ou en unique paramètre à
.catch ()
:
p.catch (reason => console.log (reason));
Les rejecters d'une promise sont appelés sur appel de
reject ()
dans l'executor... :
var p = new Promise ((resolve, reject) => reject ("Error")); p.catch (reason => console.log (reason));
...mais aussi quand une exception est soulevée dans ce dernier, la raison correspondant à l'exception :
var p = new Promise ((resolve, reject) => { throw "Error" }); p.catch (reason => console.log (reason));
Les rejecters ne sont pas appelés si une exception est soulevée dans un fulfiller. En effet, si un fulfiller est appelé, c'est que la promise est fulfilled. Et un rejecter ne peut être appelé que la promise est rejected. Or une fois fixé, l'état d'une promise ne peut plus changer : une exception soulevée dans un fulfiller ne peut donc le faire passer de fulfilled à rejected. Ici, le rejecter n'est donc pas appelé :
var p = Promise.resolve (666); p.then (result => { throw "Error" }, reason => console.log (reason));
Pour détecter l'exception tout de même, l'usage serait de rajouter un rejecter sur la promesse retournée par
.then ()
:
var p = Promise.resolve (666); p.then (result => { throw "Error" }).catch (reason => console.log (reason));
Toutefois Kyle Simpson oppose à cela le fait que le rejecter pourrait lui-même générer une erreur, etc. Il faudrait donc élaborer un système de gestion des erreurs plus sophistiqué. Pour en savoir plus, se reporter aux solutions qu'il présente ici.
Chaîner les promises
Retour à l'API Promise. Un autre point essentiel est que
.then ()
et .catch ()
retournent une promise. Cela peut surprendre, car ces méthodes prennent pour paramètre(s) un fulfiller et/ou un rejecter dont la signature n'est pas du tout celle d'un executor. Pour rappel :
- un executor prend pour paramètres des fonctions
resolve ()
etreject ()
; - un fulfiller en paramètre une valeur communiquée à
resolve ()
par l'executor ; - un rejecter prend en paramètre une valeur communiquée à
reject ()
par l'executor.
D'où la question : à partir de quel executor
.then ()
et .catch ()
créent-elles une promise ?
Pour le comprendre, il faut repartir du comportement attendu de
.then ()
. Comme déjà expliqué, le système des promises doit permettre d'échapper au "callback hell". Cela signifie que ce système doit offrir une syntaxe simple pour spécifier une callback lors de l'appel à une fonction asynchrone - ce que .then ()
permet, comme on vient de le voir. Toutefois, cela signifie aussi que ce système doit offrir une syntaxe simple pour spécifier la callback de cette callback si jamais la première callback est aussi une fonction asynchrone, et ainsi de suite. Pour cela, ne serait-il pas intéressant de permettre une telle écriture ? :
asyncGetSomeData (10).then (asyncComputeSomething).then (syncDisplaySomeResult);
De fait, c'est exactement ce que le système des promises permet, comme l'a montré l'exemple déjà donné en évoquant la promesse des promises, au début de cet article. Il est possible d'enchaîner des appels à des fonctions asynchrones - ou synchrones car qui peut le plus, peut le moins ; or un fonction synchrone, n'est-ce pas une fonction asynchrone qui retourne son résultat immédiatement ? Ce mécanisme est plus rigoureusement appelé composition, car le chaînage peut se lire formellement à l'envers :
syncDisplaySomeResult (asyncComputeSomething (asyncGetSomeData (10)))
Partant, il est attendu que
.then ()
se comporte de différentes manières selon ce que retourne le fulfiller qui lui est passé en paramètre :
-
Si un premier fulfiller ne retourne rien,
.then ()
permet simplement d'appeler un second fulfiller après :function f (resolve, reject) { window.setTimeout (() => resolve (666), 2000); } var p = new Promise (f); p.then (result => {}).then (result => console.log (result));
undefined
-
Si un premier fulfiller retourne une valeur,
.then ()
permet de la relayer en paramètre à un second fulfiller appelé après :function f (resolve, reject) { window.setTimeout (() => resolve (666), 2000); } var p = new Promise (f); p.then (result => (result + 1)).then (result => console.log (result));
667
Si un premier fulfiller retourne une promise - ce qui sera déterminé en cherchant à savoir si ce qui est retourné est thenable, comme il en sera question plus loin -, cette promise ne sera résolue qu'après la première - logique, car le fulfiller qui retourne la seconde promise n'est appelé que lorsque la première promise est résolue -, et.then ()
permet alors d'ajouter un fulfiller à cette seconde promise :function f0 (resolve, reject) { window.setTimeout (() => resolve (666), 2000); } function f1 (resolve, reject) { window.setTimeout (() => resolve (13), 2000); } var p = new Promise (f0); p.then (result => { console.log (result); return (new Promise (f1)); }).then (result => console.log (result));
// 2 secondes après le début du programme 666 // 4 secondes après le début du programme (ie : 2 secondes après le résultat précédent) 13
Ces trois comportements peuvent être indifféremment assurés en partant du principe que "qui peut le plus, peut le moins" : dans tous les cas,.then ()
doit retourner une promise. Simplement, la promise sera créée par le système des promises - ce qui n'implique pas la fourniture d'un executor quand le fulfiller ne retourne pas une promise, mais rien ou une valeur -, sur le résultat retourné par le fulfiller ou le rejecter :rien Promise fulfilled dont le résultat est undefined
valeur Promise fulfilled dont le résultat est la valeur promise pending Promise qui sera résolue dont le résultat sera celui de la promise promise fulfilled Promise fulfilled dont le résultat est celui de la promise promise rejected Promise rejected dont la raison est celle de la promise (exception) Promise rejected dont la raison est l'erreur soulevée La promise retournée est bien une nouvelle promise, même si le fulfiller ou le rejecter a retourné une promise, et dans quel qu'état que soit cette dernière. Noter qu'il n'est pas évident de le vérifier empiriquement. La tentation serait d'écrire :var p; var p0 = Promise.resolve (666); var p1 = p0.then (result => { p = Promise.resolve (result); return (p); }); console.log (p === p1);
Le problème, c'est que "run-to-completion" oblige, le fulfiller spécifié dansp0.then ()
n'aura pas été exécuté au moment duconsole.log ()
, si bien quep
vaudraundefined
. Il faut donc procéder autrement :var p; var p0 = Promise.resolve (42); var p1 = p0.then (result => { p = Promise.resolve (result); return (p); }); p1.then (result => console.log (p, p1, p === p1));
Promise {
: "fulfilled", : 42 } Promise { : "fulfilled", : 42 } false Il en va de même avec.catch ()
, comme il est possible de le vérifier ainsi :var p; var p0 = Promise.reject (42); var p1 = p0.catch (result => { p = Promise.resolve (result); return (p); }); p1.then (result => console.log (p, p1, p === p1));
Promise {
: "fulfilled", : 42 } Promise { : "fulfilled", : 42 } false Il est donc clair quep.then ().then ()
ne se lit pas du tout commep.then ()
suivi dep.then ()
!Appeler du code dans tous les cas
En plus des fulfillers et des rejecters que.then ()
et.catch ()
permettent d'ajouter à une promise, la méthode.finally ()
permet de lui ajouter une fonction qui sera appelée, sans aucun paramètre, dans tous les cas après les fulfillers ou les rejecters, sur le modèle de l'instruction finally en matière de gestion d'exception :var mustSuccess = true; var p = new Promise ((resolve, reject) => mustSuccess ? resolve () : reject ()); p.then (result => console.log ("Success"), reason => console.log ("Failure")); // "Finally" toujours affiché, que mustSuccess soit true ou false p.finally (() => console.log ("Finally"));
L'idée est de permettre de spécifier une fonction à exécuter systématiquement, plutôt que de contraindre le développeur à la spécifier deux fois comme ce serait le cas ainsi... :p.then (() => console.log ("Finally"), () => console.log ("Finally"));
...ou ainsi :p.finally (() => console.log ("Finally")); p.catch (() => console.log ("Finally"));
Comme.then ()
et.catch ()
,.finally ()
retourne une promise.Résoudre plusieurs promises "parallèlement"
L'objetPromise
dispose aussi de méthodes.all ()
et.race ()
pour demander la résolution de plusieurs promises :-
.all ()
retourne un promise qui est résolue une fois que toutes les promises fournies en paramètres sont résolues, et dont le résultat est alors un tableau des résultats de ces dernières, ou rejetée aussitôt qu'une de ces promises est rejetée, et dont la raison est alors celle de la promise rejetée :function write (text, delay) { return (new Promise ((resolve, reject) => { window.setTimeout (() => { console.log (`Returning "${text}"`); resolve (text); }, delay); })); } Promise.all ([write ("Hello", 1000), write ("World", 2000)]).then (result => console.log (`.all (): ${result}`));
Returning "Hello" Returning "world" .all(): ["Hello", "world"]
-
.race ()
retourne un promise qui est résolue aussitôt qu'une des promises fournies en paramètres est résolue, et dont le résultat est alors celui de cette promise, ou rejetée aussitôt qu'une de ces promises est rejetée, et dont la raison est alors celle de la promise rejetée :Promise.race ([write ("Hello", 1000), write ("World", 2000)]).then (result => console.log (`.race (): ${result}`));
Returning "Hello" .race (): "Hello" Returning "world"
Une promise n'est pas une instance de Promise
Le test suivant échoue, car les deux pages n'ont pas même objetPromise
:<html> <body> <iframe src="iframe.html"></iframe> <script> test = (p) => console.log (p instanceof Promise ? "yes" : "no") </script> </body> </html> <!-- iframe.html --> <html> <body> <script> parent.test (new Promise ((resolve, reject) => resolve ())); </script> </body> </html>
Pour déterminer si un objet est une promise, il ne faut pas chercher à déterminer si c'est une instance dePromise
. La technique à employer est celle du duck-typing : if it looks like a duck, and qwacks like a duck, it must be a duck. Elle consiste à déterminer si l'objet est thenable, c'est-à-dire s'il dispose d'une méthode.then ()
.Une écriture facilitée avec async et await
Depuis ECMAScript 2017, JavaScript s'est enrichi de deux instructions supplémentaires :async
etawait
. Ces instructions permettent de faciliter plus encore l'écriture du code asynchrone, en masquant en grande partie le recours à l'objetPromise
.Un exemple permet de comprendre de quoi il en retourne. Soit une API qui expose une fonction retournant une promise :function asyncGetSomeData (n) { return (new Promise ((resolve, reject) => { console.log ("Getting some data..."); window.setTimeout (() => { var i, numbers; numbers = new Array (); for (i = 0; i != n; i ++) numbers.push (i); resolve (numbers); }, 2000); })); } function asyncComputeSomething (numbers) { return (new Promise ((resolve, reject) => { console.log ("Computing something..."); window.setTimeout (() => { var i, s; s = 0; for (i = 0; i != numbers.length; i ++) s += numbers[i]; resolve (s); }, 2000); })); } function syncDisplaySomeResult (sum) { console.log (`And the result is: ${sum}`); }
L'API s'utilise normalement ainsi :function doSomething (n) { return (asyncGetSomeData (n).then (asyncComputeSomething).then (syncDisplaySomeResult)); } console.log ("Start"); doSomething (10).then ((result) => console.log ("API used")); console.log ("End");
Start Getting some data... End Computing something... And the result is: 45 API used
Grâce àasync
etawait
, il est désormais possible d'utiliser l'API d'une manière qui s'apparente plus à l'utilisation de fonctions synchrones :async function doSomething (n) { var data = await asyncGetSomeData (n); var sum = await asyncComputeSomething (data); syncDisplaySomeResult (sum); } console.log ("Start"); doSomething ().then ((result) => console.log ("API used")); console.log ("End");
async
entraîne la création d'un objetAsyncFunction
. L'appel à une telle fonction retourne une promise sur cette fonction. Cette promise est donc fulfilled quand la fonction retourne une valeur, et rejected quand la fonction soulève une exception.await
ne peut s'utiliser que dans une fonctionasync
, sur une promise. La fonction rend la main au programme, mais en interne son exécution est suspendue jusqu'à ce que la promise soit résolue. Lorsque la promise est fulfilled ou rejected, le résultat ou la raison est alors retourné et l'exécution de la fonction reprend - après la fin du programme, "run-to-completion" oblige.Bref, avecasync
etawait
, il est possible de se dispenser de faire une référence apparente au système des promises.Conclusion
Tout cela ayant été expliqué, il est possible de revenir sur la définition d'une promise donnée dans la spécification ECMAScript 2015 :A Promise is an object that is used as a placeholder for the eventual results of a deferred (and possibly asynchronous) computation.Effectivement, une promise est un objet qui est retourné par une fonction asynchrone au programme qui l'appelle, et qui permet à ce programme d'accéder au résultat retourné par cette fonction une fois qu'il sera disponible, via un ou plusieurs fulfillers que le programme fournit à la promise via sa méthode.then ()
. Ce qui complique la compréhension du système des promises à partir de la définition, c'est que cette dernière ne donne pas à voir la fonction asynchrone en question. Quand on écrit... :var p = new Promise (f);
...ce n'est pasf ()
qui est la fonction asynchrone ! La fonction asynchrone, c'est celle dans le contexte de laquelle une telle création de promise se trouve, par exemple une fonctionasyncF ()
:function asyncF () { return (new Promise ((resolve, reject) => { /* Ici le code exécuté de manière asynchrone, qui doit se terminer par... : resolve (result); ...s'il réussit et par... : reject (reason); s'il échoue. */ }); }
Or le problème, c'est qu'à force d'avoir pris l'habitude de lire la référence de l'API sur MDN plutôt que la spécification ECMAScript du fait que cette dernière est totalement illisible, le développeur se jette sur l'objetPromise
comme sur le reste, sans prendre assez de recul pour comprendre que ce n'est que la partie émergée d'un iceberg. Et pour comprendre l'iceberg, il faut repartir à la base : comprendre comment fonctionne JavaScript, et non plus se contenter de programmer en JavaScript sans se poser de question. Bref, il faut faire l'effort de comprendre la machine virtuelle sur laquelle s'exécute un programme JavaScript, tout comme on a fourni l'effort de comprendre la machine physique sur laquelle s'exécute un programme C. Cette machine virtuelle, c'est un ensemble constitué du runtime JS, et du navigateur qui l'utilise.