Análisis detallado de los eventos de JavaScript

preventDefault y stopPropagation: Indica cuándo se debe usar cada método y qué hace exactamente.

Event.stopPropagation() y Event.preventDefault()

El control de eventos de JavaScript suele ser sencillo. Esto es especialmente cierto cuando se trata de una estructura HTML simple (relativamente plana). Sin embargo, las cosas se vuelven un poco más complejas cuando los eventos viajan (o se propagan) a través de una jerarquía de elementos. Por lo general, esto ocurre cuando los desarrolladores utilizan stopPropagation() o preventDefault() para resolver los problemas que están experimentando. Si alguna vez pensaste: "Probaré preventDefault(); si no funciona, probaré stopPropagation(). Si no funciona, probaré ambos", entonces este artículo es para ti. Explicaré exactamente qué hace cada método, cuándo usar cada uno y te proporcionaremos una variedad de ejemplos de trabajo para que explores. Mi objetivo es acabar con la confusión de una vez por todas.

Sin embargo, antes de profundizar más, es importante hacer un repaso breve de los dos tipos de control de eventos posibles en JavaScript (en todos los navegadores modernos, es decir, Internet Explorer antes de la versión 9 no admitía en absoluto la captura de eventos).

Estilos de eventos (captura y burbuja)

Todos los navegadores modernos admiten la captura de eventos, pero los desarrolladores rara vez la utilizan. Curiosamente, era la única forma de eventos que Netscape admitía originalmente. Microsoft Internet Explorer, el mayor rival de Netscape, no era compatible con la captura de eventos, sino que solo admitía otro estilo de evento llamado "burbuje de eventos". Cuando se formó el W3C, encontraron su mérito en ambos estilos de eventos y declararon que los navegadores deben admitir ambos a través de un tercer parámetro en el método addEventListener. Originalmente, ese parámetro era solo un valor booleano, pero todos los navegadores modernos admiten un objeto options como tercer parámetro, que puedes usar para especificar (entre otras cosas) si deseas usar la captura de eventos o no:

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

Ten en cuenta que el objeto options es opcional, al igual que su propiedad capture. Si se omite alguno, el valor predeterminado de capture es false, lo que significa que se usará el burbuja de eventos.

Captura de eventos

¿Qué significa si tu controlador de eventos "escucha en la fase de captura"? Para entender esto, necesitamos saber cómo se originan los eventos y cómo se trasladan. Lo siguiente se aplica a todos los eventos, incluso si tú, como desarrollador, no los aprovechas, no te interesa ni piensas en él.

Todos los eventos comienzan en la ventana y primero pasan por la fase de captura. Esto significa que, cuando se envía un evento, inicia la ventana y se desplaza "hacia abajo" hacia su elemento de destino primero. Esto sucede incluso si solo estás escuchando en la etapa de burbuja. Considera los siguientes ejemplos de lenguaje de marcado y JavaScript:

<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,
);

Cuando un usuario hace clic en el elemento #C, se envía un evento que se origina en window. Luego, este evento se propagará a través de sus elementos subordinados de la siguiente manera:

window => document => <html> => <body> => y así sucesivamente hasta llegar al objetivo.

No importa si no hay nada de escucha para un evento de clic en el elemento window, document o <html>, o en el elemento <body> (o cualquier otro elemento que se encuentre en el camino hacia su destino). Un evento aún se origina en el window y comienza su recorrido como se acaba de describir.

