6.1 · async / await
Les diapositives de la séance 6
- expliquer pourquoi JavaScript a besoin d’asynchronisme alors qu’il n’a qu’un seul fil d’exécution
- décrire ce qu’est une
Promiseet énumérer ses trois états - prédire l’ordre d’exécution des
console.logdans un code mêlant synchrone et asynchrone - écrire une fonction
asyncet attendre son résultat avecawait - fabriquer une promesse à la main avec
resolveetreject - traiter les erreurs asynchrones avec
try/catchet lancer des opérations en parallèle avecPromise.all
Les fonctions fléchées (2.1 · Fonctions fléchées), les classes et méthodes (3.1 · Classes et encapsulation), et la gestion d’erreurs (5.3 · Exceptions).
Le problème : un seul fil d’exécution
En C, quand vous appelez read() sur un fichier, votre programme s’arrête et attend. Ce n’est pas grave : le système d’exploitation donne la main à un autre processus pendant ce temps.
JavaScript ne fonctionne pas ainsi. Dans un navigateur, tout votre code s’exécute sur un seul fil, celui-là même qui redessine la page et réagit aux clics. Si une fonction bloque deux secondes, alors pendant deux secondes :
- la page ne se redessine plus ;
- les clics ne produisent rien ;
- l’onglet paraît planté.
Or une requête réseau met couramment 200 ms, parfois 5 secondes. Bloquer n’est donc pas une option. La solution retenue par JavaScript : les opérations lentes ne retournent pas le résultat, elles retournent une promesse de résultat, et votre code continue.
La Promise : une valeur qui n’est pas encore là
Une Promise<T> est un objet qui représente un résultat de type T qui arrivera plus tard, ou qui n’arrivera jamais parce que l’opération a échoué.
Elle se trouve toujours dans exactement un de ces trois états :
| État | Nom anglais | Signification |
|---|---|---|
| en attente | pending | l’opération est en cours, on ne sait rien |
| tenue | fulfilled | l’opération a réussi, la promesse porte une valeur |
| rompue | rejected | l’opération a échoué, la promesse porte une erreur |
Une promesse part toujours de pending et change d’état une seule fois, définitivement. Une promesse tenue ne peut plus être rompue.
Promise<void>
Le type entre chevrons est celui de la valeur finale, pas celui de la promesse. Promise<string> signifie « une chaîne, plus tard ». Promise<void> signifie « rien, mais plus tard » : pratique pour signaler qu’une opération est terminée sans produire de valeur.
await : attendre sans bloquer
await se place devant une promesse et fournit sa valeur finale :
const p: Promise<string> = fetchData(); // p est une Promise<string>
const data: string = await p; // data est une stringDire que « await met l’exécution en pause » est exact, mais trompeur si on ne précise pas quoi est mis en pause. await suspend uniquement la fonction qui l’exécute. Le reste du programme continue : le fil n’est pas bloqué, il part faire autre chose et reviendra terminer la fonction quand la promesse sera tenue.
C’est toute la différence avec un sleep() en C, qui bloque tout.
async : la contrepartie
await ne s’écrit pas n’importe où. Il doit se trouver dans une fonction marquée async, ou à la racine d’un module ES, ce qui inclut le frontmatter d’un fichier Astro.
Et le mot-clé async a une conséquence importante : une fonction async retourne toujours une promesse, que vous le vouliez ou non.
async function fetchData(): Promise<string> {
await delay(2000);
return "Some data"; // on écrit return "Some data"
} // mais l'appelant reçoit une Promise<string>Vous écrivez return "Some data", et TypeScript vous impose pourtant d’annoter le type de retour Promise<string> et non string. Si l’appelant oublie le await, il récupère l’objet promesse lui-même, ce qui affiche Promise { <pending> } dans la console.
L’ordre d’exécution : l’exemple à comprendre
C’est cet exemple qui débloque la compréhension. Lisez-le, prédisez l’ordre des affichages, puis exécutez-le pour vérifier.
ordre.ts
function delay(milliseconds: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function fetchData(): Promise<string> {
console.log("[2] début de fetchData");
await delay(2000);
console.log("[4] les 2 secondes sont écoulées");
return "Some data";
}
console.log("[1] avant l'appel");
fetchData().then((data) => console.log("[5] reçu :", data));
console.log("[3] après l'appel");Sortie :
[1] avant l'appel
[2] début de fetchData
[3] après l'appel
... deux secondes s'écoulent ...
[4] les 2 secondes sont écoulées
[5] reçu : Some data
Trois observations à retenir :
- Le code à l’intérieur de
fetchDatadémarre tout de suite :[2]précède[3]. Appeler une fonctionasync, ce n’est pas différer son démarrage. - Au premier
await,fetchDatarend la main.[3]s’affiche immédiatement, sans attendre les deux secondes. C’est précisément l’effet recherché : le fil reste libre. [4]et[5]arrivent deux secondes plus tard, quand la promesse est tenue.
La ligne [3] s’exécute alors que fetchData n’est pas terminée. Si fetchData range son résultat dans une variable globale et que vous lisez cette variable juste après l’appel, elle sera encore vide. Pour attendre réellement la fin, il faut écrire await fetchData(), donc être soi-même dans une fonction async.
Fabriquer une promesse : resolve et reject
La plupart du temps vous consommez des promesses déjà écrites (fetch, par exemple). Il arrive cependant qu’il faille en créer une, typiquement pour emballer une ancienne API à callback comme setTimeout.
Le constructeur new Promise reçoit une fonction à deux paramètres, resolve et reject, que le moteur JavaScript vous fournit :
resolve(valeur)fait passer la promesse de pending à fulfilled avec cette valeur ;reject(erreur)la fait passer à rejected avec cette erreur.
app.ts
// Simule une lecture de fichier qui réussit une fois sur deux
function readFile(filename: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const success = Math.random() > 0.5;
setTimeout(() => {
if (success) {
resolve("Lecture du fichier OK");
} else {
reject(new Error("Erreur de lecture du fichier !"));
}
}, 2000);
});
}Dans delay, on écrit directement setTimeout(resolve, milliseconds) : setTimeout appelle la fonction qu’on lui passe après le délai, et cette fonction est justement resolve. Il n’y a pas de magie, seulement une fonction passée en argument.
.then() / .catch() ou async / await ?
Les deux syntaxes font la même chose. .then() est la forme historique :
readFile("monFichier.txt")
.then((retour) => console.log(retour))
.catch((error) => console.error(error.message));async / await est la forme moderne, et elle s’articule avec le try / catch vu au chapitre 5.3 :
async function retrieveFileContent(filename: string): Promise<void> {
try {
const retour: string = await readFile(filename);
console.log(retour);
} catch (error) {
console.error("Lecture impossible :", error);
}
}Préférez async / await. Le code se lit de haut en bas, et une seule construction gère les erreurs synchrones et asynchrones.
Un try / catch sans await n’attrape rien :
try {
readFile("x.txt"); // pas de await : la promesse part, on ne l'attend pas
} catch (e) {
// jamais exécuté
}Le bloc try se termine avant que la promesse ne soit rompue. Le rejet devient alors une unhandled promise rejection. Retenez la règle : pas de await, pas de catch.
Plusieurs opérations à la fois : Promise.all
Ce code met six secondes :
const a = await charger("/a"); // 2 s
const b = await charger("/b"); // 2 s
const c = await charger("/c"); // 2 sChaque await attend la fin du précédent, alors que les trois requêtes sont indépendantes. Promise.all les lance toutes puis attend la dernière, soit deux secondes au total :
const [a, b, c] = await Promise.all([
charger("/a"),
charger("/b"),
charger("/c"),
]);Promise.all retourne les résultats dans l’ordre des promesses fournies, jamais dans l’ordre d’arrivée. Attention cependant : si une seule promesse est rompue, Promise.all est rompue immédiatement. Pour obtenir le détail succès/échec de chacune, utilisez Promise.allSettled.
Enchaînez les await seulement quand l’opération B a besoin du résultat de A. Sinon, regroupez dans un Promise.all.
Exercices
Exercice 6.1 : Somme différée
essentiel
- Écrivez
calculateSumAsync(a: number, b: number): Promise<number>qui se résout avec la somme après un délai de 3 secondes. - Écrivez
printSum(), marquéeasync, qui afficheCalcul en cours..., appellecalculateSumAsync(3, 4)avecawait, puis affiche le résultat.
Critères de réussite : Calcul en cours... apparaît immédiatement, 7 apparaît trois secondes plus tard, et le type de retour annoté est bien Promise<number>.
function calculateSumAsync(a: number, b: number): Promise<number> {
return new Promise<number>((resolve) => {
setTimeout(() => resolve(a + b), 3000);
});
}
async function printSum(): Promise<void> {
console.log("Calcul en cours...");
const resultat: number = await calculateSumAsync(3, 4);
console.log(`Résultat : ${resultat}`);
}
printSum();
console.log("Cette ligne s'affiche AVANT le résultat.");Trace :
Calcul en cours... ← immédiat : printSum démarre tout de suite
Cette ligne s'affiche AVANT le résultat. ← immédiat : printSum a rendu la main au await
... trois secondes s'écoulent ...
Résultat : 7
Le point clé. Les deux premières lignes s’affichent dans la même milliseconde, alors que printSum n’est pas terminée. Appeler une fonction async exécute son corps immédiatement jusqu’au premier await ; à ce moment-là, elle rend la main, et le programme continue. C’est la démonstration que await suspend la fonction, pas le fil d’exécution.
Deux erreurs classiques sur ce petit code.
La flèche dans setTimeout n’est pas facultative :
setTimeout(resolve(a + b), 3000); // FAUX : resolve est appelé TOUT DE SUITE
setTimeout(() => resolve(a + b), 3000); // correct : on passe une fonction à appeler plus tardDans la version fausse, resolve(a + b) est évalué sur-le-champ pour produire l’argument de setTimeout. La promesse se résout instantanément, et setTimeout reçoit undefined.
Seconde erreur : annoter printSum(): void. Une fonction async retourne toujours une promesse ; TypeScript répond The return type of an async function must be the global Promise<T> type. Pour la même raison, calculateSumAsync s’annote Promise<number> et non number.
Variante possible : écrire calculateSumAsync en async et réutiliser la fonction delay du cours, await delay(3000); return a + b;. Le new Promise explicite reste nécessaire dès qu’il faut emballer une API à callback comme setTimeout.
Exercice 6.2 : Vérification d’un utilisateur
essentiel
- Écrivez
verifyUser(login: string, motDePasse: string): Promise<string>qui se résout avec un message de bienvenue si le couple est correct, et se rejette avec uneErrorsinon. - Appelez-la une première fois avec
.then()/.catch(). - Appelez-la une seconde fois avec
awaitdans untry/catch.
Critères de réussite : les deux versions affichent la même chose, et aucun avertissement unhandled promise rejection n’apparaît dans la console.
Comparez les deux écritures et déterminez laquelle vous relisez le plus facilement. C’est le vrai objectif de l’exercice.
function verifyUser(login: string, motDePasse: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
if (login === "admin" && motDePasse === "1234") {
resolve(`Bienvenue ${login}`);
} else {
reject(new Error("Identifiants incorrects"));
}
}, 1500);
});
}
// 2. Version .then() / .catch()
verifyUser("admin", "1234")
.then((message) => console.log("[then] " + message))
.catch((e: Error) => console.error("[then] " + e.message));
verifyUser("alice", "abcd")
.then((message) => console.log("[then] " + message))
.catch((e: Error) => console.error("[then] " + e.message));
// 3. Version await + try / catch
async function tester(login: string, motDePasse: string): Promise<void> {
try {
const message = await verifyUser(login, motDePasse);
console.log("[await] " + message);
} catch (e: unknown) {
if (e instanceof Error) {
console.error("[await] " + e.message);
}
}
}
tester("admin", "1234");
tester("alice", "abcd");Trace : rien ne s’affiche pendant 1,5 seconde, puis les quatre lignes arrivent ensemble, car les quatre setTimeout ont été armés au même instant.
... 1,5 seconde ...
[then] Bienvenue admin
[then] Identifiants incorrects
[await] Bienvenue admin
[await] Identifiants incorrects
Le point clé. .then() / .catch() et await / try / catch décrivent exactement la même chose. La différence est de lisibilité : dans la seconde version, le résultat est une variable ordinaire, et vous pouvez enchaîner des instructions après elle sans imbriquer de callbacks. C’est là que la forme moderne prend l’avantage, pas sur cet exemple à une seule étape.
Deux points de vigilance.
Chaque appel de verifyUser crée une promesse indépendante. Si vous oubliez le .catch() sur l’appel qui échoue, vous obtenez une unhandled promise rejection ; sous Node.js, cela termine le processus avec un code d’erreur. Un reject sans destinataire n’est pas ignoré, il remonte.
Dans .catch((e: Error) => ...), l’annotation Error est une affirmation de votre part : le paramètre est any par défaut, et TypeScript ne vérifie rien. Dans le bloc catch de la seconde version, en revanche, e est unknown et le compilateur vous oblige au instanceof. C’est un argument de plus en faveur d’async / await, dans la continuité du chapitre 5.3.
Exercice 6.3 : Les mêmes, en classes
entraînement transfert
Dans une méthode, this désigne l’instance sur laquelle la méthode s’exécute ; un champ s’écrit toujours this.champ. Voir 3.1 · Les classes.
Reprenez les exercices 6.1 et 6.2 en faisant de calculateSumAsync une méthode d’une classe Calculator, et de verifyUser une méthode d’une classe User.
Attention au this : dans une méthode async, this désigne bien l’instance, mais il faut appeler les autres méthodes avec await this.maMethode().
class Calculator {
calculateSumAsync(a: number, b: number): Promise<number> {
return new Promise<number>((resolve) => {
setTimeout(() => resolve(a + b), 3000);
});
}
async printSum(a: number, b: number): Promise<void> {
console.log("Calcul en cours...");
const resultat = await this.calculateSumAsync(a, b); // this. obligatoire
console.log(`Résultat : ${resultat}`);
}
}
class User {
constructor(private login: string, private motDePasse: string) {}
verifyUser(login: string, motDePasse: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
setTimeout(() => {
// this.login lu 1,5 s plus tard : la flèche conserve le this de la méthode
if (login === this.login && motDePasse === this.motDePasse) {
resolve(`Bienvenue ${login}`);
} else {
reject(new Error("Identifiants incorrects"));
}
}, 1500);
});
}
async tester(login: string, motDePasse: string): Promise<void> {
try {
console.log(await this.verifyUser(login, motDePasse));
} catch (e: unknown) {
if (e instanceof Error) {
console.error(e.message);
}
}
}
}
const calculator = new Calculator();
calculator.printSum(3, 4);
const user = new User("admin", "1234");
user.tester("admin", "1234");
user.tester("alice", "abcd");Trace :
Calcul en cours...
... 1,5 seconde ...
Bienvenue admin
Identifiants incorrects
... 1,5 seconde de plus ...
Résultat : 7
Le point clé. Le vrai sujet de l’exercice est this à l’intérieur du setTimeout. Ce callback s’exécute 1,5 seconde plus tard, longtemps après la fin de verifyUser, et pourtant this.login fonctionne. La raison est celle du chapitre 2.1 : une fonction fléchée ne crée pas son propre this, elle conserve celui de la portée où elle a été écrite : ici, la méthode, donc l’instance.
Écrivez la même chose avec function et tout s’effondre :
setTimeout(function () {
if (login === this.login) { } // ERREUR : 'this' implicitly has type 'any'
}, 1500); // à l'exécution : this n'est pas l'instanceDeux autres piquûres de rappel :
await calculateSumAsync(a, b)sansthis.donneCannot find name 'calculateSumAsync'. Une méthode n’est pas une variable de portée ;- détacher une méthode casse le lien avec l’instance :
const f = user.tester;
f("admin", "1234"); // this est undefined : TypeError à l'exécution
const g = user.tester.bind(user); // correctC’est exactement le piège que vous rencontrerez au chapitre 7.2 en passant une méthode à addEventListener.
Exercice 6.4 : Séquentiel contre parallèle
approfondissement
Écrivez chargerRessource(nom: string): Promise<string> qui attend une seconde puis se résout avec `ressource ${nom} chargée`.
- Chargez trois ressources avec trois
awaitsuccessifs. Mesurez la durée totale avecconsole.time("seq")etconsole.timeEnd("seq"). - Rechargez les mêmes trois ressources avec
Promise.all. Mesurez à nouveau.
Critère de réussite : environ 3000 ms dans le premier cas, environ 1000 ms dans le second. Expliquez en une phrase pourquoi.
function chargerRessource(nom: string): Promise<string> {
return new Promise<string>((resolve) => {
setTimeout(() => resolve(`ressource ${nom} chargée`), 1000);
});
}
async function main(): Promise<void> {
// 1. Séquentiel
console.time("seq");
const a = await chargerRessource("A");
const b = await chargerRessource("B");
const c = await chargerRessource("C");
console.timeEnd("seq"); // seq: 3.003s
console.log(a, b, c);
// 2. Parallèle
console.time("par");
const [d, e, f] = await Promise.all([
chargerRessource("D"),
chargerRessource("E"),
chargerRessource("F"),
]);
console.timeEnd("par"); // par: 1.001s
console.log(d, e, f);
}
main();Sortie :
seq: 3.003s
ressource A chargée ressource B chargée ressource C chargée
par: 1.001s
ressource D chargée ressource E chargée ressource F chargée
Les millisecondes exactes varient d’une machine et d’une exécution à l’autre ; seuls les ordres de grandeur comptent. Notez que console.timeEnd bascule en secondes au-delà de 1000 ms : vous lirez 3.003s, et non 3003ms.
L’explication en une phrase. Dans la version séquentielle, le deuxième setTimeout n’est armé qu’après l’expiration du premier, alors que Promise.all reçoit trois promesses déjà démarrées et n’attend que la plus longue.
Le point clé. Le moment où une promesse démarre est le moment où on appelle la fonction, pas le moment où on l’await. Dans Promise.all([...]), les trois appels sont évalués pour construire le tableau, donc les trois minuteurs tournent avant même que l’await n’existe.
D’où l’erreur classique, qui annule tout le bénéfice :
const resultats = await Promise.all([
await chargerRessource("D"), // le await ici force l'attente AVANT de continuer
await chargerRessource("E"), // ... le tableau se construit en 3 secondes
await chargerRessource("F"),
]);On retombe à 3000 ms : Promise.all reçoit trois chaînes déjà résolues. Les await s’écrivent devant Promise.all, jamais à l’intérieur.
Deux précisions pour éviter un contresens.
Il n’y a aucun parallélisme réel : JavaScript reste mono-thread. Promise.all n’accélère rien, il évite simplement de mettre bout à bout des attentes qui pourraient se recouvrir. Sur trois calculs purement synchrones, le gain serait nul.
Enfin, Promise.all est rompue dès qu’une seule promesse échoue, et vous perdez alors les résultats des autres. Si les trois chargements sont indépendants et que vous voulez le détail de chacun, utilisez Promise.allSettled, qui renvoie toujours un tableau de { status: "fulfilled", value } ou { status: "rejected", reason }.
QCM
1. Qu’affiche ce code ? attendre(ms) renvoie une promesse tenue après ms millisecondes.
async function charger(): Promise<void> {
console.log("1");
await attendre(1000);
console.log("2");
}
charger();
console.log("3");A. 1 2 3 B. 1 3 2 C. 3 1 2 D. 3 2 1
B. Le début de charger s’exécute tout de suite. Au premier await, la fonction rend la main : 3 s’affiche, puis 2 une seconde plus tard.
2. Que contient n ?
async function lireNom(): Promise<string> {
return "Alice";
}
const n = lireNom();A. La chaîne "Alice". B. Une promesse, pas la chaîne. C. undefined. D. Rien : le code ne compile pas.
B. Une fonction async renvoie toujours une promesse : n est une Promise<string>. Pour obtenir la chaîne, il faut await lireNom().
3. lireFichier renvoie une promesse qui sera rompue. Qu’affiche ce code ?
try {
lireFichier("x.txt");
} catch (e) {
console.log("rattrapé");
}A. rattrapé B. Rien : le try est terminé avant que la promesse soit rompue. C. Une erreur de compilation. D. rattrapé, deux fois.
B. Sans await, le try ne fait que lancer l’opération. Le rejet arrive plus tard, hors du try : pas de await, pas de catch.
À retenir
- JavaScript est mono-thread : bloquer, c’est geler la page entière
- Une
Promise<T>est une valeur de typeTà venir ; son état passe une seule fois de pending à fulfilled ou rejected awaitsuspend la fonction, pas le programme ; il n’est utilisable que dans une fonctionasyncou à la racine d’un module- Une fonction
asyncretourne toujours une promesse : annotezPromise<T>, pasT - Sans
await, untry/catchn’attrape aucune erreur asynchrone Promise.allpour les opérations indépendantes,awaitenchaînés uniquement s’il y a une dépendance