Présentation détaillée des événements JavaScript

preventDefault et stopPropagation: quand utiliser quelle méthode et que fait exactement chaque méthode.

Event.stopPropagation() et Event.preventDefault()

La gestion des événements JavaScript est souvent simple. Cela est particulièrement vrai lorsqu'il s'agit d'une structure HTML simple (relativement plate). Les choses deviennent toutefois un peu plus complexes lorsque des événements se déplacent (ou se propagent) dans une hiérarchie d'éléments. C'est généralement à ce moment que les développeurs utilisent stopPropagation() et/ou preventDefault() pour résoudre les problèmes qu'ils rencontrent. Si vous vous êtes déjà pensé : "Je vais juste essayer preventDefault(). Si cela ne fonctionne pas, j'essaierai d'utiliser stopPropagation(). Si cela ne fonctionne pas, je vais essayer les deux". Cet article est fait pour vous. Je vous expliquerai exactement ce que fait chaque méthode et quand l'utiliser, et je vous fournirai une variété d'exemples concrets à explorer. Mon but est de mettre fin à cette confusion une bonne fois pour toutes.

Avant d'aller plus loin, il est important d'aborder brièvement les deux types de gestion des événements possibles avec JavaScript (dans tous les navigateurs récents, Internet Explorer avant la version 9 ne permettait pas du tout de capturer des événements).

Styles d'événement (capture et ébullition)

Tous les navigateurs récents sont compatibles avec la capture d'événements, mais les développeurs l'utilisent très rarement. Fait intéressant, c'était la seule forme d'événements prise en charge par Netscape à l'origine. Le plus grand rival de Netscape, Microsoft Internet Explorer, ne prenait pas du tout en charge la capture d'événements, mais seulement un autre style d'événements appelé ébullition d'événements. Lors de la création du W3C, l'équipe a constaté que les deux styles d'événements étaient intéressants et déclaré que les navigateurs devaient prendre en charge les deux, via un troisième paramètre de la méthode addEventListener. À l'origine, ce paramètre n'était qu'une valeur booléenne, mais tous les navigateurs récents acceptent un objet options comme troisième paramètre, que vous pouvez utiliser pour spécifier (entre autres) si vous souhaitez utiliser la capture d'événements ou non:

someElement.addEventListener('click', myClickHandler, { capture: true | false });

Notez que l'objet options est facultatif, tout comme sa propriété capture. Si l'une de ces valeurs est omise, la valeur par défaut de capture est false, ce qui signifie que l'ébullition d'événements est utilisée.

Capture d'événements

Que signifie votre gestionnaire d'événements "écoute pendant la phase de capture" ? Pour le comprendre, nous devons savoir d'où proviennent les événements et comment ils se déplacent. Ce qui suit s'applique à tous les événements, même si vous, en tant que développeur, n'utilisez pas ces informations, ne vous en souciez pas ou n'y pensez pas.

Tous les événements commencent à cette fenêtre et passent d'abord par la phase de capture. Cela signifie que lorsqu'un événement est envoyé, la fenêtre démarre et se déplace "vers le bas" vers son élément cible en premier. Cela se produit même si vous n'écoutez que pendant la phase d'ébullition. Prenons l'exemple de balisage et de JavaScript suivants:

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

Lorsqu'un utilisateur clique sur l'élément #C, un événement, provenant de l'élément window, est envoyé. Cet événement se propagera ensuite dans ses descendants comme suit:

window => document => <html> => <body> => et ainsi de suite, jusqu'à atteindre la cible.

Peu importe que rien n'écoute un événement de clic au niveau de l'élément window, document ou <html>, de l'élément <body> (ou de tout autre élément accédant à sa cible). Un événement commence toujours à l'window et commence son parcours comme indiqué précédemment.

Dans notre exemple, l'événement de clic se propage (il s'agit d'un mot important, car il est directement lié au fonctionnement de la méthode stopPropagation() et sera expliqué plus loin dans ce document) du window à son élément cible (dans ce cas, #C) par le biais de chaque élément entre window et #C.

Cela signifie que l'événement de clic commencera à window et le navigateur posera les questions suivantes:

"Un élément écoute-t-il un événement de clic sur l'élément window lors de la phase de capture ?" Le cas échéant, les gestionnaires d'événements appropriés se déclencheront. Dans notre exemple, rien ne l'est. Par conséquent, aucun gestionnaire ne se déclenchera.

Ensuite, l'événement se propagent à document et le navigateur demande: "Est-ce que quelque chose écoute un événement de clic sur document en phase de capture ?". Le cas échéant, les gestionnaires d'événements appropriés se déclencheront.

Ensuite, l'événement se propagent à l'élément <html> et le navigateur demande: "Un élément écoute-t-il un clic sur l'élément <html> lors de la phase de capture ?". Le cas échéant, les gestionnaires d'événements appropriés se déclencheront.

