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

preventDefault et stopPropagation : quand utiliser chaque méthode et ce qu'elle fait exactement.

Event.stopPropagation() et Event.preventDefault()

La gestion des événements JavaScript est souvent simple. C'est particulièrement vrai lorsque vous traitez une structure HTML simple (relativement plate). Les choses se compliquent un peu plus lorsque les événements se déplacent (ou se propagent) dans une hiérarchie d'éléments. C'est généralement à ce moment-là que les développeurs se tournent vers stopPropagation() et/ou preventDefault() pour résoudre les problèmes qu'ils rencontrent. Si vous vous êtes déjà dit "Je vais essayer preventDefault() et si ça ne marche pas, j'essaierai stopPropagation() et si ça ne marche pas, j'essaierai les deux", cet article est fait pour vous ! Je vais vous expliquer exactement ce que fait chaque méthode, quand utiliser laquelle et vous fournir divers exemples concrets à explorer. Mon objectif est de mettre fin à votre confusion une fois pour toutes.

Avant d'aller plus loin, il est important d'aborder brièvement les deux types de gestion d'événements possibles en JavaScript (dans tous les navigateurs modernes, car Internet Explorer avant la version 9 ne prenait pas du tout en charge la capture d'événements).

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

Tous les navigateurs modernes sont compatibles avec la capture d'événements, mais les développeurs l'utilisent très rarement. Il est intéressant de noter qu'il s'agissait de la seule forme d'événement que Netscape prenait en charge à l'origine. Le principal concurrent de Netscape, Microsoft Internet Explorer, n'était pas du tout compatible avec la capture d'événements, mais uniquement avec un autre style d'événement appelé "propagation d'événements". Lorsque le W3C a été créé, il a trouvé des avantages aux deux styles d'événement et a déclaré que les navigateurs devaient les prendre en charge tous les deux, via un troisième paramètre de la méthode addEventListener. À l'origine, ce paramètre n'était qu'un booléen, mais tous les navigateurs modernes 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énement ou non :

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

Notez que l'objet options est facultatif, tout comme sa propriété capture. Si l'un des deux est omis, la valeur par défaut de capture est false, ce qui signifie que la propagation d'événement sera utilisée.

Capture d'événements

Que signifie le fait que votre gestionnaire d'événements "écoute en phase de capture" ? Pour comprendre cela, nous devons savoir comment les événements sont créés et comment ils se propagent. Les informations suivantes s'appliquent à tous les événements, même si vous, en tant que développeur, ne les utilisez pas, ne vous en souciez pas ou n'y pensez pas.

Tous les événements commencent au niveau de la fenêtre et passent d'abord par la phase de capture. Cela signifie que lorsqu'un événement est déclenché, il démarre la fenêtre et se déplace d'abord "vers le bas" en direction de son élément cible. Cela se produit même si vous n'écoutez que pendant la phase de propagation. Prenons l'exemple de balisage et de code 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 window est déclenché. Cet événement se propagera ensuite à ses descendants comme suit :

window => document => <html> => <body> => et ainsi de suite, jusqu'à ce qu'il atteigne la cible.

Peu importe si rien n'écoute un événement de clic au niveau de l'élément window, document, <html> ou <body> (ou tout autre élément sur le chemin de sa cible). Un événement provient toujours de window et commence son parcours comme décrit ci-dessus.

Dans notre exemple, l'événement de clic va ensuite se propager (ce mot est important, car il est directement lié au fonctionnement de la méthode stopPropagation() et sera expliqué plus loin dans ce document) à partir de window vers son élément cible (#C dans ce cas) en passant par chaque élément entre window et #C.

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

"Est-ce que quelque chose écoute un événement de clic sur le window dans la phase de capture ?" Si c'est le cas, les gestionnaires d'événements appropriés se déclencheront. Dans notre exemple, rien ne l'est, donc aucun gestionnaire ne se déclenchera.

Ensuite, l'événement va se propager au document et le navigateur va demander : "Quelqu'un écoute-t-il un événement de clic sur le document dans la phase de capture ?" Si c'est le cas, les gestionnaires d'événements appropriés se déclenchent.

Ensuite, l'événement va se propager à l'élément <html> et le navigateur va demander : "Y a-t-il un écouteur de clic sur l'élément <html> dans la phase de capture ?" Si c'est le cas, les gestionnaires d'événements appropriés se déclenchent.

Ensuite, l'événement va se propager à l'élément <body> et le navigateur va demander : "Quelqu'un écoute-t-il un événement de clic sur l'élément <body> dans la phase de capture ?" Si tel est le cas, les gestionnaires d'événements appropriés se déclenchent.

L'événement va ensuite se propager à l'élément #A. Là encore, le navigateur demandera si un élément écoute un événement de clic sur #A lors de la phase de capture. Si c'est le cas, les gestionnaires d'événements appropriés se déclencheront.

L'événement se propagera ensuite à l'élément #B (et la même question sera posée).