En nuestro ejemplo, el evento de clic se propagará (esta es una palabra importante, ya que vinculará directamente el funcionamiento del método stopPropagation() y se explicará más adelante en este documento) desde window hasta su elemento de destino (en este caso, #C) a través de cada elemento entre window y #C.

Esto significa que el evento de clic comenzará a las window y el navegador hará las siguientes preguntas:

"¿Hay algún elemento que esté escuchando un evento de clic en window durante la fase de captura?" Si es así, se activarán los controladores de eventos adecuados. En nuestro ejemplo, nada lo es, así que no se activarán controladores.

A continuación, el evento se propagará a document y el navegador preguntará: "¿Hay algo que esté escuchando un evento de clic en document en la fase de captura?". Si es así, se activarán los controladores de eventos adecuados.

A continuación, el evento se propagará al elemento <html> y el navegador preguntará: "¿Hay algo que esté escuchando un clic en el elemento <html> en la fase de captura?". Si es así, se activarán los controladores de eventos adecuados.

A continuación, el evento se propagará al elemento <body> y el navegador preguntará: "¿Hay algo que esté escuchando un evento de clic en el elemento <body> en la fase de captura?". Si es así, se activarán los controladores de eventos adecuados.

A continuación, el evento se propagará al elemento #A. Una vez más, el navegador preguntará: "¿Hay algún elemento que esté escuchando un evento de clic en #A en la fase de captura? Si es así, se activarán los controladores de eventos correspondientes.

A continuación, el evento se propagará al elemento #B (y se hará la misma pregunta).

Por último, el evento alcanzará su objetivo, y el navegador preguntará: "¿Hay algo que esté escuchando un evento de clic en el elemento #C durante la fase de captura?". Esta vez, la respuesta es “sí”. Este breve período, en el que el evento se encuentra en el objetivo, se conoce como "fase objetivo". En este punto, se iniciará el controlador de eventos, el navegador ejecutará console.log “Se hizo clic en #C” y, luego, habrás terminado, ¿verdad? Incorrecto. Y aún no hemos terminado. El proceso continúa, pero ahora pasa a la fase de burbujeación.

Evento emergente

El navegador te preguntará:

"¿Hay algún elemento que esté escuchando un evento de clic en #C durante la fase de burbuja?" Presta mucha atención aquí. Es completamente posible escuchar clics (o cualquier tipo de evento) en ambas fases, la de captura y la de burbuja. Además, si conectaste controladores de eventos en ambas fases (p.ej., si llamaste a .addEventListener() dos veces, una vez con capture = true y una vez con capture = false), entonces sí, ambos controladores de eventos se activarían absolutamente para el mismo elemento. Sin embargo, también es importante tener en cuenta que se activan en diferentes fases (una en la fase de captura y otra en la fase de burbuja).

A continuación, el evento se propagará (lo más común es "burbuja", ya que parece que el evento está viajando "hacia arriba" en el árbol del DOM) hasta su elemento superior, #B, y el navegador preguntará: "¿Hay algo que esté escuchando eventos de clic en #B en la fase de burbuja?". En nuestro ejemplo, nada lo es, por lo que no se activarán controladores.

A continuación, el evento aparecerá en una burbuja de #A, y el navegador preguntará: "¿Hay algo que esté escuchando eventos de clic en #A durante la fase de burbuja?".

A continuación, el evento aparecerá en el cuadro <body>: "¿Hay algo que esté escuchando eventos de clic en el elemento <body> durante la fase de burbuja?".

A continuación, el elemento <html>: "¿Hay algún elemento que escuche eventos de clic en el elemento <html> en la fase de burbuja?

A continuación, el objeto document: "¿Hay algún elemento que esté escuchando eventos de clic en el elemento document en la fase de burbuja?".

Por último, el elemento window: "¿Hay algo que esté escuchando eventos de clic en la ventana en la fase de burbuja?".

¡Vaya! Ese fue un largo recorrido, y nuestro evento probablemente esté muy cansado a esta altura, pero lo creas o no, ese es el recorrido que atraviesa cada evento. La mayoría de las veces, esto nunca se nota porque los desarrolladores, por lo general, solo están interesados en una fase del evento o en la otra (y suele ser la fase de burbuja).

Vale la pena dedicar algo de tiempo a experimentar con la captura de eventos, la creación de burbujas y el registro de algunas notas en la consola a medida que se activan los controladores. Es muy útil ver el camino que sigue un evento. Aquí hay un ejemplo que escucha a cada elemento en ambas fases.

<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,
);

