Análisis detallado de los eventos de JavaScript

preventDefault y stopPropagation: cuándo usar cada uno y qué hace exactamente cada método

El manejo 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 complican un poco cuando los eventos se propagan a través de una jerarquía de elementos. Por lo general, esto es cuando los desarrolladores se comunican con stopPropagation() o preventDefault() para resolver los problemas que tienen. Si alguna vez pensaste: "Voy a probar preventDefault() y, si no funciona, probaré stopPropagation() y, si no funciona, probaré ambas", este artículo es para ti. Te explicaré exactamente qué hace cada método, cuándo usarlo y te proporcionaré una variedad de ejemplos funcionales para que los explores. Mi objetivo es acabar con tu confusión de una vez por todas.

Sin embargo, antes de profundizar demasiado, es importante mencionar brevemente los dos tipos de control de eventos posibles en JavaScript (en todos los navegadores modernos, Internet Explorer antes de la versión 9 no admitía la captura de eventos).

Estilos de eventos (captura y burbujeo)

Todos los navegadores modernos admiten la captura de eventos, pero los desarrolladores rara vez la usan. Curiosamente, era la única forma de eventos que Netscape admitía originalmente. El rival más grande de Netscape, Microsoft Internet Explorer, no admitía la captura de eventos en absoluto, sino que solo admitía otro estilo de eventos llamado burbujeo de eventos. Cuando se formó el W3C, encontraron mérito en ambos estilos de eventos y declararon que los navegadores deberían admitir ambos, a través de un tercer parámetro para 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 cualquiera de ellos, el valor predeterminado de capture es false, lo que significa que se usará el burbujeo de eventos.

Captura de eventos

¿Qué significa si tu controlador de eventos está "escuchando en la fase de captura"? Para entender esto, debemos saber cómo se originan los eventos y cómo se propagan. Lo siguiente es cierto para todos los eventos, incluso si tú, como desarrollador, no los aprovechas, no te importan o no los tienes en cuenta.

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 escuchas en la fase de burbujeo. Considera el siguiente ejemplo 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 descendientes de la siguiente manera:

window => document => <html> => <body> => y así sucesivamente, hasta que alcance el objetivo.

No importa si no se está escuchando un evento de clic en el elemento window, document, <html> o <body> (o cualquier otro elemento 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 propaga (esta es una palabra importante, ya que se vinculará directamente con el funcionamiento del método stopPropagation() y se explicará más adelante en este documento) desde el 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á en window y el navegador hará las siguientes preguntas:

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

A continuación, el evento se propaga 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 propaga 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.

Luego, 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 durante la fase de captura? De ser así, se activarán los controladores de eventos adecuados.

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

Por último, el evento llegará a su destino y el navegador preguntará: "¿Hay algo que esté escuchando un evento de clic en el elemento #C en la fase de captura?". La respuesta esta vez es “sí”. Este breve período en el que el evento se lleva a cabo en el objetivo se conoce como la “fase objetivo”. En este punto, se activará el controlador de eventos, el navegador mostrará el mensaje "Se hizo clic en #C" en la consola y, luego, habremos terminado, ¿no? Incorrecto. Y eso no es todo. El proceso continúa, pero ahora pasa a la fase de burbujas.

Burbuja de eventos

El navegador te preguntará:

"¿Hay algo que escuche un evento de clic en #C en la fase de burbujas?" Presta especial atención a este punto. Es completamente posible detectar clics (o cualquier tipo de evento) en ambas fases: la de captura y la de propagación. Y si conectaste controladores de eventos en ambas fases (p. ej., llamando a .addEventListener() dos veces, una con capture = true y otra con capture = false), entonces sí, ambos controladores de eventos se activarán 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 burbujeo).

Luego, el evento se propagará (lo que se indica con mayor frecuencia como "burbuja" porque parece que el evento está viajando "hacia arriba" por el árbol del DOM) a su elemento superior, #B, y el navegador preguntará: "¿Hay algo que escuche eventos de clic en #B en la fase de burbuja?". En nuestro ejemplo, no se cumple ninguna, por lo que no se activará ningún controlador.