Enfin, l'événement atteindra sa cible et le navigateur demandera : "Quelqu'un écoute-t-il un événement de clic sur l'élément #C lors de la phase de capture ?" Cette fois, la réponse est "oui" ! Cette brève période pendant laquelle l'événement se trouve sur la cible est appelée "phase cible". À ce stade, le gestionnaire d'événements se déclenche, le navigateur affiche "#C a été cliqué" dans la console, et c'est tout, n'est-ce pas ? Mauvaise réponse. Nous n'avons pas encore terminé. Le processus se poursuit, mais passe à la phase de propagation.

Propagation d'événements

Le navigateur vous demandera :

"Est-ce que quelque chose écoute un événement de clic sur #C dans la phase de propagation ?" Soyez très attentif. Il est tout à fait possible d'écouter les clics (ou tout autre type d'événement) dans les deux phases de capture et de bouillonnement. Si vous avez configuré des gestionnaires d'événements dans les 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. Toutefois, il est important de noter qu'ils se déclenchent à des phases différentes (l'un dans la phase de capture et l'autre dans la phase de propagation).

Ensuite, l'événement va se propager (plus communément appelé "bouillonnement" car il semble que l'événement se déplace "vers le haut" de l'arborescence DOM) vers son élément parent, #B, et le navigateur va demander : "Y a-t-il quelque chose qui écoute les événements de clic sur #B dans la phase de bouillonnement ?" Dans notre exemple, rien ne l'est, donc aucun gestionnaire ne se déclenchera.

Ensuite, l'événement se propagera à #A et le navigateur demandera : "Quelqu'un écoute-t-il les événements de clic sur #A pendant la phase de bouillonnement ?"

Ensuite, l'événement se propagera à <body> : "Quelqu'un écoute-t-il les événements de clic sur l'élément <body> pendant la phase de bouillonnement ?"

Ensuite, l'élément <html> : "Est-ce que quelque chose écoute les événements de clic sur l'élément <html> dans la phase de propagation ?

Ensuite, le document : "Quelque chose écoute-t-il les événements de clic sur le document dans la phase de propagation ?"

Enfin, window : "Y a-t-il un écouteur d'événements de clic sur la fenêtre dans la phase de propagation ?"

Ouf ! C'était un long voyage, et notre événement est probablement très fatigué maintenant, mais croyez-le ou non, c'est le voyage que chaque événement traverse ! La plupart du temps, cela ne se remarque pas, car les développeurs ne s'intéressent généralement qu'à l'une ou l'autre des phases d'événement (et il s'agit généralement de la phase de propagation).

Il vaut la peine de passer un peu de temps à jouer avec la capture et la propagation d'événements, et à consigner des notes dans la console lorsque les gestionnaires se déclenchent. Il est très utile de voir le chemin emprunté par un événement. Voici un exemple qui écoute chaque élément dans les 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'élément #C), vous verrez tous ces gestionnaires d'événements se déclencher. Avec un peu de style CSS pour rendre les éléments plus évidents, voici l'élément de sortie de la console #C (avec une capture d'écran également) :

"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"

event.stopPropagation()

Maintenant que nous comprenons l'origine des événements et leur propagation dans le DOM lors des phases de capture et de bouillonnement, nous pouvons nous intéresser à event.stopPropagation().

La méthode stopPropagation() peut être appelée sur (la plupart des) événements DOM natifs. Je dis "la plupart" car il y en a quelques-uns 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 appartiennent à cette catégorie. Vous pouvez appeler stopPropagation(), mais rien d'intéressant ne se produira, car ces événements ne se propagent pas.

Mais que fait stopPropagation ?

Il fait, à peu près, ce qu'il dit. Lorsque vous l'appelez, l'événement cesse, à partir de ce moment, de se propager à tous les éléments auxquels il se rendrait normalement. Cela vaut pour les deux directions (capture et propagation). Par conséquent, si vous appelez stopPropagation() n'importe où pendant la phase de capture, l'événement n'atteindra jamais la phase cible ni la phase de propagation. Si vous l'appelez pendant la phase de bouillonnement, il aura déjà traversé la phase de capture, mais il cessera de "bouillonner" à partir du point où vous l'avez appelé.

En revenant à notre exemple de balisage, que se passerait-il, à votre avis, si nous appelions stopPropagation() dans la phase de capture au niveau de l'élément #B ?

Le résultat serait le 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"

Que diriez-vous d'arrêter la propagation à #A lors de la phase de bouillonnement ? Vous obtiendrez 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"

Encore une, juste pour le plaisir. Que se passe-t-il si nous appelons stopPropagation() dans la phase cible pour #C ? Rappelons que la "phase cible" est le nom donné à la période pendant laquelle l'événement est à sa cible. Le résultat serait le 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 enregistrons "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 de propagation". Cela devrait être parfaitement logique. Nous avons appelé stopPropagation() from l'ancien, c'est donc le point où la propagation de l'événement cessera.

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