El resultado de la consola dependerá del elemento en el que hagas clic. Si haces clic en el elemento "más profundo" del árbol del DOM (el elemento #C), verás que se activa cada uno de estos controladores de eventos. Con un poco de diseño CSS para que quede más claro cuál es el elemento, aquí está el elemento #C de salida de la consola (también incluye una captura de pantalla):

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

Puedes jugar con esto de manera interactiva en la demostración en vivo que aparece a continuación. Haz clic en el elemento #C y observa el resultado de la consola.

event.stopPropagation()

Si comprendemos dónde se originan los eventos y cómo se trasladan (es decir, se propagan) a través del DOM, tanto en la fase de captura como en la de burbuja, podemos centrar nuestra atención en event.stopPropagation().

Se puede llamar al método stopPropagation() en la mayoría de los eventos de DOM nativos. Digo "la mayoría" porque hay algunas en las que llamar a este método no hará nada (porque el evento no se propaga al principio). Los eventos como focus, blur, load, scroll y algunos más se incluyen en esta categoría. Puedes llamar a stopPropagation(), pero no sucederá nada interesante, ya que estos eventos no se propagan.

¿Qué hace stopPropagation?

Hace, en gran medida, justo lo que dice. Cuando lo llames, el evento dejará, a partir de ese punto, de propagarse a cualquier elemento al que, de otro modo, viajaría. Esto se aplica a ambas direcciones (la captura y la creación de burbujas). Por lo tanto, si llamas a stopPropagation() en cualquier punto de la fase de captura, el evento nunca llegará a la fase objetivo ni a la de burbujas. Si lo llamas en la fase de burbuja, ya habrá pasado por la fase de captura, pero dejará de "burbujear" desde el punto en el que lo llamaste.

Volviendo al mismo lenguaje de marcado de ejemplo, ¿qué crees que ocurriría si llamamos a stopPropagation() en la fase de captura del elemento #B?

daría el siguiente resultado:

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

Puedes jugar con esto de manera interactiva en la demostración en vivo que aparece a continuación. Haz clic en el elemento #C en la demostración en vivo y observa el resultado de la consola.

¿Qué tal si se detiene la propagación en #A durante la fase de burbujeación? Eso daría como resultado el siguiente resultado:

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

Puedes jugar con esto de manera interactiva en la demostración en vivo que aparece a continuación. Haz clic en el elemento #C en la demostración en vivo y observa el resultado de la consola.

Una más, solo por diversión. ¿Qué sucede si llamamos a stopPropagation() en la fase de destino de #C? Recuerda que "fase objetivo" es el nombre que se le da al período en el que el evento está en su objetivo. daría el siguiente resultado:

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

Ten en cuenta que el controlador de eventos de #C en el que registramos “clic en #C en la fase de captura” todavía se ejecuta, pero el que registramos “haz clic en #C en la fase de burbuja” no lo hace. Esto debería tener perfecto sentido. Llamamos a stopPropagation() desde el primero, por lo que ese es el punto en el que finalizará la propagación del evento.

Puedes jugar con esto de manera interactiva en la demostración en vivo que aparece a continuación. Haz clic en el elemento #C en la demostración en vivo y observa el resultado de la consola.

Te animo a que juegues en cualquiera de estas demostraciones en vivo. Intenta hacer clic solo en el elemento #A o en el elemento body. Intenta predecir lo que sucederá y, luego, observa si estás en lo cierto. En este punto, deberías poder predecir con bastante precisión.

event.stopImmediatePropagation()

¿Qué es este método extraño y no se utiliza con frecuencia? Es similar a stopPropagation, pero en lugar de detener un evento para que no viaje a descendientes (capturando) o principales (burbujeantes), este método solo se aplica cuando tienes más de un controlador de eventos conectado a un solo elemento. Dado que addEventListener() admite un estilo multicast de eventos, es completamente posible conectar un controlador de eventos a un solo elemento más de una vez. Cuando esto sucede, (en la mayoría de los navegadores), los controladores de eventos se ejecutan en el orden en que se conectaron. Llamar a stopImmediatePropagation() evita que se activen los controladores posteriores. Consulta el siguiente ejemplo:

<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,
);

El ejemplo anterior generará el siguiente resultado de la consola:

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

Ten en cuenta que el tercer controlador de eventos nunca se ejecuta debido a que el segundo controlador de eventos llama a e.stopImmediatePropagation(). Si, en cambio, llamamos a e.stopPropagation(), el tercer controlador seguiría ejecutándose.

event.preventDefault()

Si stopPropagation() evita que un evento se desplace "hacia abajo" (capturando) o "hacia arriba" (burbujeante), ¿qué hace preventDefault()? Parece que hace algo similar. ¿Sí?

En realidad, no. Si bien los dos se confunden, en realidad no tienen mucho que ver entre sí. Cuando veas preventDefault(), agrega la palabra "acción" en tu cabeza. Piensa en "evitar la acción predeterminada".

¿Cuál es la acción predeterminada que puedes solicitar? Por desgracia, la respuesta no es tan clara porque depende en gran medida de la combinación de elemento y evento en cuestión. Para que las cosas sean aún más confusas, a veces no hay ninguna acción predeterminada.

Comencemos con un ejemplo muy sencillo de entender. ¿Qué esperas que suceda cuando haces clic en un enlace de una página web? Obviamente, esperas que el navegador navegue a la URL especificada por ese vínculo. En este caso, el elemento es una etiqueta de anclaje y el evento es un evento de clic. Esa combinación (<a> + click) tiene una "acción predeterminada" que consiste en navegar al elemento href del vínculo. ¿Qué pasa si deseas evitar que el navegador realice esa acción predeterminada? Es decir, supongamos que deseas evitar que el navegador navegue a la URL especificada por el atributo href del elemento <a>. Esto es lo que preventDefault() hará por ti. Observa este ejemplo:

<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,
);

Puedes jugar con esto de manera interactiva en la demostración en vivo que aparece a continuación. Haz clic en el vínculo The Avett Brothers y observa el resultado de la consola (y el hecho de que no se te redireccionó al sitio web de Avett Brothers).

Por lo general, si se hace clic en el vínculo The Avett Brothers, se navegaría a www.theavettbrothers.com. Sin embargo, en este caso, conectamos un controlador de eventos de clic al elemento <a> y especificamos que se debería evitar la acción predeterminada. Por lo tanto, cuando un usuario haga clic en este vínculo, no se lo dirigirá a ningún lado y, en su lugar, la consola solo registrará "Quizás deberíamos reproducir parte de su música aquí mismo".

¿Qué otras combinaciones de elementos y eventos te permiten evitar la acción predeterminada? No puedo enumerarlos a todos, y a veces solo tienes que experimentar para verlos. Pero, a modo de resumen, estos son algunos:

  • Elemento <form> + evento “submit”: preventDefault() para esta combinación impedirá que se envíe un formulario. Esto es útil si deseas realizar una validación y, si algo falla, puedes llamar condicionalmente a preventDefault para detener el envío del formulario.

  • Elemento <a> + evento "click": preventDefault() para esta combinación evita que el navegador navegue a la URL especificada en el atributo href del elemento <a>.

  • document + evento "rueda del mouse": preventDefault() para esta combinación evita el desplazamiento de la página con la rueda del mouse (sin embargo, el desplazamiento con el teclado funcionaría).
    ↜ Esto requiere llamar a addEventListener() con { passive: false }.

  • document + evento "keydown": el valor de preventDefault() para esta combinación es letal. ya que la página se vuelve inútil en gran medida, lo que evita el desplazamiento del teclado, las pestañas y los elementos destacados del teclado.

  • document + evento "mousedown": preventDefault() para esta combinación evitará que el texto se destaque con el mouse y cualquier otra acción "predeterminada" que se pueda invocar con este botón.

  • Elemento <input> + evento "keypress": preventDefault() para esta combinación evitará que los caracteres escritos por el usuario lleguen al elemento de entrada (pero no lo hagas; rara vez hay un motivo válido para hacerlo).

  • document + evento "contextmenu": preventDefault() para esta combinación evita que aparezca el menú contextual del navegador nativo cuando un usuario hace clic con el botón derecho o lo mantiene presionado (o de cualquier otra manera en la que pueda aparecer un menú contextual).

Esta lista no es exhaustiva, pero con suerte te dará una buena idea de cómo se puede usar preventDefault().

¿Un chiste práctico y divertido?

¿Qué sucede si stopPropagation() y preventDefault() en la fase de captura, a partir del documento? ¡Súpere la risa! El siguiente fragmento de código hará que cualquier página web sea prácticamente inútil:

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 });

La verdad es que no sé por qué querrías hacer esto (excepto para contarle un chiste a alguien), pero es útil pensar en lo que está sucediendo aquí y darse cuenta de por qué crea la situación que genera.

Todos los eventos se originan en window. Por lo tanto, en este fragmento, detenemos y cerramos en sus pistas, todos los eventos click, keydown, mousedown, contextmenu y mousewheel para que no lleguen a cualquier elemento que pueda estar escuchandolos. También llamamos a stopImmediatePropagation para que también se anulen los controladores conectados al documento después de este.

Ten en cuenta que stopPropagation() y stopImmediatePropagation() no son (al menos no en su mayoría) lo que hace que la página sea inútil. Simplemente evitan que los eventos lleguen a donde irían de otro modo.

Pero también llamamos a preventDefault(), que recordarás que evita la acción predeterminada. Por lo tanto, se evitan todas las acciones predeterminadas (como desplazar la rueda del mouse, desplazar o destacar o destacar o presionar del teclado, hacer clic en un vínculo, mostrar el menú contextual, etc.), lo que deja la página en un estado bastante inútil.

Demostraciones en vivo

Para explorar todos los ejemplos de este artículo nuevamente en un solo lugar, consulta la demostración incorporada a continuación.

Agradecimientos

Hero image de Tom Wilson en Unsplash.