A continuación, el evento se propagará a #A y el navegador preguntará: "¿Hay algo que esté escuchando eventos de clic en #A en la fase de propagación?".

A continuación, el evento se propagará a <body>: "¿Hay algo que esté escuchando eventos de clic en el elemento <body> en la fase de propagación?"

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

A continuación, el document: "¿Hay algo que esté escuchando eventos de clic en el document en la fase de burbujeo?"

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

¡Vaya! Fue un recorrido largo y es probable que nuestro evento ya esté muy cansado, pero, aunque no lo creas, 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 de evento o en la otra (y, por lo general, es la fase de burbuja).

Vale la pena dedicar un tiempo a experimentar con la captura y el burbujeo de eventos, y a registrar algunas notas en la consola a medida que se activan los controladores. Es muy útil ver la ruta que sigue un evento. Este es un ejemplo que escucha todos los elementos 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 hicieras clic en el elemento “más profundo” del árbol del DOM (el elemento #C), verías que se activa cada uno de estos controladores de eventos. Con un poco de estilo CSS para que sea más obvio qué elemento es cuál, este es el elemento #C de la salida de la consola (también con 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 ella de forma interactiva en la demostración en vivo a continuación. Haz clic en el elemento #C y observa el resultado de la consola.

event.stopPropagation()

Ahora que comprendemos dónde se originan los eventos y cómo se trasladan (es decir, se propagan) a través del DOM en la fase de captura y en la fase de activación, podemos enfocarnos en event.stopPropagation().

Se puede llamar al método stopPropagation() en la mayoría de los eventos nativos del DOM. Digo “mayores” porque hay algunas en las que llamar a este método no hará nada (porque, para empezar, el evento no se propaga). Eventos como focus, blur, load, scroll y algunos otros pertenecen a esta categoría. Puedes llamar a stopPropagation(), pero no sucederá nada interesante, ya que estos eventos no se propagan.

Pero ¿qué hace stopPropagation?

Hace exactamente lo que dice. Cuando lo llames, el evento dejará de propagarse a los elementos a los que, de otro modo, se propagaría. Esto se aplica a ambas direcciones (captura y burbuja). Por lo tanto, si llamas a stopPropagation() en cualquier parte de la fase de captura, el evento nunca llegará a la fase de destino ni a la fase de burbujeo. Si la llamas en la fase de burbuja, ya habrá pasado por la fase de captura, pero dejará de “generar” a partir del punto en el que la llamaste.

Volviendo a nuestro mismo ejemplo de marcado, ¿qué crees que sucedería si llamáramos a stopPropagation() en la fase de captura en el elemento #B?

El resultado sería el siguiente:

"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 interactuar con esta función 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 detenemos la propagación en #A en la fase de burbujeo? Esto generarí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"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

Puedes interactuar con esta función 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 objetivo para #C? Recuerda que la "fase objetivo" es el nombre que se le da al período en el que el evento está en su objetivo. El resultado sería el siguiente:

"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 toda se ejecuta el controlador de eventos para #C, en el que registramos "click on #C in the capture capture" (hacer clic en #C en la fase de captura), pero no lo hace el que registra "hacer clic en #C en la fase de burbuja". Esto debería tener mucho sentido. Llamamos a stopPropagation() desde el primero, por lo que ese es el punto en el que cesará la propagación del evento.

Puedes interactuar con esta función 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 recomiendo que experimentes con cualquiera de estas demostraciones en vivo. Intenta hacer clic solo en el elemento #A o solo en el elemento body. Intenta predecir lo que sucederá y luego observa si la información es correcta. En este punto, deberías poder predecir con bastante exactitud.

event.stopImmediatePropagation()

¿Qué es este método extraño y poco usado? Es similar a stopPropagation, pero en lugar de detener un evento para que deje de viajar a descendientes (capturar) o principales (burbujas), 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 de eventos multicast, 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 dará como resultado 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 llama a e.stopImmediatePropagation(). Si, en cambio, hubiéramos llamado a e.stopPropagation(), el tercer controlador se ejecutaría de todos modos.

event.preventDefault()

Si stopPropagation() evita que un evento se transmita "hacia abajo" (captura) o "hacia arriba" (burbuja), ¿qué hace preventDefault()? Parece que hace algo similar. ¿Es así?

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

¿Cuál es la acción predeterminada que podrías solicitar? Lamentablemente, la respuesta a esa pregunta no es tan clara porque depende en gran medida de la combinación de elementos y eventos en cuestión. Y, para complicar aún más las cosas, a veces no hay ninguna acción predeterminada.

Comencemos con un ejemplo muy simple para comprenderlo. ¿Qué esperas que suceda cuando haces clic en un vínculo en una página web? Por supuesto, esperas que el navegador navegue a la URL especificada por ese vínculo. En este caso, el elemento es una etiqueta de ancla y el evento es un evento de clic. Esa combinación (<a> + click) tiene una "acción predeterminada" de navegar al href del vínculo. ¿Qué sucede si quieres evitar que el navegador realice esa acción predeterminada? Es decir, supongamos que quieres evitar que el navegador navegue a la URL especificada por el atributo href del elemento <a>. Esto es lo que preventDefault() hará por ti. Considera el siguiente 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 ella de forma interactiva en la demostración en vivo 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 redirecciona al sitio web de Avett Brothers).

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