Ensuite, l'événement se propagent à l'élément <body> et le navigateur demande: "Un élément écoute-t-il un événement de clic sur l'élément <body> lors de la phase de capture ?". Le cas échéant, les gestionnaires d'événements appropriés se déclencheront.

Ensuite, l'événement se propagent à l'élément #A. Là encore, le navigateur demande: "Est-ce qu'un élément écoute un événement de clic sur #A en phase de capture ? Si tel est le cas, les gestionnaires d'événements appropriés se déclenchent.

Ensuite, l'événement se propagent à l'élément #B (et la même question sera posée).

Enfin, l'événement atteindra sa cible et le navigateur demandera: "Un élément écoute-t-il un événement de clic sur l'élément #C lors de la phase de capture ?". Cette fois-ci, la réponse est "oui !". Cette courte période pendant laquelle l'événement a lieu à la cible est appelée "phase cible". À ce stade, le gestionnaire d'événements se déclenche, le navigateur console.log"#C wasclick" (Clic sur #C a été cliqué), et nous avons terminé. Faux ! Nous n'avons pas du tout terminé. Le processus se poursuit, mais il passe maintenant à la phase d'ébullition.

Bulles d'événements

Le navigateur vous demandera:

"Un élément écoute-t-il un événement de clic sur #C en phase d'ébullition ?" Soyez particulièrement attentif. Il est tout à fait possible d'écouter les clics (ou tout type d'événement) lors des deux phases de capture et d'ébullition. De plus, si vous avez connecté des gestionnaires d'événements lors des deux phases (par exemple, en appelant .addEventListener() deux fois, une fois avec capture = true et une fois avec capture = false), alors oui, les deux gestionnaires d'événements se déclencheraient absolument pour le même élément. Mais il est également important de noter qu'ils se déclenchent en différentes phases (une lors de la phase de capture et l'autre lors de la phase d'ébullition).

Ensuite, l'événement se propage (plus communément appelé "bulle", car il semble qu'il se déplace "vers le haut" de l'arborescence DOM) vers son élément parent, #B. Le navigateur demande alors: "Un élément écoute-t-il les événements de clic sur #B dans la phase d'ébullition ?". Dans notre exemple, rien ne l'est, donc aucun gestionnaire ne se déclenchera.

L'événement s'affichera ensuite sur #A et le navigateur demandera: "Est-ce qu'un élément écoute les événements de clic sur #A en phase d'ébullition ?".

L'événement apparaîtra ensuite sur <body>: "Un élément écoute-t-il les événements de clic sur l'élément <body> dans la phase d'ébullition ?".

Ensuite, l'élément <html>: "Un élément écoute-t-il les événements de clic sur l'élément <html> dans la phase d'ébullition ?

Ensuite, le document: "Un élément écoute-t-il les événements de clic sur le document dans la phase d'ébullition ?".

Enfin, la window: "Est-ce que quelque chose écoute les événements de clic sur la fenêtre pendant la phase d'ébullition ?"

Ouf ! Ce fut un long voyage, et notre événement est probablement très fatigué à présent, mais croyez-le ou non, c'est le voyage que traverse chaque événement ! La plupart du temps, cette situation n'est jamais remarquée, car les développeurs ne s'intéressent qu'à une phase de l'événement ou à l'autre (il s'agit généralement de la phase d'ébullition).

Il est utile de vous familiariser avec la capture d'événements et l'ébullition d'événements, ainsi que la journalisation de quelques notes dans la console au moment du déclenchement des gestionnaires. Il est très utile de voir le chemin emprunté par un événement. Voici un exemple qui écoute chaque élément des deux phases.

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

Le résultat de la console dépend de l'élément sur lequel vous cliquez. Si vous cliquez sur l'élément "le plus profond" de l'arborescence DOM (élément #C), chacun de ces gestionnaires d'événements se déclenchera. Avec un peu de style CSS pour indiquer plus clairement l'un des éléments, voici l'élément #C de la sortie de la console (avec également une capture d'écran):

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

Vous pouvez jouer de manière interactive avec cet outil dans la démonstration en direct ci-dessous. Cliquez sur l'élément #C et observez le résultat de la console.

event.stopPropagation()

Maintenant que vous savez d'où proviennent les événements et comment ils se propagent via le DOM à la fois lors des phases de capture et d'ébullition, nous pouvons nous concentrer sur event.stopPropagation().

La méthode stopPropagation() peut être appelée sur (la plupart) des événements DOM natifs. Je dis "most" (la plupart), car il y a quelques cas sur lesquels l'appel de cette méthode ne fera rien (car l'événement ne se propage pas au début). Les événements tels que focus, blur, load, scroll et quelques autres entrent dans cette catégorie. Vous pouvez appeler stopPropagation(), mais rien d'intéressant ne se passera, car ces événements ne se propagent pas.

