Formation JS | Maîtriser l'Asynchrone
Apprenez à gérer l'asynchronisme en JavaScript en utilisant les fonctions de rappel (callbacks) et les Promesses (Promises) avec then/catch et async/await.
Si les termes « Moteur JavaScript », « Heap », « Call Stack », « APIs Web », « Callback Queue » ou encore « Event Loop » vous sont étrangers, je vous encourage vivement à découvrir comment fonctionne JavaScript avant d’entamer ce chapitre dédié au traitement de tâches asynchrones en JavaScript.
Callback
Définition
Lorsque l’on exécute du code asynchrone, les callbacks JavaScript jouent un rôle clé.
Un callback en JavaScript n’est ni plus ni moins qu’une fonction qui est passée en tant qu’argument à une autre fonction et qui sera appelée une fois que la fonction principale a terminé son exécution.
Callback synchrone
Voici un exemple simple de callback synchrone en JavaScript :
function fonctionPrincipale(firstName, cb) {
console.log(`Hello ${firstName}`);
cb();
}
fonctionPrincipale('John', () => {
console.log('Je suis le callback');
})
Dans cet exemple, la fonction fonctionPrincipale
prend deux arguments :
firstName
: une chaîne de caractèrescb
: une fonction de rappel (callback) qui sera exécutée à l’intérieur defonctionPrincipale
Lorsque fonctionPrincipale
est appelée avec callback
comme argument, la fonction principale :
-
Commence par afficher le message
Hello ${firstName}
-
Puis, appelle la fonction de rappel en utilisant l’argument
cb
. Le moment où le callback sera appelé dépendra de la durée de l’exécution de la fonction principale et de ce qui se passe dans celle-ci.
Si la mécanique des callbacks peut être utilisée de manière synchrone, les callbacks sont généralement utilisés pour récupérer les résultats d’opérations asynchrones et pour effectuer des traitements supplémentaires sur ces résultats.
Callback asynchrone
Les fonctions asynchrones en JavaScript sont souvent utilisées pour effectuer des opérations qui prennent du temps, comme :
- Des appels réseau (Fetch, Ajax…)
- Des opérations sur des fichiers (lecture et écriture)
- Des timers (décomptes et intervalles)
- L’écoute d’évènements sur l’interface (clic, saisie clavier…)
- Etc.
Lorsque vous appelez une fonction asynchrone, le script ne restera pas bloqué dans l’attente d’une réponse, mais continue bel et bien à s’exécuter.
Le modèle asynchrone de JavaScript est mis en œuvre à l’aide de fonctions de rappel, qui sont exécutées après l’achèvement d’une opération asynchrone ; les fonctions de rappel sont passées en tant qu’arguments à des fonctions asynchrones, et sont appelées une fois que la fonction asynchrone a terminé son travail.
// Une fonction qui fake avec setTimeout() le fait d'effectuer une lourde opération asynchrone de 1 sec pour retourner un fruit par son indice
function getFruit(i, cb) {
setTimeout(() => {
const fruits = ['Banane', 'Pomme', 'Poire']
cb(fruits[i])
}, 1000)
}
function main() {
console.log("Start")
getFruit(2, (item) => {
console.log(item)
})
console.log("End")
}
main()
Bien que la fonction getFruit()
soit asynchrone et ne retourne un résultat qu’au bout de 1
seconde, l’utilisation d’une fonction de callback lors de son appel, permet d’avoir la garantie que le console.log(item)
soit effectué au terme du décompte.
Les résultats seront affiché dans l’ordre suivant :
> Start
> End
> Poire
Callback Hell
Le “Callback Hell” (ou “l’enfer des callbacks” en français) se produit lorsque vous avez des fonctions de rappel imbriquées les unes dans les autres, ce qui rend le code difficile à lire et à comprendre.
Voici un exemple de code qui illustre le Callback Hell :
function getUser(userId, callback) {
// Je récupère l'utilisateur ayant l'id "userId"
const user = ...
callback(user)
}
function getUserPosts(userId, callback) {
// Je récupère les posts de l'utilisateur ayant l'id "userId"
const posts = ...
callback(posts)
}
function getPostComments(postId, callback) {
// Je récupère les commentaires du post ayant l'id "postId"
const comments = ...
callback(comments)
}
getUser(1, (user) => {
console.log(user)
getUserPosts(user.id, (posts) => {
console.log(posts)
posts.forEach((post) => {
getPostComments(post.id, (comments) => {
console.log(comments)
})
})
})
})
Dans cet exemple, imaginons que nos 3 fonctions effectuent des appels Fetch
pour récupérer les données d’un utilisateur, ses articles de blog et les commentaires associés à chacun de ses articles. Chaque fonction prend une fonction de rappel qui est appelée avec les données récupérées.
-
Nous appelons d’abord
getUser()
avec un ID d’utilisateur et une fonction de rappel pour afficher les données de l’utilisateur. -
Ensuite, nous appelons
getUserPosts()
avec l’ID de l’utilisateur pour récupérer ses articles de blog et une fonction de rappel pour les afficher. -
Enfin, nous appelons
getPostComments()
avec l’ID du post pour récupérer ses commentaires et une fonction de rappel pour les afficher.
Pour éviter le “Callback Hell” et gérer l’asynchronisme de manière plus claire et plus lisible, il est recommandé d’utiliser une approche alternative, plus moderne, basée sur les Promesses.
Promesses (Promise)
Qu’est-ce qu’une Promesse ?
En JavaScript, une Promesse (Promise
) est un objet qui représente la réalisation ou l’échec éventuel d’une opération asynchrone et qui permet d’exécuter du code en réponse à cet état.
Une Promesse peut être dans l’un des 3 états suivants :
- En attente (
pending
) : l’opération asynchrone est en cours (n’a pas encore été résolue ou rejetée). - Résolue (
fulfilled
) : l’opération asynchrone a été réussie et la Promesse a renvoyé une valeur. - Rejetée (
rejected
) : l’opération asynchrone a échoué et la Promesse a renvoyé une raison d’échec.
Pour construire une Promesse en JavaScript, on peut instancier la classe Promise
en transmettant à son constructeur une fonction qui prend deux paramètres : resolve
et reject
.
Voici un exemple de fonction retournant une Promesse :
function getRandom(max) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const random = Math.floor(Math.random() * (max + 1))
if (random >= 0) {
resolve(random)
} else {
reject("Erreur lors de la génération du nombre aléatoire")
}
}, 1000)
})
}
Ici, la fonction getRandom()
retourne une Promesse contenant :
- En cas de résolution, un nombre aléatoire
random
compris entre0
(inclus) etmax
. - En cas de rejet, un message d’erreur indiquant
Erreur lors de la génération du nombre aléatoire
.
Pour récupérer le résultat d’une Promesse, on peut ensuite utiliser 2 approches : les méthodes .then()
et .catch()
ou les fonctions asynchrones async
/ await
.
Then, catch & finally
Pour récupérer le résultat de l’exécution d’une fonction asynchrone retournant une Promesse, on appelle les méthodes .then()
et .catch()
.
Une Promesse peut être :
- Résolue (
resolve
), si l’opération asynchrone s’est exécutée avec succès. - Rejetée (
reject
), si une erreur est survenue pendant l’exécution de cette opération.
then()
La fonction .then()
permet de définir le comportement à adopter si la Promise
est résolue. Ce comportement est défini au sein de callbacks :
getRandom(10)
// Raccourci pour .then((response) => { console.log(response) })
.then(response => console.log(response))
Lorsque le moteur JavaScript rencontre une instruction then()
, il attend que la Promesse associée soit résolue ou rejetée sans suspendre l’exécution du script courant.
catch()
La fonction .catch()
permet quant à elle de définir le comportement à adopter si la Promise
est rejetée. Ce comportement est défini au sein de callbacks :
getRandom(10)
// Raccourci pour .then((response) => { console.log(response) })
.then(response => console.log(response))
.catch(error => console.log(error))
Chaîner des opérations asynchrones
Les méthodes .then()
et .catch()
retournent toutes deux une nouvelle Promesse, qui peut être utilisée pour chaîner d’autres opérations asynchrones.
fetch(`https://jsonplaceholder.typicode.com/posts`)
// Conversion du JSON en objet JS
.then(response => response.json())
// Affichage de l'objet JS
.then(data => console.log(data))
.catch(error => console.error(error))
finaly()
Une troisième méthode de l’objet Promise
, finally()
, permet de spécifier une fonction de rappel à exécuter immédiatement après que la Promesse ait été résolue ou rejetée.
Cette méthode est souvent utilisée pour effectuer des tâches de nettoyage, telles que la fermeture de fichiers ou la libération de ressources, une fois que la Promesse a été résolue ou rejetée, indépendamment de son résultat.
fetch(`https://jsonplaceholder.typicode.com/posts`)
.then(response => response.json())
.catch(error => console.error(error))
.finally(() => console.log("Promesse traitée."))
JavaScript dispose également d’un autre mécanisme asynchrone basé sur la déclaration de fonctions asynchrones (async/await
).
Async/Await
Les mots clés async
et await
ont été introduits dans le langage JavaScript en 2017 pour simplifier et rendre plus lisibles la définition et l’appel de fonctions asynchrones à base de Promesses.
L’utilisation de ces mots-clés permet d’écrire du code asynchrone visuellement proche d’un code synchrone.
Déclaration async
Avec cette approche, une fonction asynchrone est déclarée en la préfixant du mot-clé async
.
async function getUser(userId) {
// Corps de la fonction...
}
Une fonction déclarée avec le mot-clé async
retourne toujours une Promesse.
Appel / attente await
Pour appeler une fonction asynchrone déclarée avec async
, on utilise le mot-clé await
qui signifie « j’attends que la Promesse soit résolue et me retourne une valeur ».
const user = await getUser(1)
Notez que le mot-clé await
ne peut être utilisé qu’au sein d’une fonction elle-même déclarée comme asynchrone avec le mot-clé async
. Il peut alors être intéressant d’utiliser une fonction IIFE afin de déclarer une fonction et de l’appeler dans la foulée.
(async () => {
const user = await getUser(1) // ⏳ exécution suspendue
console.log(user)
})()
Lorsque le moteur JavaScript rencontre une instruction await
, il suspend l’exécution de la fonction courante jusqu’à ce que la Promesse associée à l’attente soit résolue ou rejetée.
Ainsi, quand on appelle plusieurs fonctions asynchrones d’affilée avec await
, on a la garantie qu’elles s’exécutent l’un après l’autre, à la manière d’un programme synchrone, sans pour autant bloquer l’exécution d’autres opérations asynchrones qui pourraient s’exécuter en arrière plan (timer, écouteur d’évènement…).
(async () => {
const user = await getUser(1) // ⏳ exécution suspendue
console.log(user)
const posts = await getUserPosts(user.id) // ⏳ exécution suspendue
console.log(posts)
})()
Si await
met en pause l'exécution du script, pourquoi parle-t-on d'asynchrone ? Comment exécuter du code en parallèle ?
Quand on utilise await
, le code attend que la Promesse soit résolue avant de continuer à exécuter les lignes suivantes. Dans ce contexte, await
est bloquant.
async function exemple() {
console.log("Avant await")
await getUser(1) // ⏳ exécution suspendue
console.log("Après await")
}
exemple()
Sortie :
Avant await
Après await
Ici, Après await
ne s’affiche qu’après avoir reçu la Promesse. Cela peut potentiellement créer une attente de quelques secondes…
Pourquoi parle-t-on d’asynchrone alors ?
L’aspect asynchrone signifie que JavaScript ne bloque pas l’exécution globale du programme pendant qu’il attend une Promesse. Au lieu de bloquer tout le thread, il bloque seulement l’exécution de la fonction en cours.
async function exemple() {
console.log("Avant await")
await getUser(1)
console.log("Après await")
}
exemple()
console.log("Code en dehors de la fonction")
Dans l’exemple précédent, bien que await
soit bloquant au sein de la fonction, le reste du programme n’est pas bloqué.
Sortie :
Avant await
Code en dehors de la fonction
Après await
C’est ça la puissance de l’asynchrone en JavaScript : on peut attendre un résultat sans geler tout le script. C’est grâce au moteur JavaScript, qui permet de continuer à exécuter d’autres scripts dans la file d’attente des tâches (Callback Queue
) du thread principal.
await
est donc bloquant au sein de la fonction asynchrone dans laquelle il est utilisé, mais non bloquant dans le scope global.
Pour intercepter les erreurs (Promesses rejetées), on prend l’habitude d’effectuer notre appel await
au sein d’un bloc try {} catch (err) {}
.
(async () => {
try {
const user = await getUser(1)
console.log(user)
} catch(err) {
console.log(err)
}
})()
Résumé : callbacks, then/catch et async/await
Callbacks, then/catch
et async/await
: en utilisant ces méthodes, il est possible de travailler efficacement de manière asynchrone en JavaScript, ce qui permet d’optimiser les performances et de rendre les applications plus réactives.
Voici un tableau récapitulatif opposant les 3 approches pour traiter des appels asynchrones en JavaScript en se basant sur les critères principaux :
Critère | Callbacks | Promesses avec .then() | Fonctions asynchrones avec async/await |
---|---|---|---|
Lisibilité | La syntaxe manque de clarté et de structure car les callbacks sont déclenchés un peu partout dans le code. | Les méthodes ..then() , .catch() et .finally() rendent le traitement des Promesses plus clair. | La syntaxe est encore plus légère et simple à comprendre grâce à l’utilisation des mots-clés async et await. |
Chaînage des appels | Les callbacks peuvent être chaînés, mais cela rend la syntaxe encore plus complexe et difficile à comprendre. #callbackhell | La méthode .then() peut être chaînée indéfiniment afin de traiter de multiples appels de Promesses. | Les fonctions asynchrones async permettent d’enchaîner les appels de manière encore plus simple et lisible grâce à l’utilisation du mot-clé await. |
Gestion de l’asynchronicité | La gestion de l’asynchronicité est souvent complexe et sujette aux erreurs, car il est nécessaire d’utiliser des fonctions de rappel à plusieurs niveaux d’imbrication. | La gestion de l’asynchronicité est facilitée grâce à la méthode .then() qui permet d’attendre de recevoir une Promesse, sans suspendre l’exécution de la fonction courante. | La gestion de l’asynchronicité est encore plus simplifiée grâce à l’utilisation de await qui permet d’écrire du code asynchrone comme du code synchrone. |
Gestion des erreurs | Gestion des erreurs complexe et souvent verbeuse à mettre en place. | La gestion des erreurs est facilitée grâce aux méthodes .catch() et .finally() disponibles sur les Promesses. | La gestion des erreurs est simplifiée grâce à l’utilisation d’un bloc try/catch. |
Bien que ces 3 approches (callbacks
, then/catch
et async/await
) soient fonctionnellement viables, que la qualité et la lisibilité du code dépendent en grande partie du développeur, de son style de codage et de la complexité/des besoins du programme, il est généralement admis que les fonctions asynchrones avec async/await
demeurent l’approche la plus moderne et générique pour gérer l’asynchronicité dans les programmes JavaScript modernes.
then/catch
VS async/await
Confrontons les syntaxes des 2 approches then/catch
et async/await
en traduisant un Callback Hell :
function f1(cb) {
cb('value 1')
}
function f2(cb) {
cb('value 1')
}
function f3(cb) {
cb('value 3')
}
f1((value1) => {
console.log(value1)
f2((value2) => {
console.log(value2)
f3((value3) => {
console.log(value3)
})
})
})
Avec then/catch
function f1() {
return new Promise((resolve, reject) => {
resolve('value 1')
})
}
function f2() {
return new Promise((resolve, reject) => {
resolve('value 2')
})
}
function f3() {
return new Promise((resolve, reject) => {
resolve('value 3')
})
}
f1().then((value1) => console.log(value1))
f2().then((value2) => console.log(value2))
f3().then((value3) => console.log(value3))
Avec async/await
async function f1() {
return 'value 1'
}
function f2() {
return 'value 2'
}
function f3() {
return 'value 3'
}
(async () => {
console.log(await f1())
console.log(await f2())
console.log(await f3())
})()