Detalhamento do evento JavaScript

preventDefault e stopPropagation: quando usar qual e o que exatamente cada método faz.

Event.stopPropagation() e Event.preventDefault()

O tratamento dos eventos JavaScript costuma ser simples. Isso é especialmente verdadeiro ao lidar com uma estrutura HTML simples (relativamente plana). No entanto, as coisas ficam um pouco mais envolvidas quando os eventos viajam (ou se propagam) por uma hierarquia de elementos. Geralmente, isso ocorre quando os desenvolvedores usam stopPropagation() e/ou preventDefault() para resolver os problemas. Se você já pensou "Vou tentar o preventDefault() e, se não funcionar, vou tentar stopPropagation() e, se não funcionar, vou tentar os dois", então este artigo é para você. Explicarei exatamente o que cada método faz e quando usar cada um, além de apresentar vários exemplos funcionais para você explorar. Meu objetivo é acabar com sua confusão de uma vez por todas.

Antes de nos aprofundarmos, é importante mencionar brevemente os dois tipos de manipulação de eventos possíveis no JavaScript. Em todos os navegadores modernos, o Internet Explorer antes da versão 9 não era compatível com a captura de eventos.

Estilos de evento (captura e propagação)

Todos os navegadores mais recentes são compatíveis com a captura de eventos, mas ela raramente é usada por desenvolvedores. Curiosamente, era a única forma de eventing originalmente compatível com o Netscape. O maior rival do Netscape, o Microsoft Internet Explorer, não era compatível com a captura de eventos, mas apenas aceitou outro estilo de evento chamado de propagação de evento. Quando o W3C foi formado, eles encontraram mérito em ambos os estilos de eventing e declararam que os navegadores precisam oferecer suporte a ambos, usando um terceiro parâmetro para o método addEventListener. Originalmente, esse parâmetro era apenas um booleano, mas todos os navegadores mais recentes oferecem suporte a um objeto options como terceiro parâmetro, que pode ser usado para especificar, entre outras coisas, se você quer usar a captura de eventos ou não:

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

Observe que o objeto options e a propriedade capture são opcionais. Se um deles for omitido, o valor padrão de capture será false, o que significa que a propagação de eventos vai ser usada.

Captura de eventos

O que significa quando o manipulador de eventos está "detectando a fase de captura"? Para entender isso, precisamos saber como os eventos se originam e como eles se deslocam. As informações a seguir são verdadeiras para todos os eventos, mesmo que você, como desenvolvedor, não utilize, se preocupe com isso ou não pense sobre isso.

Todos os eventos começam na janela e passam primeiro pela fase de captura. Isso significa que, quando um evento é enviado, ele inicia a janela e se desloca "para baixo" em direção ao elemento de destino primeiro. Isso acontece mesmo se você estiver ouvindo apenas na fase de bolhas. Considere o exemplo de marcação e JavaScript a seguir:

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

Quando o usuário clica no elemento #C, um evento, originado no window, é enviado. Esse evento será propagado pelos descendentes da seguinte maneira:

window => document => <html> => <body> => e assim por diante, até atingir a meta.

Não importa se nada está detectando um evento de clique no elemento window, document, <html> ou <body> (ou qualquer outro a caminho do destino). Um evento ainda tem origem no window e inicia a jornada conforme acabou de ser descrito.