Mais que fait stopPropagation ?

C'est à peu près ce qu'il dit. Lorsque vous l'appelez, l'événement cesse de se propager aux éléments vers lesquels il se dirige normalement. Cela est vrai pour les deux sens (capture et ébullition). Ainsi, si vous appelez stopPropagation() n'importe où dans la phase de capture, l'événement n'atteindra jamais la phase cible ni la phase d'ébullition. Si vous l'appelez dans la phase d'ébullition, elle aura déjà franchi la phase de capture, mais elle cessera d'émettre des bulles à partir du moment où vous l'avez appelée.

Revenons à notre exemple de balisage. Selon vous, que se passerait-il si nous appelions stopPropagation() lors de la phase de capture au niveau de l'élément #B ?

Vous obtenez le résultat suivant:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

Vous pouvez jouer de manière interactive avec cet outil dans la démonstration en direct ci-dessous. Cliquez sur l'élément #C dans la démonstration en direct et observez le résultat de la console.

Que diriez-vous d'arrêter la propagation à #A en phase d'ébullition ? Vous obtenez le résultat suivant:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

Vous pouvez jouer de manière interactive avec cet outil dans la démonstration en direct ci-dessous. Cliquez sur l'élément #C dans la démonstration en direct et observez le résultat de la console.

Encore un, pour le plaisir. Que se passe-t-il si nous appelons stopPropagation() dans la phase cible pour #C ? Rappelez-vous que la "phase cible" désigne la période au cours de laquelle l'événement a atteint sa cible. Vous obtenez le résultat suivant:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

Notez que le gestionnaire d'événements pour #C, dans lequel nous consignons "clic sur #C dans la phase de capture" s'exécute toujours, mais pas celui dans lequel nous enregistrons "clic sur #C dans la phase d'ébullition". Cela devrait avoir un sens parlant. Nous avons appelé stopPropagation() à partir de la méthode précédente. Il s'agit donc du moment auquel la propagation de l'événement s'arrête.

Vous pouvez jouer de manière interactive avec cet outil dans la démonstration en direct ci-dessous. Cliquez sur l'élément #C dans la démonstration en direct et observez le résultat de la console.

Je vous encourage à tester l'une de ces démonstrations en direct. Essayez de cliquer uniquement sur l'élément #A ou sur l'élément body uniquement. Essayez de prédire ce qui va se passer, puis observez si vous avez raison. À ce stade, vous devriez être en mesure d'effectuer des prédictions assez précises.

event.stopImmediatePropagation()

Qu'est-ce que cette méthode étrange et peu utilisée ? Elle est semblable à stopPropagation, mais plutôt que d'empêcher un événement de se déplacer vers des descendants (capture) ou des ancêtres (bouillonnement), cette méthode ne s'applique que lorsque plusieurs gestionnaires d'événements sont connectés à un seul élément. Étant donné que addEventListener() est compatible avec un style d'événements de multidiffusion, il est tout à fait possible de connecter un gestionnaire d'événements à un seul élément plusieurs fois. Lorsque cela se produit (dans la plupart des navigateurs), les gestionnaires d'événements sont exécutés dans l'ordre dans lequel ils ont été branchés. L'appel de stopImmediatePropagation() empêche le déclenchement des gestionnaires suivants. Prenons l'exemple suivant :

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

L'exemple ci-dessus donne le résultat suivant dans la console:

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

Notez que le troisième gestionnaire d'événements ne s'exécute jamais, car le deuxième gestionnaire d'événements appelle e.stopImmediatePropagation(). Si nous appelions e.stopPropagation() à la place, le troisième gestionnaire s'exécuterait quand même.

event.preventDefault()

Si stopPropagation() empêche un événement de descendre (capture de données) ou de "monter vers le haut" (bouillonnement), que fait preventDefault() ? On dirait qu'il fait quelque chose de similaire. Est-ce que cela ?

Pas vraiment. Bien que les deux soient souvent confondus, ils n’ont en fait pas grand-chose à voir l’un avec l’autre. Lorsque vous voyez preventDefault(), ajoutez le mot "action" dans votre tête. Pensez « empêcher l’action par défaut ».

Et quelle est l'action par défaut que vous pouvez demander ? Malheureusement, la réponse n'est pas aussi claire, car elle dépend fortement de la combinaison élément + événement en question. Et pour compliquer encore plus les choses, il arrive qu'aucune action par défaut ne soit disponible.

