Réfactorer les chaînes de Promesses en Async/Await sans changer le comportement
Comment transformer les chaînes .then() en code async/await lisible, ce que attend vraiment pause, quand exécuter les étapes en parallèle, et comment try/catch remplace .catch().
Sources: Async/Await in JavaScript: Writing Cleaner Asynchronous Code
Les promesses ont sauvé JavaScript des appels récursifs imbriqués, mais une chaîne d’étapes .then() dépendantes ressemble toujours à un réseau de tuyauteries plutôt qu’à une simple séquence d’instructions. async/await comble cette lacune en permettant au code asynchrone d’avoir l’air de code ordinaire exécuté du haut vers le bas, tout en conservant les promesses en arrière-plan. Ce guide montre comment convertir une chaîne de promesses de manière sûre, ce que await pause réellement, quand des appels await séquentiels gaspillent du temps, et comment le traitement des erreurs change, afin que vous puissiez refactoriser du code existant sans en modifier le fonctionnement.
De une chaîne then à une fonction linéaire
Voici une chaîne typique dans laquelle chaque étape dépend du résultat de la précédente : charger un utilisateur, puis ses commandes, puis les reçus correspondants, avec un gestionnaire pour toute erreur.
getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.then(receipts => {
console.log(receipts);
})
.catch(err => handleError(err));
C’est correct, et bien mieux que les appels récursifs imbriqués, mais il faut encore suivre visuellement la chaîne pour comprendre le flux des données. async/await, standardisé en ES2017, a été conçu pour éliminer cette dernière source de friction. La même logique réécrite se présente comme suit :
async function loadData(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
console.log(receipts);
} catch (err) {
handleError(err);
}
}
Le comportement correspond à la chaîne : chaque étape ne commence qu’après l’achèvement de la précédente, et toute erreur est gérée par le même gestionnaire d’erreurs. Ce qui a changé, c’est la lisibilité. Chaque valeur intermédiaire possède désormais un nom, et le flux s’écoule de haut en bas.
Gardez toujours à l’esprit un principe fondamental : async/await ne remplace pas les promesses. Il s’agit simplement d’un sucre syntaxique par rapport à elles. Les promesses continuent de gérer le travail asynchrone ; la syntaxe ne fait que modifier la manière dont on les écrit et on les lit.
Ce que modifie le mot-clé async
Placer async devant une fonction a deux effets. La fonction ci-dessous renvoie un objet ordinaire :
async function getUser(id) {
return { id, name: "Aarav" };
}
Elle renvoie toujours une promesse
Même si getUser renvoie un objet normal, l’appel à cette fonction produit une promesse qui se résout en ce même objet. Les appels utilisent donc cette promesse comme n’importe quelle autre :
getUser(1).then(user => console.log(user));
// { id: 1, name: "Aarav" }
Si la fonction lance une exception, la promesse renvoyée est rejetée au lieu que l’erreur se propage de manière synchrone. Il est facile d’oublier cela lorsque une fonction qui était auparavant synchrone devient async : tous les appels qui utilisaient directement sa valeur de retour reçoivent désormais une promesse et doivent être mis à jour.
Cela permet d’utiliser await à l’intérieur de son corps
await est valide à l’intérieur d’une fonction async. Le JavaScript moderne permet également l’utilisation de await au niveau le plus élevé dans les modules ES, ce qui est pratique pour les scripts et l’initialisation des modules, mais dans les fonctions ordinaires, on a encore besoin de async.
Ce que attend vraiment pause
Une expression await restitue le contrôle jusqu’à ce que sa promesse soit résolue d’une manière ou d’une autre. En cas de succès, l’expression produit la valeur résolue ; en cas d’échec, cette ligne lance une exception, tout comme une instruction throw. L’exemple affiche un message, attend l’utilisateur, puis affiche le résultat :
async function loadUser() {
console.log("Fetching user...");
const user = await getUser(1); // pauses here until getUser's promise settles
console.log("Got user:", user);
}
La sortie apparaît dans cet ordre :
Fetching user...
Got user: { id: 1, name: "Aarav" }
Le détail important concerne la signification du terme « pause ». Seule cette fonction est suspendue, à cette ligne précise. Le reste du programme, y compris d’autres codes, des compteurs temporels et les requêtes entrantes sur un serveur, continue de fonctionner. Lorsque la promesse est résolue, la fonction reprend là où elle s’était arrêtée, avec la valeur résolue assignée à la variable.
Les awaits séquentiels et leur coût caché
Plusieurs awaits consécutifs s’exécutent les uns après les autres :
async function loadDashboard(id) {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
}
Chaque ligne attend son propre étape avant que la suivante ne commence, ce qui reproduit le comportement en chaîne des .then() sans cette chaîne. Cela est correct lorsque chaque étape a besoin du résultat précédent, comme ici : on ne peut pas récupérer les commandes avant de connaître l’utilisateur.
Les problèmes commencent lorsque le même style est appliqué à des opérations qui ne dépendent pas les unes des autres. Dans le chargeur de tableau de bord ci-dessous, l’utilisateur, les notifications et les recommandations sont indépendants, pourtant chaque requête attend celle qui la précède :
async function loadDashboard() {
const user = await getUser();
const notifications = await getNotifications();
const recommendations = await getRecommendations();
return { user, notifications, recommendations };
}
Le temps total devient alors la somme des trois requêtes plutôt que celui de la plus lente.
Démarrer en même temps des tâches indépendantes
Puisque les trois appels sont indépendants, vous pouvez les lancer en même temps et attendre leur exécution complète à l’aide de Promise.all():
async function loadDashboard() {
const [user, notifications, recommendations] = await Promise.all([
getUser(),
getNotifications(),
getRecommendations()
]);
return { user, notifications, recommendations };
}
Toutes les trois requêtes sont en cours d’exécution simultanément, et le déstructuration vous permet d’obtenir les résultats dans le même ordre que l’array d’entrée. Un point important à connaître : Promise.all() se satisfait uniquement lorsque toutes les promesses sont satisfaites, et il échoue dès que l’une d’elles échoue, sans attendre les autres. Si vous souhaitez obtenir tous les résultats, même en cas d’échecs, Promise.allSettled() est plus adapté. Les compromis entre ces combinateurs sont abordés dans choosing between Promise.all, Promise.race and sequential awaits.
Gestion des erreurs avec try et catch
Avec async/await, le bloc familier try/catch remplace .catch(), de sorte que les erreurs synchrones et asynchrones partagent désormais une même structure :
async function loadUser(id) {
try {
const user = await getUser(id);
console.log(user);
} catch (err) {
console.log("Failed to load user:", err.message);
}
}
Si la promesse retournée par getUser(id) est rejetée, le contrôle passe directement à catch, exactement comme cela se passerait pour une erreur générée par du code synchrone. Notez que cette fonction gère l’erreur puis termine normalement, de sorte que sa propre promesse s’accomplit avec la valeur undefined. Les appels ne peuvent pas savoir que le chargement a échoué. Lorsqu’un appelant doit réagir, il faut relancer l’erreur après en avoir enregistré les détails ou retourner un résultat explicite à la place.
Un gestionnaire pour plusieurs étapes
Un seul bloc try peut couvrir toute une séquence d’opérations await :
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
const receipts = await getReceipts(orders);
return receipts;
} catch (err) {
console.log("Something failed in the dashboard load:", err.message);
}
}
Quelle que soit l’étape qui échoue, l’exécution passe directement à catch, il n’est donc pas nécessaire d’avoir un gestionnaire pour chaque étape. Cela reflète l’effet de centralisation que .catch() avait à la fin d’une chaîne. Lorsque différentes étapes nécessitent des stratégies de récupération distinctes, par exemple essayer à nouveau uniquement la requête de reçus, il suffit d’encadrer cette étape dans un try plus restreint.
Comparaison des deux styles côte à côte
Voici le même chargeur sous les deux formes :
// Promises
function loadDashboard(id) {
return getUser(id)
.then(user => getOrders(user.id))
.then(orders => getReceipts(orders))
.catch(err => console.log("Failed:", err.message));
}
// async/await
async function loadDashboard(id) {
try {
const user = await getUser(id);
const orders = await getOrders(user.id);
return await getReceipts(orders);
} catch (err) {
console.log("Failed:", err.message);
}
}
Aucune des deux versions ne modifie le fonctionnement asynchrone sous-jacent. Le choix porte sur celle que les utilisateurs peuvent lire et maintenir plus facilement, et pour une logique en plusieurs étapes, async/await gagne généralement. Un point subtil dans la deuxième version : return await getReceipts(orders) à l’intérieur de try est important. Écrire return getReceipts(orders) sans await reviendrait à renvoyer la promesse avant qu’elle ne soit résolue, de sorte qu’un échec à ce dernier étape échapperait complètement au bloc catch.
Questions fréquentes
Attendre bloque-t-il toute l’application ?
Non. Il ne suspend que la fonction asynchrone qui le contient. Le reste du code, y compris d’autres requêtes, continue de fonctionner pendant que cette fonction attend.
Pouvez-vous utiliser await en dehors d’une fonction asynchrone ?
Ce n’est pas disponible dans les fonctions ordinaires. L’exception concerne l’instruction await de niveau supérieur dans les modules ES, qui fonctionne au niveau du module mais pas à l’intérieur de fonctions non asynchrones ordinaires.
Async/await est-il plus rapide que les promesses ?
Non. Il est basé sur les promesses, donc on ne peut pas s’attendre à un avantage en termes de vitesse grâce au changement de syntaxe. Les avantages résident dans la lisibilité et la maintenabilité. Les véritables améliorations de vitesse proviennent d’exécuter des tâches indépendantes en parallèle, et non de la mot-clé elle-même.
Que se passe-t-il en l’absence de try/catch ?
Si une promesse en attente échoue et qu’aucun mécanisme ne la capture, la promesse propre de la fonction asynchrone échoue également. Si aucun appelant ne gère cet échec non plus, l’environnement de exécution signale un échec non géré, ce qui, dans les versions actuelles de Node.js, termine par défaut le processus. Gérez les erreurs avec try/catch à l’intérieur de la fonction ou avec .catch() là où elle est appelée. Pour en savoir plus sur ce type de problèmes, consultez les idées fausses courantes sur async/await qui causent des bugs en production.
Points clés
async/awaitmodifie la manière dont le code asynchrone est écrit, mais pas la manière dont il s’exécute ; ce sont toujours les promesses qui effectuent le travail.- Une fonction
asyncrenvoie toujours une promesse, donc en la convertissant en fonctionasync, on modifie son contrat pour tous les appelants.
await ne suspend que sa propre fonction, jamais l’ensemble du programme.Promise.all() ou Promise.allSettled().try/catch entourant plusieurs appels à await centralise le traitement des erreurs ; n’oubliez pas de relancer l’erreur lorsque les appels suivants doivent en être informés, et d’utiliser return await à l’intérieur de try.