No nosso exemplo, o evento de clique será propagado (essa é uma palavra importante, porque está diretamente ligada à forma como o método stopPropagation() funciona e será explicado mais adiante neste documento) do window para o elemento de destino (neste caso, #C) usando cada elemento entre window e #C.

Isso significa que o evento de clique começará em window e o navegador fará as seguintes perguntas:

"Alguma coisa está detectando um evento de clique na window na fase de captura?" Nesse caso, os manipuladores de eventos apropriados serão disparados. Em nosso exemplo, nada é, então nenhum manipulador será disparado.

Em seguida, o evento se propagará para o document, e o navegador perguntará: "Alguma coisa está ouvindo um evento de clique no document na fase de captura?" Nesse caso, os manipuladores de eventos apropriados serão acionados.

Em seguida, o evento se propaga para o elemento <html>, e o navegador pergunta: "Há algo detectando um clique no elemento <html> na fase de captura?" Nesse caso, os manipuladores de eventos apropriados serão acionados.

Em seguida, o evento se propagará para o elemento <body>, e o navegador perguntará: "Há algo detectando um evento de clique no elemento <body> na fase de captura?" Nesse caso, os manipuladores de eventos apropriados serão acionados.

Em seguida, o evento se propagará para o elemento #A. Novamente, o navegador perguntará: "Se há algo detectando um evento de clique em #A na fase de captura e, em caso afirmativo, os manipuladores de eventos apropriados são disparados.

Em seguida, o evento se propagará para o elemento #B (e a mesma pergunta será feita).

Por fim, o evento chegará ao destino, e o navegador perguntará: "Algo está detectando um evento de clique no elemento #C na fase de captura?" Desta vez, a resposta é "sim!" Esse breve período em que o evento está no destino é conhecido como "fase de destino". Nesse ponto, o manipulador de eventos será acionado, o navegador irá console.log "#C foi clicado" e pronto! Errado! Não terminamos. O processo continua, mas agora muda para a fase de propagação.

Direcionamento de eventos

O navegador vai perguntar:

"Alguma coisa está ouvindo um evento de clique em #C na fase de propagação?" Preste muita atenção aqui. É completamente possível detectar cliques (ou qualquer tipo de evento) nas fases de captura e de propagação. E se você tivesse conectado os manipuladores de eventos nas duas fases (por exemplo, chamando .addEventListener() duas vezes, uma com capture = true e outra com capture = false), sim, os dois manipuladores de eventos seriam disparados absolutamente para o mesmo elemento. Mas também é importante notar que eles são disparados em fases diferentes (uma na fase de captura e outra na fase de bolhas).

Em seguida, o evento se propagará, o que geralmente é chamado de "balão", porque parece que o evento está "subindo" na árvore DOM, até o elemento pai, #B, e o navegador perguntará: "Há algo detectando eventos de clique em #B na fase de propagação?" Em nosso exemplo, não há nada, então nenhum gerenciador será acionado.

Em seguida, o evento aparecerá em #A, e o navegador perguntará: "Alguma coisa está detectando eventos de clique em #A na fase de propagação?"

Em seguida, o evento aparecerá em um balão para <body>: "Alguma coisa está detectando eventos de clique no elemento <body> na fase de propagação?"

Em seguida, o elemento <html>: "Alguma coisa detecta eventos de clique no elemento <html> na fase de propagação?

Em seguida, a document: "Alguma coisa está ouvindo eventos de clique na document na fase de propagação?"

Por fim, window: "Alguma coisa está ouvindo eventos de clique na janela na fase de propagação?".

Ufa. Foi uma longa jornada, e nosso evento provavelmente está muito cansado agora, mas, acredite ou não, essa é a jornada pela qual cada evento passa! Na maioria das vezes, isso nunca é notado porque os desenvolvedores geralmente estão interessados apenas em uma fase do evento ou na outra, e geralmente é a fase de propagação.

Vale a pena testar a captura de eventos, a propagação de eventos e o registro de algumas notas no console enquanto os gerenciadores são disparados. É muito esclarecedor ver o caminho que um evento percorre. Aqui está um exemplo que ouve todos os elementos em ambas as 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,
);