¿Qué otras combinaciones de elementos o eventos te permiten evitar la acción predeterminada? No puedo mencionarlas todas, y a veces solo tienes que experimentar para ver. Sin embargo, aquí tienes algunos ejemplos:

  • Elemento <form> + evento "submit": preventDefault() para esta combinación evitará que se envíe un formulario. Esto es útil si deseas realizar una validación y, si algo falla, puedes llamar de forma condicional a preventDefault para evitar que se envíe el 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 "mousewheel": preventDefault() para esta combinación evita que la página se desplace con la rueda del mouse (aunque el desplazamiento con el teclado seguirá funcionando).
    ↜ Esto requiere llamar a addEventListener() con { passive: false }.

  • document + evento "keydown": preventDefault() para esta combinación es letal. Esto hace que la página sea prácticamente inútil, ya que impide el desplazamiento del teclado, el uso de la tecla Tab y el uso de la función de destacar del teclado.

  • document + evento "mousedown": preventDefault() para esta combinación evitará que se destaque texto con el mouse y cualquier otra acción "predeterminada" que se invocara con un mouse hacia abajo.

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

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

Esta lista no es exhaustiva, pero esperamos que te brinde una buena idea de cómo se puede usar preventDefault().

¿Un chiste práctico divertido?

¿Qué sucede si stopPropagation() y preventDefault() en la fase de captura, comenzando en el documento? ¡Se desata la hilaridad! El siguiente fragmento de código renderizará cualquier página web de manera que 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 });

Realmente no sé por qué querrías hacer esto (solo tal vez para broma sobre alguien), pero es útil pensar en lo que está pasando aquí y darse cuenta de por qué se crea la situación que sucede.

Todos los eventos se originan en window, por lo que, en este fragmento, detenemos todos los eventos click, keydown, mousedown, contextmenu y mousewheel para que nunca lleguen a ningún elemento que pueda escucharlos. También llamamos a stopImmediatePropagation para que se frustren todos 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 otra manera.

Sin embargo, también llamamos a preventDefault(), que recordarás que evita la acción predeterminada. Por lo tanto, se impiden todas las acciones predeterminadas (como el desplazamiento de la rueda del mouse, el desplazamiento del teclado o el resaltado o la tabulación, hacer clic en un vínculo, mostrar el menú contextual, etcétera), 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.