Si vous avez déjà utilisé l'API fetch
JavaScript pour améliorer la soumission d'un formulaire, il y a de fortes chances que vous ayez accidentellement introduit un bogue de demande en double/condition de concurrence. Aujourd'hui, je vais vous expliquer le problème et mes recommandations pour l'éviter.
(Vidéo à la fin si vous préférez)
Considérons un très basique
<form method="post"> <label for="name">Name</label> <input id="name" name="name" /> <button>Submit</button> </form>
Lorsque nous appuyons sur le bouton Soumettre, le navigateur effectue une actualisation complète de la page.
Remarquez comment le navigateur se recharge après avoir cliqué sur le bouton Soumettre.
L'actualisation de la page n'est pas toujours l'expérience que nous voulons offrir à nos utilisateurs, donc une alternative courante consiste à utiliserfetch
.
Une approche simpliste pourrait ressembler à l'exemple ci-dessous.
Après le montage de la page (ou du composant), nous récupérons le nœud DOM du formulaire, ajoutons un écouteur d'événement qui construit une requête fetch
à l'aide du formulairepreventDefault()
de l'événement.
const form = document.querySelector('form'); form.addEventListener('submit', handleSubmit); function handleSubmit(event) { const form = event.currentTarget; fetch(form.action, { method: form.method, body: new FormData(form) }); event.preventDefault(); }
Maintenant, avant que les hotshots JavaScript ne commencent à me tweeter à propos de GET vs POST et du corps de la demande etfetch
délibérément simple car ce n'est pas l'objectif principal.
Le problème clé ici est le event.preventDefault()
. Cette méthode empêche le navigateur d'exécuter le comportement par défaut de chargement de la nouvelle page et de soumission du formulaire.
Maintenant, si nous regardons l'écran et que nous appuyons sur soumettre, nous pouvons voir que la page ne se recharge pas, mais nous voyons la requête HTTP dans notre onglet réseau.
Notez que le navigateur n'effectue pas de rechargement complet de la page.
Malheureusement, en utilisant JavaScript pour empêcher le comportement par défaut, nous avons en fait introduit un bogue que le comportement par défaut du navigateur n'a pas.
Lorsque nous utilisons plaine
Si nous comparons cela à l'exemple JavaScript, nous verrons que toutes les requêtes sont envoyées, et toutes sont complètes sans qu'aucune ne soit annulée.
Cela peut être un problème, car même si chaque demande peut prendre un temps différent, elles peuvent être résolues dans un ordre différent de celui dans lequel elles ont été lancées. Cela signifie que si nous ajoutons des fonctionnalités à la résolution de ces demandes, nous pourrions avoir un comportement inattendu.
A titre d'exemple, nous pourrions créer une variable à incrémenter pour chaque requête (« totalRequestCount
»). Chaque fois que nous exécutons la fonction handleSubmit
, nous pouvons incrémenter le nombre total ainsi que capturer le nombre actuel pour suivre la demande actuelle (« thisRequestNumber
»).
Lorsqu'une requête fetch
est résolue, nous pouvons enregistrer son numéro correspondant dans la console.
const form = document.querySelector('form'); form.addEventListener('submit', handleSubmit); let totalRequestCount = 0 function handleSubmit(event) { totalRequestCount += 1 const thisRequestNumber = totalRequestCount const form = event.currentTarget; fetch(form.action, { method: form.method, body: new FormData(form) }).then(() => { console.log(thisRequestNumber) }) event.preventDefault(); }
Maintenant, si nous frappons ce bouton de soumission plusieurs fois, nous pourrions voir différents numéros imprimés sur la console dans le désordre : 2, 3, 1, 4, 5. Cela dépend de la vitesse du réseau, mais je pense que nous pouvons tous être d'accord que ce n'est pas idéal.
Considérez un scénario dans lequel un utilisateur déclenche plusieurs requêtes fetch
en succession rapprochée et, une fois terminées, votre application met à jour la page avec ses modifications. L'utilisateur pourrait finalement voir des informations inexactes en raison de demandes résolues dans le désordre.
Il ne s'agit pas d'un problème dans le monde non-JavaScript, car le navigateur annule toute requête précédente et charge la page une fois la requête la plus récente terminée, en chargeant la version la plus récente. Mais les rafraîchissements de page ne sont pas aussi sexy.
La bonne nouvelle pour les amateurs de JavaScript est que nous pouvons avoir à la fois un
Nous avons juste besoin de faire un peu plus de démarches.
Si vous consultez la documentation de l'API fetch
, vous verrez qu'il est possible d'abandonner une récupération à l'aide d'un AbortController
et de la propriété signal
des options fetch
. Cela ressemble à ceci :
const controller = new AbortController(); fetch(url, { signal: controller.signal });
En fournissant le signal de AbortContoller
à la requête fetch
, nous pouvons annuler la requête à tout moment lorsque la méthode abort
de AbortContoller
est déclenchée.
Vous pouvez voir un exemple plus clair dans la console JavaScript. Essayez de créer un AbortController
, en lançant la requête fetch
, puis en exécutant immédiatement la méthode abort
.
const controller = new AbortController(); fetch('', { signal: controller.signal }); controller.abort()
Vous devriez immédiatement voir une exception imprimée sur la console. Dans les navigateurs Chromium, il devrait dire, "Uncaught (in promise) DOMException: L'utilisateur a abandonné une demande." Et si vous explorez l'onglet Réseau, vous devriez voir une demande ayant échoué avec le texte d'état "(annulé)".
Dans cet esprit, nous pouvons ajouter un AbortController
au gestionnaire de soumission de notre formulaire. La logique sera la suivante :
AbortController
pour toutes les demandes précédentes. S'il en existe un, annulez-le.
AbortController
pour la requête en cours qui peut être abandonné lors des requêtes suivantes.
AbortController
correspondant.
Il existe plusieurs façons de procéder, mais j'utiliserai un WeakMap
pour stocker les relations entre chaque nœud DOM <form>
soumis et son AbortController
respectif. Lorsqu'un formulaire est soumis, nous pouvons vérifier et mettre à jour la WeakMap
en conséquence.
const pendingForms = new WeakMap(); function handleSubmit(event) { const form = event.currentTarget; const previousController = pendingForms.get(form); if (previousController) { previousController.abort(); } const controller = new AbortController(); pendingForms.set(form, controller); fetch(form.action, { method: form.method, body: new FormData(form), signal: controller.signal, }).then(() => { pendingForms.delete(form); }); event.preventDefault(); } const forms = document.querySelectorAll('form'); for (const form of forms) { form.addEventListener('submit', handleSubmit); }
L'essentiel est de pouvoir associer un contrôleur d'abandon à son formulaire correspondant. L'utilisation du nœud DOM du formulaire comme clé de WeakMap
est un moyen pratique de le faire.
Avec cela en place, nous pouvons ajouter le signal de AbortController
à la requête fetch
, abandonner tous les contrôleurs précédents, en ajouter de nouveaux et les supprimer à la fin.
J'espère que tout cela a du sens.
Maintenant, si nous frappons plusieurs fois le bouton d'envoi de ce formulaire, nous pouvons voir que toutes les demandes d'API, à l'exception de la plus récente, sont annulées.
Cela signifie que toute fonction répondant à cette réponse HTTP se comportera davantage comme prévu.
Maintenant, si nous utilisons la même logique de comptage et de journalisation que nous avons ci-dessus, nous pouvons écraser le bouton d'envoi sept fois et verrions six exceptions (dues à AbortController
) et un journal de "7" dans la console.
Si nous soumettons à nouveau et laissons suffisamment de temps pour que la demande soit résolue, nous verrons "8" dans la console. Et si nous écrasons le bouton Soumettre un tas de fois, encore une fois, nous continuerons à voir les exceptions et le nombre de requêtes finales dans le bon ordre.
Si vous souhaitez ajouter un peu plus de logique pour éviter de voir DOMExceptions dans la console lorsqu'une requête est abandonnée, vous pouvez ajouter un bloc .catch()
après votre requête fetch
et vérifier si le nom de l'erreur correspond à " AbortError
" :
fetch(url, { signal: controller.signal, }).catch((error) => { // If the request was aborted, do nothing if (error.name === 'AbortError') return; // Otherwise, handle the error here or throw it back to the console throw error });
Tout cet article était axé sur les formulaires améliorés par JavaScript, mais c'est probablement une bonne idée d'inclure un AbortController
chaque fois que vous créez une demande fetch
. C'est vraiment dommage qu'il ne soit pas déjà intégré à l'API. Mais j'espère que cela vous montre une bonne méthode pour l'inclure.
Il convient également de mentionner que cette approche n'empêche pas l'utilisateur de spammer le bouton d'envoi plusieurs fois. Le bouton est toujours cliquable et la demande se déclenche toujours, cela fournit simplement un moyen plus cohérent de traiter les réponses.
Malheureusement, si un utilisateur spamme un bouton d'envoi, ces demandes iront toujours à votre backend et pourraient utiliser un tas de ressources inutiles.
Certaines solutions naïves peuvent désactiver le bouton d'envoi, en utilisant un
Ils ne traitent pas les abus via des requêtes scriptées.
Pour traiter les abus d'un trop grand nombre de requêtes adressées à votre serveur, vous souhaiterez probablement configurer certains
Il convient également de mentionner que la limitation du débit ne résout pas le problème initial des demandes en double, des conditions de concurrence et des mises à jour incohérentes de l'interface utilisateur. Idéalement, nous devrions utiliser les deux pour couvrir les deux extrémités.
Quoi qu'il en soit, c'est tout ce que j'ai pour aujourd'hui. Si vous voulez regarder une vidéo qui traite du même sujet, regardez ceci.
Merci beaucoup d'avoir lu. Si vous avez aimé cet article, n'hésitez pas
Initialement publié le