Un asynchrone robuste
Double clic, réponses dans le désordre, données écrasées : provoquer les bugs classiques, puis les corriger.
Objectifs de la séance
- empêcher le double envoi d'une action ;
- éviter la mise à jour perdue quand deux tâches modifient la même donnée ;
- ignorer une réponse périmée qui arrive après une réponse plus récente ;
- annuler une requête devenue inutile avec
AbortController; - représenter l'état d'une requête sans contradiction (
inactif,chargement,succes,erreur).
Petite vérification
Avant de commencer, vérifiez si vous savez...
- exécuter
async.jset lire la dernière ligne affichée - dire ce que renvoie une fonction
asyncqui se termine parreturn 5 - dire quelle propriété de la réponse d'un
fetchil faut tester avant de lire le JSON
📌 Réponse attendue
Notions théoriques
Pourquoi un code « qui marche » peut quand même bugger
Un programme asynchrone ne s'exécute pas toujours dans l'ordre où on l'a écrit : plusieurs tâches se chevauchent. Un code qui fonctionne lors d'un test tranquille peut produire des résultats faux quand l'utilisateur clique deux fois, tape vite, ou quand le réseau est lent. Ces bugs sont rares, aléatoires, donc très difficiles à reproduire. La seule vraie protection est de les prévoir.
Voici les quatre situations à connaître. Tous les exemples utilisent cette fonction fournie :
function attendre(millisecondes) {
return new Promise((resolve) => setTimeout(resolve, millisecondes));
}
Situation 1 : le double clic
Un utilisateur impatient clique deux fois sur « Envoyer ». Sans protection, la réponse est envoyée deux fois (deux scores enregistrés, deux paiements...).
La protection : un indicateur enCours, testé et activé avant le premier await.
let enCours = false;
async function envoyerReponse() {
if (enCours) {
console.log("Envoi déjà en cours, clic ignoré");
return;
}
enCours = true; // juste après le test, SANS aucun await entre les deux
try {
await attendre(300); // l'envoi réel
console.log("Réponse envoyée");
} finally {
enCours = false; // toujours remis à false, même en cas d'erreur
}
}
Le test et l'activation se font sans await entre eux : JavaScript n'exécute qu'une chose à la fois, donc personne ne peut s'intercaler. Le finally garantit que le bouton
n'est jamais bloqué définitivement.
Situation 2 : la mise à jour perdue
Deux tâches modifient la même donnée. Chacune lit la valeur, attend, puis écrit sa version : la seconde écrase le travail de la première.
let score = 0;
// FRAGILE : on lit AVANT d'attendre
async function ajouterPointsFragile(points) {
const actuel = score; // lecture
await attendre(10); // pendant ce temps, l'autre tâche modifie score
score = actuel + points; // écrit une valeur périmée
}
// (dans une fonction async)
await Promise.all([ajouterPointsFragile(10), ajouterPointsFragile(10)]);
console.log(score); // 10 au lieu de 20 : un ajout a été perdu !
La correction : lire la valeur au moment d'écrire, après l'attente.
// SÛR : on lit APRÈS avoir attendu
async function ajouterPoints(points) {
await attendre(10);
score = score + points; // lecture et écriture dans la même instruction
}
- React :
setScore((ancien) => ancien + 10)reçoit toujours la dernière valeur (au lieu desetScore(score + 10)qui utilise une photo périmée). - Base de données : un score ne se calcule pas côté application puis s'écrit ; le serveur doit faire le calcul (voir la sécurité dans la série Expo).
Situation 3 : les réponses dans le désordre
L'utilisateur tape « mot », puis « mots ». Deux recherches partent. Le réseau est capricieux : la première (« mot ») répond après la seconde. Résultat : l'écran affiche la réponse de « mot », alors que l'utilisateur a tapé « mots ». C'est une réponse périmée.
function rechercher(texte, delai) {
return attendre(delai).then(() => `Résultats pour "${texte}"`);
}
let affichage = "";
async function lancerRecherche(texte, delai) {
affichage = await rechercher(texte, delai); // BUG : la dernière réponse reçue gagne
}
lancerRecherche("mot", 300); // répond après 300 ms
lancerRecherche("mots", 100); // répond après 100 ms (plus vite)
// après 400 ms : affichage vaut "Résultats pour "mot"" (périmé !)
Correction 1 : un numéro de requête. Chaque requête reçoit un numéro. À l'arrivée de la réponse, on vérifie que c'est toujours la plus récente.
let dernierNumero = 0;
async function lancerRechercheSure(texte, delai) {
dernierNumero += 1;
const monNumero = dernierNumero; // mon numéro à moi
const resultat = await rechercher(texte, delai);
if (monNumero !== dernierNumero) {
return; // une recherche plus récente existe : cette réponse est périmée, on l'ignore
}
affichage = resultat;
}
Situation 4 : annuler une requête devenue inutile
Ignorer la réponse périmée est bien, mais la requête a quand même consommé du réseau. Mieux vaut l'annuler. Pour cela, on utilise un AbortController : il fournit un
signal que l'on transmet à la requête, et une méthode abort() pour l'annuler.
Avec fetch, c'est direct :
const controleur = new AbortController();
fetch("https://exemple.fr/api/recherche?q=mots", { signal: controleur.signal });
controleur.abort(); // annule la requête : la promesse est rompue avec une erreur "AbortError"
Pour nos exercices, voici une recherche annulable (à copier telle quelle, sans chercher à en comprendre chaque détail) :
function rechercherAnnulable(texte, delai, signal) {
return new Promise((resolve, reject) => {
const minuterie = setTimeout(() => resolve(`Résultats pour "${texte}"`), delai);
signal.addEventListener("abort", () => {
clearTimeout(minuterie);
reject(new DOMException("Recherche annulée", "AbortError"));
});
});
}
Et la façon de l'utiliser : annuler la précédente, lancer la nouvelle, distinguer une annulation volontaire d'une vraie erreur.
let controleur = null;
async function lancerRechercheAnnulable(texte, delai) {
controleur?.abort(); // on annule la recherche précédente (s'il y en a une)
const monControleur = new AbortController();
controleur = monControleur;
try {
affichage = await rechercherAnnulable(texte, delai, monControleur.signal);
} catch (erreur) {
if (erreur instanceof Error && erreur.name === "AbortError") {
return; // annulation volontaire : ce n'est pas un problème
}
throw erreur; // une vraie erreur : on la laisse remonter
}
}
C'est exactement le schéma utilisé dans un useEffect de React (partie 7) et dans la série Expo.
Bonus : l'état d'une requête, sans contradiction
Quand on gère un chargement avec plusieurs booléens (enChargement, erreur, donnees), on peut se retrouver dans un état impossible (chargement et erreur en même temps).
Il est plus sûr de n'avoir qu'une seule variable qui prend une valeur parmi un petit nombre de choix :
// statut : "inactif" | "chargement" | "succes" | "erreur"
let etat = { statut: "inactif" };
async function charger() {
etat = { statut: "chargement" };
try {
const donnees = await attendre(200).then(() => ["question 1", "question 2"]);
etat = { statut: "succes", donnees };
} catch {
etat = { statut: "erreur", message: "Chargement impossible" };
}
}
À chaque instant, l'application est dans un seul état : l'écran affiche un cercle (chargement), un message d'erreur (erreur) ou la liste (succès), jamais deux à la fois.
Pour chaque action asynchrone, posez-vous quatre questions :
- Que se passe-t-il si l'utilisateur la déclenche deux fois ?
- Que se passe-t-il si une réponse arrive en retard ?
- Que se passe-t-il si elle échoue ?
- Que se passe-t-il si deux tâches modifient la même donnée ?
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Votre mission : rendre robustes quatre fonctions asynchrones fragiles. Travaillez dans le fichier labo-js/robuste.js : placez en tête attendre, rechercher et rechercherAnnulable
(copiés de la partie « Notions théoriques »), puis ajoutez chaque étape. Chaque scénario est lancé depuis une fonction principal() (écrite à la fin).
Étape 1 — Bloquer le double envoi
Un joueur double-clique sur « Valider ». Seul un envoi doit partir.
Test et activation de enCours avant tout await, remise à false dans un finally : aucune ouverture à un deuxième envoi, aucun risque de blocage définitif.
Étape 2 — Éviter la mise à jour perdue
Deux tâches ajoutent 10 points. Le score final doit valoir 20. Complétez la version sûre.
Si une valeur partagée peut changer pendant l'attente, relisez-la après l'attente, au moment même de l'utiliser.
Étape 3 — Ignorer une réponse périmée
Deux recherches partent : « mot » (lente) puis « mots » (rapide). L'affichage final doit être celui de « mots ».
Après une attente, le monde a pu changer. Avant d'utiliser le résultat, vérifiez qu'il est toujours d'actualité.
Étape 4 — Annuler la recherche précédente
Reprenez le schéma d'annulation : la nouvelle recherche annule l'ancienne. Une annulation ne doit pas être signalée comme une erreur.
throw erreur à la fin du catch relance toute erreur qui n'est pas une annulation. Ainsi, une vraie panne n'est jamais avalée par erreur.
📌 Une solution
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
| Double envoi | Indicateur enCours testé et activé avant le premier await, remis à false dans un finally. |
| Mise à jour perdue | Ne pas lire une donnée partagée avant un await pour l'écrire après : relire au moment d'écrire. |
| Réponse périmée | Numéroter les requêtes et ignorer toute réponse qui n'est plus la plus récente. |
| Annulation | AbortController : abort() annule, AbortError n'est pas une vraie erreur, les autres sont relancées. |
| État d'une requête | Une seule variable statut (inactif, chargement, succes, erreur) évite les états contradictoires. |
| Réflexe | Se demander : deux fois ? en retard ? en échec ? même donnée modifiée par deux tâches ? |
Aperçu de la prochaine séance
Vous maîtrisez les bases du JavaScript : bravo ! Dans la partie 6, vous découvrez TypeScript, qui ajoute à tout ce que vous savez un correcteur automatique : il vérifie vos données et vos fonctions avant même l'exécution, et détecte une grande partie des erreurs vues depuis le début du cours.