A saída do console dependerá do elemento em que você clicar. Se você clicar no elemento "mais profundo" na árvore do DOM (o elemento #C), verá cada um desses manipuladores de eventos ser disparados. Com um pouco de estilo CSS para tornar mais óbvio qual elemento é qual, aqui está o elemento #C de saída do console (com uma captura de tela também):

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

Teste isso interativamente na demonstração ao vivo abaixo. Clique no elemento #C e observe a saída do console.

event.stopPropagation()

Com uma compreensão da origem dos eventos e de como eles viajam (ou seja, se propagam) pelo DOM tanto na fase de captura quanto na de propagação, agora podemos voltar nossa atenção para event.stopPropagation().

O método stopPropagation() pode ser chamado na maioria dos eventos DOM nativos. Digo "a maioria" porque há alguns em que chamar esse método não fará nada (porque o evento não se propaga para começar). Eventos como focus, blur, load, scroll e alguns outros se enquadram nessa categoria. Você pode chamar stopPropagation(), mas nada interessante acontecerá, já que esses eventos não são propagados.

Mas o que a stopPropagation faz?

Ele faz praticamente o que diz. Quando você o chamar, o evento deixará de se propagar para os elementos para os quais ele viajaria. Isso é válido para as duas direções (captura e espuma). Portanto, se você chamar stopPropagation() em qualquer lugar da fase de captura, o evento nunca vai chegar à fase de destino ou de propagação. Se você chamá-la na fase de Bolha, ela já terá passado pela fase de captura, mas deixará de ser chamada a partir do ponto em que você a chamou.

Voltando à mesma marcação de exemplo, o que você acha que aconteceria se chamássemos stopPropagation() na fase de captura no elemento #B?

Isso resultaria na seguinte saída:

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

Teste isso interativamente na demonstração ao vivo abaixo. Clique no elemento #C na demonstração ao vivo e observe a saída do console.

E se quiser interromper a propagação em #A na fase de propagação? Isso resultaria na seguinte saída:

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

Teste isso interativamente na demonstração ao vivo abaixo. Clique no elemento #C na demonstração ao vivo e observe a saída do console.

Mais uma vez, só por diversão. O que acontece se chamarmos stopPropagation() na fase de destino para #C? Lembre-se de que a "fase de destino" é o nome dado ao período em que o evento está no destino. Isso resultaria na seguinte saída:

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

Observe que o manipulador de eventos para #C, em que registramos "clicar em #C na fase de captura", ainda é executado, mas o em que registramos "clique em #C na fase de propagação" não. Isso deve fazer muito sentido. Chamamos stopPropagation() do primeiro, de modo que esse é o ponto em que a propagação do evento cessará.

Teste isso interativamente na demonstração ao vivo abaixo. Clique no elemento #C na demonstração ao vivo e observe a saída do console.

Incentive você a brincar em qualquer uma dessas demonstrações ao vivo. Tente clicar apenas no elemento #A ou apenas no elemento body. Tente prever o que vai acontecer e confira se você acertou. Neste ponto, você conseguirá fazer previsões com bastante precisão.

event.stopImmediatePropagation()

O que é esse método estranho e não muito usado? Ele é semelhante a stopPropagation, mas em vez de impedir que um evento chegue aos descendentes (capturando) ou ancestrais (borboleta), esse método só se aplica quando você tem mais de um manipulador de eventos conectado a um único elemento. Como addEventListener() oferece suporte a um estilo multicast de eventos, é completamente possível conectar um manipulador de eventos a um único elemento mais de uma vez. Quando isso acontece, na maioria dos navegadores, os manipuladores de eventos são executados na ordem em que foram conectados. Chamar stopImmediatePropagation() impede que todos os gerenciadores subsequentes sejam acionados. Confira o exemplo abaixo:

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

O exemplo acima vai resultar na seguinte saída do console:

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

O terceiro manipulador de eventos nunca é executado porque o segundo chama e.stopImmediatePropagation(). Se tivéssemos chamado e.stopPropagation(), o terceiro gerenciador ainda seria executado.

event.preventDefault()

Se stopPropagation() impedir que um evento aconteça "para baixo" (capturando) ou "para cima" (borboleta), o que preventDefault() fará? Parece que ele faz algo semelhante. Faz isso?

Na verdade, não. Embora muitas vezes os dois fiquem confusos, eles não têm muito a ver um com o outro. Quando preventDefault() aparecer na sua cabeça, adicione a palavra "ação". Pense em "evitar a ação padrão".

E qual é a ação padrão que você pode perguntar? Infelizmente, a resposta não é tão clara porque depende muito da combinação de elemento + evento em questão. Para tornar as coisas ainda mais confusas, às vezes não há nenhuma ação padrão.

Vamos começar com um exemplo muito simples de entender. O que você espera que aconteça ao clicar em um link em uma página da Web? Obviamente, você espera que o navegador navegue para o URL especificado por aquele link. Nesse caso, o elemento é uma tag âncora e o evento é um evento de clique. Essa combinação (<a> + click) tem uma "ação padrão" de navegar para o href do link. E se você quiser impedir que o navegador execute essa ação padrão? Ou seja, suponha que você queira impedir que o navegador navegue até o URL especificado pelo atributo href do elemento <a>? É isso que preventDefault() vai fazer por você. Veja este exemplo:

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

Teste isso interativamente na demonstração ao vivo abaixo. Clique no link The Avett Brothers e observe a saída do console (e o fato de você não ser redirecionado para o site do Avett Brothers).

Normalmente, clicar no link chamado The Avett Brothers resultaria na navegação para www.theavettbrothers.com. Nesse caso, conectamos um manipulador de eventos de clique ao elemento <a> e especificamos que a ação padrão precisa ser impedida. Assim, quando um usuário clicar nesse link, ele não será direcionado para nenhum lugar. Em vez disso, o console simplesmente registrará "Talvez a gente precise apenas tocar algumas das músicas aqui?".

Que outras combinações de elemento/evento permitem evitar a ação padrão? não posso listar todos, e às vezes você tem que apenas experimentar para ver. Brevemente, aqui estão alguns:

  • Elemento <form> + evento "submit": preventDefault() para esta combinação impedirá o envio de um formulário. Isso é útil se você quiser realizar a validação e, se algo falhar, é possível chamar condicionalmente preventDefault para interromper o envio do formulário.

  • Elemento <a> + evento "click": preventDefault() para essa combinação impede que o navegador navegue até o URL especificado no atributo href do elemento <a>.

  • document + evento "mousewheel": preventDefault() para essa combinação impede a rolagem da página com a roda do mouse. No entanto, rolar com o teclado ainda funciona.
    ↜ É necessário chamar addEventListener() com { passive: false }.

  • document + evento "keydown": preventDefault() para esta combinação é letal. Isso torna a página inútil, impedindo a rolagem do teclado, a tabulação e o destaque do teclado.

  • document + evento "mousedown": preventDefault() para essa combinação evita o destaque de texto com o mouse e qualquer outra ação "padrão" que seria invocada com o mouse para baixo.

  • Elemento <input> + evento "pressionar": preventDefault() para essa combinação vai evitar que os caracteres digitados pelo usuário cheguem ao elemento de entrada, mas não faça isso, raramente há um motivo válido para isso.

  • document + evento "contextmenu": preventDefault() para essa combinação impede que o menu de contexto do navegador nativo apareça quando um usuário clicar com o botão direito do mouse ou tocar nele e mantê-lo pressionado (ou qualquer outra forma de exibição de um menu de contexto).

Essa não é uma lista completa, mas esperamos que dê uma boa ideia de como preventDefault() pode ser usado.

Uma piada prática?

O que acontece se você stopPropagation() e preventDefault() estiverem na fase de captura, começando no documento? O hilário vem aí! O snippet de código a seguir inutilizará qualquer página da Web:

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

Eu realmente não sei por que você iria querer fazer isso (exceto talvez pregar uma piada sobre alguém), mas é útil pensar sobre o que está acontecendo aqui e perceber por que isso cria a situação.

Todos os eventos se originam em window. Portanto, neste snippet, estamos interrompendo, sem interrupções, todos os eventos click, keydown, mousedown, contextmenu e mousewheel ao acessar qualquer elemento que possa estar detectando eles. Também chamamos stopImmediatePropagation para que todos os gerenciadores conectados ao documento depois desse também sejam frustrados.

Observe que stopPropagation() e stopImmediatePropagation() não são (pelo menos não praticamente) o que tornam a página inútil. Eles simplesmente evitam que os eventos cheguem aonde eles iriam de outra forma.

Mas também chamamos preventDefault(), que impede a ação padrão. Assim, todas as ações padrão (como rolar o mouse, rolar pelo teclado, destacar ou usar a tecla Tab, clicar em links, exibição do menu de contexto etc.) são impedidas, deixando a página em um estado razoavelmente inútil.

Demonstrações ao vivo

Para explorar todos os exemplos deste artigo em um só lugar, confira a demonstração incorporada abaixo.

Agradecimentos

Imagem principal de Tom Wilson no Unsplash (links em inglês).