event.stopImmediatePropagation()

Qu'est-ce que cette méthode étrange et peu utilisée ? Il est semblable à stopPropagation, mais au lieu d'empêcher un événement de se propager aux descendants (capture) ou aux ancêtres (propagation), cette méthode ne s'applique que lorsque plusieurs gestionnaires d'événements sont connectés à un même élément. Étant donné que addEventListener() prend en charge un style d'événement multicast, il est tout à fait possible de connecter un gestionnaire d'événements à un seul élément plusieurs fois. Dans ce cas, (dans la plupart des navigateurs), les gestionnaires d'événements sont exécutés dans l'ordre dans lequel ils ont été connectés. L'appel de stopImmediatePropagation() empêche le déclenchement de tout gestionnaire ultérieur. 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 génère 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 avions appelé e.stopPropagation() à la place, le troisième gestionnaire s'exécuterait quand même.

event.preventDefault()

Si stopPropagation() empêche un événement de se propager "vers le bas" (capture) ou "vers le haut" (propagation), que fait preventDefault() ? Il semble qu'il fasse quelque chose de similaire. Est-ce le cas ?

Pas vraiment. Bien qu'ils soient souvent confondus, ils n'ont en réalité pas grand-chose en commun. Lorsque vous voyez preventDefault(), ajoutez mentalement le mot "action". Pensez à "empêcher l'action par défaut".

Quelle est l'action par défaut ? Malheureusement, la réponse n'est pas aussi claire, car elle dépend fortement de la combinaison élément/événement en question. Pour rendre les choses encore plus confuses, il n'y a parfois aucune action par défaut !

Commençons par un exemple très simple. Selon vous, que va-t-il se passer si 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" qui consiste à accéder au href du lien. Et si vous souhaitiez empêcher le navigateur d'effectuer cette action par défaut ? Par exemple, 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() fera pour vous. Considérez 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,
);

Normalement, en cliquant sur le lien intitulé "The Avett Brothers", vous seriez redirigé vers www.theavettbrothers.com. Dans ce cas, nous avons associé un gestionnaire d'événements de clic à l'élément <a> et spécifié que l'action par défaut devait être empêchée. Ainsi, lorsqu'un utilisateur cliquera sur ce lien, il ne sera redirigé nulle part. La console enregistrera simplement le message "Peut-être devrions-nous simplement lire de la musique ici ?".

Quelles autres combinaisons d'éléments/d'événements vous permettent d'empêcher l'action par défaut ? Je ne peux pas tous les énumérer, et parfois, vous devez simplement faire des tests pour voir. En voici quelques-unes :

  • L'élément <form> + l'événement "submit" : preventDefault() pour cette combinaison empêchera l'envoi d'un formulaire. Cela est utile si vous souhaitez effectuer une 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>.

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

  • Événement document + "keydown" : preventDefault() pour cette combinaison est fatal. Elle rend la page largement inutilisable, en empêchant le défilement au clavier, la navigation par tabulation et la mise en surbrillance au clavier.

  • Événement document + "mousedown" : preventDefault() pour cette combinaison empêchera la mise en surbrillance du texte avec la souris et toute autre action "par défaut" qu'un utilisateur pourrait invoquer avec un clic de souris.

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

  • Événement document + "contextmenu" : preventDefault() pour cette combinaison empêche le menu contextuel natif du navigateur d'apparaître lorsqu'un utilisateur effectue un clic droit ou un appui prolongé (ou toute autre méthode d'affichage d'un menu contextuel).

Cette liste n'est en aucun cas exhaustive, mais nous espérons qu'elle vous donnera une bonne idée de la façon dont preventDefault() peut être utilisé.

Une farce amusante ?

Que se passe-t-il si vous stopPropagation() et preventDefault() pendant la phase de capture, en commençant par le document ? Le résultat est hilarant ! L'extrait de code suivant rendra n'importe quelle page Web presque 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 voudriez faire cela (sauf peut-être pour faire 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 que cela crée.

Tous les événements proviennent de window. Dans cet extrait, nous arrêtons net tous les événements click, keydown, mousedown, contextmenu et mousewheel afin qu'ils n'atteignent jamais 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 déjoués.

Notez que stopPropagation() et stopImmediatePropagation() ne sont pas (du moins pas la plupart du temps) ce qui rend la page inutile. Ils empêchent simplement les événements d'atteindre leur destination habituelle.

Mais nous appelons également preventDefault(), qui, vous vous en souviendrez, empêche l'action par défaut. Toutes les actions par défaut (comme le défilement avec la molette de la souris, le défilement au clavier, la mise en surbrillance, la navigation par tabulation, le clic sur un lien, l'affichage du menu contextuel, etc.) sont donc empêchées, ce qui laisse la page dans un état assez inutile.

Remerciements

Image héros de Tom Wilson sur Unsplash.