Commençons par un exemple très simple à comprendre. Que pensez-vous qu'il se passe lorsque vous cliquez sur un lien sur une page Web ? Évidemment, vous vous attendez à ce que le navigateur accède à l'URL spécifiée par ce lien. Dans ce cas, l'élément est une balise d'ancrage et l'événement est un événement de clic. Cette combinaison (<a> + click) a une "action par défaut" d'accès à l'attribut href du lien. Comment faire si vous souhaitez empêcher le navigateur d'effectuer cette action par défaut ? Supposons que vous souhaitiez empêcher le navigateur d'accéder à l'URL spécifiée par l'attribut href de l'élément <a>. C'est ce que preventDefault() vous apportera. Prenons l'exemple suivant :

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

Vous pouvez jouer de manière interactive avec cet outil dans la démonstration en direct ci-dessous. Cliquez sur le lien The Avett Brothers et observez la sortie de la console (et le fait que vous n'êtes pas redirigé vers le site Web Avett Brothers).

Normalement, le fait de cliquer sur le lien intitulé "The Avett Brothers" renverra vers www.theavettbrothers.com. Toutefois, dans le cas présent, nous avons connecté un gestionnaire d'événements de clic à l'élément <a> et spécifié que l'action par défaut doit être évitée. Ainsi, lorsqu'un utilisateur clique sur ce lien, il n'est redirigé nulle part. La console enregistre simplement la phrase suivante : "Peut-être devrions-nous écouter une partie de leur musique ici à la place ?".

Quelles autres combinaisons élément/événement vous permettent d'empêcher l'action par défaut ? Je ne peux pas toutes les répertorier, et parfois, il suffit d'expérimenter pour les voir. En voici quelques-uns pour être concis:

  • Élément <form> + événement "submit" : preventDefault() pour cette combinaison empêche l'envoi d'un formulaire. Cela est utile si vous souhaitez effectuer la validation et en cas d'échec, vous pouvez appeler preventDefault de manière conditionnelle pour empêcher l'envoi du formulaire.

  • Élément <a> + événement "click" : preventDefault() pour cette combinaison empêche le navigateur d'accéder à l'URL spécifiée dans l'attribut "href" de l'élément <a>.

  • document + Événement "mousewheel" : preventDefault() pour cette combinaison empêche le défilement de la page avec la molette de la souris (cependant, le défilement avec le clavier fonctionnerait toujours).
    ↜ Cette opération nécessite d'appeler addEventListener() avec { passive: false }.

  • document + événement "keydown" : preventDefault() pour cette combinaison est mortel. La page est en grande partie inutile, ce qui empêche le défilement au clavier, les tabulations et la mise en surbrillance au clavier.

  • document + Événement "mousedown" : preventDefault() pour cette combinaison empêche la mise en surbrillance du texte avec la souris et toute autre action "par défaut" invoquée avec la souris.

  • Élément <input> + événement "keypress" : preventDefault() pour cette combinaison empêche les caractères saisis par l'utilisateur d'atteindre l'élément d'entrée (mais ne le faites pas, car cela n'a que rarement, voire jamais, une raison valable).

  • document + Événement "contextmenu" : preventDefault() pour cette combinaison empêche le menu contextuel du navigateur natif d'apparaître lorsqu'un utilisateur effectue un clic droit ou appuie de manière prolongée (ou toute autre manière permettant d'afficher un menu contextuel).

Cette liste n'est en aucun cas exhaustive, mais nous espérons qu'elle vous aidera à comprendre comment utiliser preventDefault().

Une blague pratique amusante ?

Que se passe-t-il si vous effectuez des actions stopPropagation() et preventDefault() lors de la phase de capture, à partir du document ? L'hilarité s'ensuit ! L'extrait de code suivant rend toute page Web complètement inutile:

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

Je ne sais pas vraiment pourquoi vous voudrez faire ça (sauf peut-être pour jouer une blague à quelqu'un), mais il est utile de réfléchir à ce qui se passe ici et de comprendre pourquoi cela crée la situation.

Tous les événements proviennent de window. Dans cet extrait, nous arrêtons donc, dans leurs pistes, tous les événements click, keydown, mousedown, contextmenu et mousewheel d'atteindre les éléments susceptibles de les écouter. Nous appelons également stopImmediatePropagation afin que tous les gestionnaires connectés au document après celui-ci soient également bloqués.

Notez que stopPropagation() et stopImmediatePropagation() ne sont pas (du moins pas principalement) ce qui rend la page inutile. Ils empêchent simplement les événements d'arriver là où ils seraient autrement.

Mais nous appelons également preventDefault(), dont vous vous souvenez, et empêche l'action par défaut. Ainsi, toutes les actions par défaut (comme le défilement de la molette de la souris, le défilement du clavier, la mise en surbrillance, la sélection de la touche de tabulation, les clics sur un lien, l'affichage du menu contextuel, etc.) sont toutes évitées, laissant la page dans un état relativement inutile.

Démonstrations en direct

Pour revoir tous les exemples de cet article au même endroit, consultez la démo intégrée ci-dessous.

Remerciements

Image principale de Tom Wilson sur Unsplash.