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.

Icône de calendrier MAJ en
Intermédiaire
9 chapitres

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 :

copié !
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ères
  • cb : une fonction de rappel (callback) qui sera exécutée à l’intérieur de fonctionPrincipale

Lorsque fonctionPrincipale est appelée avec callback comme argument, la fonction principale :

  1. Commence par afficher le message Hello ${firstName}

  2. 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.

copié !
// 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 :

copié !
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.

  1. Nous appelons d’abord getUser() avec un ID d’utilisateur et une fonction de rappel pour afficher les données de l’utilisateur.

  2. 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.

  3. 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 :

copié !
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 entre 0 (inclus) et max.
  • 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 :

copié !
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 :

copié !
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.

copié !
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.

copié !
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.

copié !
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 ».

copié !
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.

copié !
(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…).

copié !
(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.

copié !
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.

copié !
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) {}.

copié !
(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èreCallbacksPromesses 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 appelsLes callbacks peuvent être chaînés, mais cela rend la syntaxe encore plus complexe et difficile à comprendre. #callbackhellLa 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 erreursGestion 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 :

copié !
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
copié !
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
copié !
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())
})()