preventDefault
y stopPropagation
: Cuándo usar cada uno y qué hace exactamente cada método
Event.stopPropagation() y Event.preventDefault()
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 más cuando los eventos viajan (o se propagan) a través de una jerarquía de elementos. Por lo general, los desarrolladores recurren a stopPropagation()
o preventDefault()
para resolver los problemas que experimentan. Si alguna vez pensaste "Probaré preventDefault()
y, si no funciona, probaré stopPropagation()
y, si no funciona, probaré ambos", este artículo es para ti. Te explicaré exactamente qué hace cada método, cuándo usar cada uno y te proporcionaré una variedad de ejemplos prácticos para que explores. Mi objetivo es terminar con tu confusión de una vez por todas.
Sin embargo, antes de profundizar demasiado, es importante mencionar brevemente los dos tipos de controladores de eventos posibles en JavaScript (en todos los navegadores modernos, ya que Internet Explorer anterior a la versión 9 no admitía la captura de eventos).
Estilos de eventos (captura y propagación)
Todos los navegadores modernos admiten la captura de eventos, pero los desarrolladores la usan muy poco.
Curiosamente, fue la única forma de registro de eventos que Netscape admitió originalmente. El mayor rival de Netscape, Microsoft Internet Explorer, no admitía la captura de eventos en absoluto, sino que solo admitía otro estilo de eventos llamado propagación de eventos. Cuando se formó el W3C, se consideró que ambos estilos de eventos eran válidos y se declaró que los navegadores debí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 alguno de los dos, el valor predeterminado para capture
es false
, lo que significa que se usará la propagación de eventos.
Captura de eventos
¿Qué significa que tu controlador de eventos esté "escuchando en la fase de captura"? Para comprender esto, debemos saber cómo se originan los eventos y cómo se propagan. Lo siguiente se aplica a todos los eventos, incluso si tú, como desarrollador, no los aprovechas, no te preocupas por ellos 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 despacha un evento, se inicia la ventana y se desplaza "hacia abajo" hacia su elemento objetivo primero. Esto sucede incluso si solo escuchas en la fase de propagación. Considera el siguiente ejemplo de JavaScript y marcado:
<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 secundarios de la siguiente manera:
window
=> document
=> <html>
=> <body>
=> y así sucesivamente hasta que alcanza el objetivo.
No importa si no hay nada que escuche un evento de clic en el elemento window
, document
, <html>
o <body>
(o cualquier otro elemento en su camino hacia el objetivo). Un evento sigue originándose en window
y comienza su recorrido como se describió anteriormente.
En nuestro ejemplo, el evento de clic se propaga (esta es una palabra importante, ya que se relacionará 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 el window
y el #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 correspondientes. En nuestro ejemplo, no hay nada, por lo que no se activará ningún controlador.
A continuación, el evento se propaga al document
y el navegador pregunta: "¿Hay algo que esté escuchando un evento de clic en el document
en la fase de captura?". Si es así, se activarán los controladores de eventos correspondientes.
A continuación, el evento se propaga al elemento <html>
y el navegador pregunta: "¿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 correspondientes.
A continuación, el evento se propaga al elemento <body>
y el navegador pregunta: "¿Hay algún objeto escuchando un evento de clic en el elemento <body>
en la fase de captura?". Si es así, se activarán los controladores de eventos correspondientes.
A continuación, el evento se propaga al elemento #A
. Nuevamente, el navegador preguntará: "¿Hay algo 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.
Luego, el evento se propaga al elemento #B
(y se hará la misma pregunta).
Por último, el evento llegará a su destino y el navegador preguntará: "¿Hay algún objeto de escucha para un evento de clic en el elemento #C
en la fase de captura?". La respuesta esta vez es "¡sí!". Este breve período durante el cual el evento se encuentra en el objetivo se conoce como "fase de destino". En este punto, se activará el controlador de eventos, el navegador registrará en la consola "Se hizo clic en #C" y, luego, habremos terminado, ¿verdad?
¡Incorrecto! Aún no terminamos. El proceso continúa, pero ahora cambia a la fase de propagación.
Propagación de eventos
El navegador te preguntará lo siguiente:
"¿Hay algo que esté escuchando un evento de clic en #C
en la fase de propagación?". Presta mucha atención aquí.
Es perfectamente posible escuchar clics (o cualquier tipo de evento) en ambas fases, la de captura y la de destino. Y si hubieras conectado 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ían 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 propagación).
A continuación, el evento se propaga (se conoce más comúnmente como "destino" porque parece que el evento viaja "hacia arriba" en el árbol del DOM) a su elemento principal, #B
, y el navegador preguntará: "¿Hay algo que esté escuchando eventos de clic en #B
en la fase de destino?". En nuestro ejemplo, no hay nada, por lo que no se activará ningún controlador.
A continuación, el evento se propagará a #A
y el navegador preguntará: "¿Hay algún objeto que esté escuchando eventos de clic en #A
en la fase de propagación?".
A continuación, el evento se propagará a <body>
: "¿Hay algún objeto de escucha de eventos de clic en el elemento <body>
en la fase de propagación?".
A continuación, el elemento <html>
: "¿Hay algún elemento que esté escuchando eventos de clic en el elemento <html>
en la fase de propagación?
A continuación, el document
: "¿Hay algún objeto escuchando eventos de clic en el document
en la fase de propagación?".
Por último, window
: "¿Hay algún objeto escuchando eventos de clic en la ventana en la fase de propagación?".
¡Vaya! Ese fue un largo viaje, y nuestro evento probablemente esté muy cansado ahora, pero, lo creas o no, ese es el viaje que atraviesa cada evento. La mayoría de las veces, esto nunca se nota porque los desarrolladores suelen interesarse solo en una fase del evento o en la otra (y, por lo general, es la fase de propagación).
Vale la pena dedicarle tiempo a experimentar con la captura y la propagación de eventos, y registrar algunas notas en la consola a medida que se activan los controladores. Es muy útil ver la ruta que sigue un evento. A continuación, se muestra un ejemplo que escucha 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 hicieras clic en el elemento más "profundo" del árbol del DOM (el elemento #C
), verías que se activan todos y cada uno de estos controladores de eventos. Con un poco de diseño de CSS para que sea más obvio qué elemento es cuál, aquí está el elemento de salida de la consola #C
(con una captura de pantalla también):
"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()
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 destino, podemos centrar nuestra atención en event.stopPropagation()
.
Se puede llamar al método stopPropagation()
en (la mayoría de) los eventos nativos del DOM. Digo "la mayoría" porque hay algunos en los que llamar a este método no hará nada (porque el evento no se propaga desde el principio). En esta categoría, se incluyen eventos como focus
, blur
, load
, scroll
y algunos otros. Puedes llamar a stopPropagation()
, pero no sucederá nada interesante, ya que estos eventos no se propagan.
Pero, ¿qué hace stopPropagation
?
Hace, básicamente, lo que dice. Cuando lo llames, el evento dejará de propagarse a cualquier elemento al que, de otro modo, se propagaría. Esto se aplica a ambas direcciones (captura y burbujeo). 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 propagación. Si lo llamas en la fase de propagación, ya habrá pasado por la fase de captura, pero dejará de propagarse desde el punto en el que lo 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"
¿Qué tal si detenemos la propagación en #A
durante la fase de propagación? 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"
Una más, solo por diversión. ¿Qué sucede si llamamos a stopPropagation()
en la fase de destino para #C
?
Recuerda que la "fase objetivo" es el nombre que se le da al período en el que el evento alcanza 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 el controlador de eventos para #C
en el que registramos "click on #C in the capturing phase" aún se ejecuta, pero el que registramos "click on #C in the bubbling phase" no lo hace. 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.
Te invito a que pruebes 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 tu predicción es correcta. En este punto, deberías poder predecir con bastante precisión.
event.stopImmediatePropagation()
¿Qué es este método extraño y poco utilizado? Es similar a stopPropagation
, pero, en lugar de detener un evento para que no se propague a los descendientes (captura) o a los ancestros (propagación), 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 de multidifusión, es perfectamente 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 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 en 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, hubiéramos llamado a e.stopPropagation()
, el tercer controlador se habría ejecutado de todos modos.
event.preventDefault()
Si stopPropagation()
evita que un evento se desplace "hacia abajo" (captura) o "hacia arriba" (propagación), ¿qué hace preventDefault()
? Parece que hace algo similar. ¿Lo hace?
En realidad, no. Si bien a menudo se confunden, en realidad no tienen mucho que ver entre sí.
Cuando veas preventDefault()
, agrega mentalmente la palabra "acción". Piensa en "evitar la acción predeterminada".
¿Y cuál es la acción predeterminada?, te preguntarás. Lamentablemente, la respuesta a esa pregunta no es tan clara, ya que depende en gran medida de la combinación de elemento y evento en cuestión. Y, para que las cosas sean aún más confusas, 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 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" de navegar al href del vínculo. ¿Qué sucede si quieres impedir 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. 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,
);
Normalmente, si se hace clic en el vínculo etiquetado como 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 debe evitar la acción predeterminada. Por lo tanto, cuando un usuario haga clic en este vínculo, no se lo redireccionará a ningún lugar y, en cambio, la consola simplemente registrará el mensaje "¿Quizás deberíamos reproducir algo de su música aquí mismo?".
¿Qué otras combinaciones de elementos o eventos te permiten evitar la acción predeterminada? No puedo enumerarlos todos, y, a veces, solo tienes que experimentar para ver qué sucede. Sin embargo, a continuación, te presentamos algunos de ellos:
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 a preventDefault de forma condicional para evitar que se envíe el formulario.Elemento
<a>
+ evento "clic":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 el desplazamiento de la página con la rueda del mouse (aunque el desplazamiento con el teclado seguiría funcionando).
↜ Esto requiere llamar aaddEventListener()
con{ passive: false }
.Evento
document
+ "keydown":preventDefault()
para esta combinación es letal. Esto hace que la página sea prácticamente inútil, ya que impide el desplazamiento con el teclado, la navegación con la tecla Tab y el resaltado del teclado.Evento
document
+ "mousedown":preventDefault()
para esta combinación evitará el resaltado de texto con el mouse y cualquier otra acción "predeterminada" que se invocaría 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, si es que alguna vez, hay un motivo válido para hacerlo).Evento
document
+ "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 la que podría aparecer un menú contextual).
Esta no es una lista exhaustiva, pero esperamos que te dé una buena idea de cómo se puede usar preventDefault()
.
¿Una broma divertida?
¿Qué sucede si stopPropagation()
y preventDefault()
en la fase de captura, comenzando por el documento? ¡Se desata la hilaridad! El siguiente fragmento de código renderizará cualquier página web casi por completo 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 });
No sé por qué querrías hacer esto (quizás para jugarle una broma a alguien), pero es útil pensar en lo que sucede aquí y comprender por qué se crea la situación que se genera.
Todos los eventos se originan en window
, por lo que, en este fragmento, detenemos en seco todos los eventos click
, keydown
, mousedown
, contextmenu
y mousewheel
para que no lleguen a ningún elemento que pueda estar escuchándolos. También llamamos a stopImmediatePropagation
para que también se frustren 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 impiden que los eventos lleguen a donde de otro modo llegarían.
Pero también llamamos a preventDefault()
, que, como recordarás, evita la acción predeterminada. Por lo tanto, se impiden todas las acciones predeterminadas (como el desplazamiento con la rueda del mouse, el desplazamiento con el teclado, el resaltado, la navegación con tabulaciones, el clic en vínculos, la visualización del menú contextual, etcétera), lo que deja la página en un estado bastante inútil.
Agradecimientos
Imagen hero de Tom Wilson en Unsplash.