Lidando com Eventos

Você tem poder sobre sua mente—não sobre eventos externos. Perceba isso, e você encontrará força.

Marcus Aurelius, Meditations
Ilustração mostrando uma máquina de Rube Goldberg envolvendo uma bola, uma gangorra, uma tesoura e um martelo, que afetam uns aos outros em uma reação em cadeia que acende uma lâmpada.

Alguns programas trabalham com entrada direta do usuário, como ações de mouse e teclado. Esse tipo de entrada não está disponível previamente como uma estrutura de dados bem organizada—ela chega aos poucos, em tempo real, e o programa precisa reagir a ela conforme acontece.

Manipuladores de eventos

Imagine uma interface em que a única forma de descobrir se uma tecla do teclado está sendo pressionada é ler o estado atual dessa tecla. Para conseguir reagir a pressionamentos de tecla, você teria que ler constantemente o estado da tecla para capturá-lo antes que ela seja solta novamente. Seria perigoso realizar outros cálculos demorados, pois você poderia perder um pressionamento.

Algumas máquinas primitivas lidam com entrada dessa forma. Um passo além disso é o hardware ou o sistema operacional perceber o pressionamento da tecla e colocá-lo em uma fila. Um programa pode então verificar periodicamente a fila em busca de novos eventos e reagir ao que encontrar ali.

Claro, o programa precisa se lembrar de verificar a fila, e fazê-lo com frequência, porque qualquer tempo entre a tecla ser pressionada e o programa perceber o evento fará o software parecer pouco responsivo. Essa abordagem é chamada de polling. A maioria dos programadores prefere evitá-la.

Um mecanismo melhor é o sistema notificar ativamente o código quando um evento ocorre. Navegadores fazem isso permitindo que registremos funções como manipulador (handlers) para eventos específicos.

<p>Clique neste documento para ativar o manipulador.</p>
<script>
  window.addEventListener("click", () => {
    console.log("Você bateu?");
  });
</script>

A ligação window se refere a um objeto embutido fornecido pelo navegador. Ele representa a janela do navegador que contém o documento. Chamar seu método addEventListener registra o segundo argumento para ser chamado sempre que o evento descrito pelo primeiro argumento ocorrer.

Eventos e nós do DOM

Cada manipulador de evento do navegador é registrado em um contexto. No exemplo anterior, chamamos addEventListener no objeto window para registrar um manipulador para toda a janela. Esse método também pode ser encontrado em elementos do DOM e em alguns outros tipos de objetos. Listeners de eventos são chamados apenas quando o evento acontece no contexto do objeto no qual foram registrados.

<button>Clique em mim</button>
<p>Nenhum manipulador aqui.</p>
<script>
  let button = document.querySelector("button");
  button.addEventListener("click", () => {
    console.log("Botão clicado.");
  });
</script>

Esse exemplo anexa um manipulador ao nó do botão. Cliques no botão fazem esse manipulador rodar, mas cliques no restante do documento não.

Dar a um nó um atributo onclick tem um efeito semelhante. Isso funciona para a maioria dos tipos de eventos—você pode anexar um manipulador por meio de um atributo cujo nome é o nome do evento com on na frente.

Mas um nó pode ter apenas um atributo onclick, então você pode registrar apenas um manipulador por nó dessa forma. O método addEventListener permite adicionar qualquer número de manipuladores, o que significa que é seguro adicionar manipuladores mesmo se já houver outro manipulador no elemento.

O método removeEventListener, chamado com argumentos semelhantes aos de addEventListener, remove um manipulador.

<button>Botão de uso único</button>
<script>
  let button = document.querySelector("button");
  function once() {
    console.log("Feito.");
    button.removeEventListener("click", once);
  }
  button.addEventListener("click", once);
</script>

A função passada para removeEventListener precisa ser o mesmo valor de função passado para addEventListener. Quando você precisa desregistrar um manipulador, vai querer dar um nome à função manipuladora (once, no exemplo) para poder passar o mesmo valor de função para ambos os métodos.

Objetos de evento

Embora tenhamos ignorado isso até agora, funções manipuladoras de eventos recebem um argumento: o objeto de evento. Esse objeto contém informações adicionais sobre o evento. Por exemplo, se quisermos saber qual botão do mouse foi pressionado, podemos olhar a propriedade button do objeto de evento.

<button>Clique em mim como quiser</button>
<script>
  let button = document.querySelector("button");
  button.addEventListener("mousedown", event => {
    if (event.button == 0) {
      console.log("Botão esquerdo");
    } else if (event.button == 1) {
      console.log("Botão do meio");
    } else if (event.button == 2) {
      console.log("Botão direito");
    }
  });
</script>

As informações armazenadas em um objeto de evento variam conforme o tipo de evento. (Discutiremos diferentes tipos mais adiante no capítulo.) A propriedade type do objeto sempre contém uma string que identifica o evento (como "click" ou "mousedown").

Propagação

Para a maioria dos tipos de eventos, manipuladores registrados em nós com filhos também receberão eventos que acontecem nos filhos. Se um botão dentro de um parágrafo é clicado, manipuladores de evento no parágrafo também verão o evento de clique.

Mas se tanto o parágrafo quanto o botão tiverem um manipulador, o manipulador mais específico—o do botão—é executado primeiro. Diz-se que o evento propaga para fora, do nó onde aconteceu para o nó pai e até a raiz do documento. Por fim, depois que todos os manipuladores registrados em um nó específico tiveram sua vez, manipuladores registrados na janela (window) inteira têm a chance de responder ao evento.

Em qualquer ponto, um manipulador de evento pode chamar o método stopPropagation no objeto de evento para impedir que manipuladores mais acima recebam o evento. Isso pode ser útil quando, por exemplo, você tem um botão dentro de outro elemento clicável e não quer que cliques no botão ativem o comportamento do elemento externo.

O exemplo a seguir registra manipuladores "mousedown" tanto em um botão quanto no parágrafo ao redor dele. Quando clicado com o botão direito do mouse, o manipulador do botão chama stopPropagation, o que impede o manipulador do parágrafo de rodar. Quando o botão é clicado com outro botão do mouse, ambos os manipuladores serão executados.

<p>Um parágrafo com um <button>botão</button>.</p>
<script>
  let para = document.querySelector("p");
  let button = document.querySelector("button");
  para.addEventListener("mousedown", () => {
    console.log("Manipulador do parágrafo.");
  });
  button.addEventListener("mousedown", event => {
    console.log("Manipulador do botão.");
    if (event.button == 2) event.stopPropagation();
  });
</script>

A maioria dos objetos de evento tem uma propriedade target que se refere ao nó onde eles se originaram. Você pode usar essa propriedade para garantir que não está tratando algo que propagou de um nó que você não quer tratar.

Também é possível usar a propriedade target para capturar um conjunto amplo de um tipo específico de evento. Por exemplo, se você tem um nó contendo uma longa lista de botões, pode ser mais conveniente registrar um único manipulador de clique no nó externo e usar a propriedade target para descobrir se um botão foi clicado, em vez de registrar manipuladores individuais em todos os botões.

<button>A</button>
<button>B</button>
<button>C</button>
<script>
  document.body.addEventListener("click", event => {
    if (event.target.nodeName == "BUTTON") {
      console.log("Clicado", event.target.textContent);
    }
  });
</script>

Ações padrão

Muitos eventos têm uma ação padrão. Se você clicar em um link, será levado ao destino do link. Se pressionar a seta para baixo, o navegador rolará a página. Se clicar com o botão direito, verá um menu de contexto. E assim por diante.

Para a maioria dos tipos de eventos, os manipuladores JavaScript são chamados antes de o comportamento padrão acontecer. Se o manipulador não quiser que esse comportamento normal aconteça, normalmente porque já tratou o evento, ele pode chamar o método preventDefault no objeto de evento.

Isso pode ser usado para implementar seus próprios atalhos de teclado ou menu de contexto. Também pode ser usado para interferir de forma irritante no comportamento esperado pelos usuários. Por exemplo, aqui está um link que não pode ser seguido:

<a href="https://developer.mozilla.org/">MDN</a>
<script>
  let link = document.querySelector("a");
  link.addEventListener("click", event => {
    console.log("Não.");
    event.preventDefault();
  });
</script>

Tente não fazer esse tipo de coisa sem um bom motivo. Será desagradável para quem usa sua página quando o comportamento esperado é quebrado.

Dependendo do navegador, alguns eventos não podem ser interceptados de forma alguma. No Chrome, por exemplo, o atalho de teclado para fechar a aba atual (ctrl-W ou command-W) não pode ser tratado com JavaScript.

Eventos de teclado

Quando uma tecla do teclado é pressionada, seu navegador dispara um evento "keydown". Quando ela é solta, você recebe um "keyup".

<p>Esta página fica violeta quando você segura a tecla V.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == "v") {
      document.body.style.background = "violet";
    }
  });
  window.addEventListener("keyup", event => {
    if (event.key == "v") {
      document.body.style.background = "";
    }
  });
</script>

Apesar do nome, "keydown" não dispara apenas quando a tecla é pressionada fisicamente. Quando uma tecla é pressionada e mantida, o evento dispara novamente cada vez que a tecla repete. Às vezes você precisa tomar cuidado com isso. Por exemplo, se você adicionar um botão ao DOM quando uma tecla é pressionada e removê-lo quando a tecla é solta, pode acabar adicionando centenas de botões se a tecla ficar pressionada por mais tempo.

O exemplo anterior observa a propriedade key do objeto de evento para ver de qual tecla se trata. Essa propriedade contém uma string que, para a maioria das teclas, corresponde ao caractere que seria digitado ao pressioná-la. Para teclas especiais como enter, ela contém uma string que nomeia a tecla ("Enter", neste caso). Se você segurar shift ao pressionar uma tecla, isso também pode influenciar o nome da tecla—"v" vira "V", e "1" pode virar "!", se isso for o que shift-1 produz no seu teclado.

Teclas modificadoras como shift, ctrl, alt e meta (command no Mac) geram eventos de tecla como teclas normais. Ao procurar combinações de teclas, você também pode verificar se essas teclas estão pressionadas observando as propriedades shiftKey, ctrlKey, altKey e metaKey de eventos de teclado e mouse.

<p>Pressione Control-Space para continuar.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == " " && event.ctrlKey) {
      console.log("Continuando!");
    }
  });
</script>

O nó do DOM onde um evento de teclado se origina depende do elemento que tem foco quando a tecla é pressionada. A maioria dos nós não pode ter foco a menos que você lhes dê um atributo tabindex, mas coisas como links, botões e campos de formulário podem. Voltaremos aos campos de formulário no Capítulo 18. Quando nada específico tem foco, document.body atua como o nó alvo dos eventos de teclado.

Quando o usuário está digitando texto, usar eventos de teclado para descobrir o que está sendo digitado é problemático. Algumas plataformas, especialmente o teclado virtual em Android telefones, não disparam eventos de teclado. Mas mesmo com um teclado tradicional, alguns tipos de entrada de texto não correspondem diretamente a pressionamentos de tecla, como softwares de input method editor (IME) usados por pessoas cujos sistemas de escrita não cabem no teclado, onde múltiplas teclas são combinadas para criar caracteres.

Para perceber quando algo foi digitado, elementos nos quais você pode digitar, como as tags <input> e <textarea>, disparam eventos "input" sempre que o usuário altera seu conteúdo. Para obter o conteúdo real digitado, é melhor lê-lo diretamente do campo com foco, como discutiremos no Capítulo 18.

Eventos de ponteiro

Atualmente existem duas formas amplamente usadas de apontar para coisas na tela: mouses (incluindo dispositivos que funcionam como mouse, como touchpads e trackballs) e telas sensíveis ao toque. Eles produzem tipos diferentes de eventos.

Cliques do mouse

Pressionar um botão do mouse faz com que vários eventos sejam disparados. Os eventos "mousedown" e "mouseup" são semelhantes a "keydown" e "keyup" e ocorrem quando o botão é pressionado e solto. Eles acontecem nos nós do DOM que estão imediatamente sob o ponteiro do mouse quando o evento ocorre.

Após o evento "mouseup", um evento "click" é disparado no nó mais específico que contém tanto o pressionamento quanto a liberação do botão. Por exemplo, se eu pressionar o botão do mouse em um parágrafo e depois mover o ponteiro para outro parágrafo e soltar o botão, o evento "click" acontecerá no elemento que contém ambos os parágrafos.

Se dois cliques acontecerem próximos um do outro, um evento "dblclick" (duplo clique) também será disparado, após o segundo clique.

Para obter informações precisas sobre o local onde um evento de mouse aconteceu, você pode olhar as propriedades clientX e clientY, que contêm as coordenadas do evento (em pixels) relativas ao canto superior esquerdo da janela, ou pageX e pageY, que são relativas ao canto superior esquerdo de todo o documento (o que pode ser diferente quando a janela foi rolada).

O programa a seguir implementa uma aplicação de desenho primitiva. Cada vez que você clica no documento, ele adiciona um ponto sob o ponteiro do mouse.

<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* arredonda os cantos */
    background: teal;
    position: absolute;
  }
</style>
<script>
  window.addEventListener("click", event => {
    let dot = document.createElement("div");
    dot.className = "dot";
    dot.style.left = (event.pageX - 4) + "px";
    dot.style.top = (event.pageY - 4) + "px";
    document.body.appendChild(dot);
  });
</script>

Criaremos uma aplicação de desenho menos primitiva no Capítulo 19.

Movimento do mouse

Sempre que o ponteiro do mouse se move, um evento "mousemove" é disparado. Esse evento pode ser usado para acompanhar a posição do mouse. Uma situação comum em que isso é útil é ao implementar alguma forma de funcionalidade de arrastar com o mouse.

Como exemplo, o programa a seguir exibe uma barra e configura manipuladores para que arrastar para a esquerda ou direita nessa barra a torne mais estreita ou mais larga:

<p>Arraste a barra para mudar sua largura:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
  let lastX; // Acompanha a última posição X do mouse observada
  let bar = document.querySelector("div");
  bar.addEventListener("mousedown", event => {
    if (event.button == 0) {
      lastX = event.clientX;
      window.addEventListener("mousemove", moved);
      event.preventDefault(); // Evita seleção
    }
  });

  function moved(event) {
    if (event.buttons == 0) {
      window.removeEventListener("mousemove", moved);
    } else {
      let dist = event.clientX - lastX;
      let newWidth = Math.max(10, bar.offsetWidth + dist);
      bar.style.width = newWidth + "px";
      lastX = event.clientX;
    }
  }
</script>

Note que o manipulador "mousemove" é registrado na janela inteira. Mesmo se o mouse sair da barra durante o redimensionamento, enquanto o botão estiver pressionado, ainda queremos atualizar seu tamanho.

Precisamos parar de redimensionar a barra quando o botão do mouse for solto. Para isso, podemos usar a propriedade buttons (note o plural), que informa sobre os botões atualmente pressionados. Quando é 0, nenhum botão está pressionado. Quando há botões pressionados, o valor da propriedade buttons é a soma dos códigos desses botões—o botão esquerdo tem código 1, o direito 2 e o do meio 4. Com os botões esquerdo e direito pressionados, por exemplo, o valor de buttons será 3.

Note que a ordem desses códigos é diferente da usada por button, onde o botão do meio vinha antes do direito. Como mencionado, consistência não é um ponto forte da interface de programação do navegador.

Eventos de toque

O estilo de navegador gráfico que usamos foi projetado pensando em interfaces com mouse, numa época em que telas sensíveis ao toque eram raras. Para fazer a web “funcionar” nos primeiros celulares com touchscreen, os navegadores desses dispositivos fingiam, até certo ponto, que eventos de toque eram eventos de mouse. Se você tocar na tela, receberá eventos "mousedown", "mouseup" e "click".

Mas essa ilusão não é muito robusta. Uma tela sensível ao toque não funciona como um mouse: não tem vários botões, você não pode acompanhar o dedo quando ele não está na tela (para simular "mousemove"), e permite múltiplos dedos ao mesmo tempo.

Eventos de mouse cobrem interação por toque apenas em casos simples—se você adicionar um manipulador "click" a um botão, usuários de toque ainda conseguirão usá-lo. Mas algo como a barra redimensionável do exemplo anterior não funciona em uma tela sensível ao toque.

Existem tipos de eventos específicos disparados por interação de toque. Quando um dedo começa a tocar a tela, você recebe um evento "touchstart". Quando ele se move enquanto toca, eventos "touchmove" são disparados. Por fim, quando ele deixa de tocar a tela, você verá um evento "touchend".

Como muitas telas sensíveis ao toque podem detectar vários dedos ao mesmo tempo, esses eventos não têm um único conjunto de coordenadas associado. Em vez disso, seus objetos de evento têm uma propriedade touches, que contém um objeto semelhante a array de pontos, cada um com suas próprias propriedades clientX, clientY, pageX e pageY.

Você pode fazer algo como isto para mostrar círculos vermelhos ao redor de cada dedo tocando:

<style>
  dot { position: absolute; display: block;
        border: 2px solid red; border-radius: 50px;
        height: 100px; width: 100px; }
</style>
<p>Toque nesta página</p>
<script>
  function update(event) {
    for (let dot; dot = document.querySelector("dot");) {
      dot.remove();
    }
    for (let i = 0; i < event.touches.length; i++) {
      let {pageX, pageY} = event.touches[i];
      let dot = document.createElement("dot");
      dot.style.left = (pageX - 50) + "px";
      dot.style.top = (pageY - 50) + "px";
      document.body.appendChild(dot);
    }
  }
  window.addEventListener("touchstart", update);
  window.addEventListener("touchmove", update);
  window.addEventListener("touchend", update);
</script>

Frequentemente você vai querer chamar preventDefault em manipuladores de eventos de toque para sobrescrever o comportamento padrão do navegador (que pode incluir rolar a página ao deslizar) e evitar que eventos de mouse sejam disparados, para os quais você pode também ter um manipulador.

Eventos de rolagem

Sempre que um elemento é rolado, um evento "scroll" é disparado nele. Isso tem vários usos, como saber o que o usuário está vendo no momento (para desativar animaçãoes fora da tela ou enviar relatórios de espionagem para sua sede maligna) ou mostrar algum indicador de progresso (destacando parte de um índice ou mostrando um número de página).

O exemplo a seguir desenha uma barra de progresso acima do documento e a atualiza para preencher conforme você rola para baixo:

<style>
  #progress {
    border-bottom: 2px solid blue;
    width: 0;
    position: fixed;
    top: 0; left: 0;
  }
</style>
<div id="progress"></div>
<script>
  // Cria algum conteúdo
  document.body.appendChild(document.createTextNode(
    "supercalifragilisticexpialidocious ".repeat(1000)));

  let bar = document.querySelector("#progress");
  window.addEventListener("scroll", () => {
    let max = document.body.scrollHeight - innerHeight;
    bar.style.width = `${(pageYOffset / max) * 100}%`;
  });
</script>

Dar a um elemento um position de fixed funciona de forma semelhante a absolute, mas também impede que ele role junto com o restante do documento. O efeito é manter nossa barra de progresso no topo. Sua largura é alterada para indicar o progresso atual. Usamos %, em vez de px, como unidade ao definir a largura, para que o elemento seja dimensionado em relação à largura da página.

A ligação global innerHeight nos dá a altura da janela, que devemos subtrair da altura total rolável—você não pode continuar rolando quando chega ao fim do documento. Também existe innerWidth para a largura da janela. Dividindo pageYOffset, a posição atual de rolagem, pela posição máxima de rolagem e multiplicando por 100, obtemos a porcentagem para a barra de progresso.

Chamar preventDefault em um evento de rolagem não impede que a rolagem aconteça. Na verdade, o manipulador é chamado apenas depois que a rolagem ocorre.

Eventos de foco

Quando um elemento ganha foco, o navegador dispara um evento "focus" nele. Quando ele perde o foco, o elemento recebe um evento "blur".

Diferentemente dos eventos discutidos anteriormente, esses dois não propagam. Um manipulador em um elemento pai não é notificado quando um elemento filho ganha ou perde foco.

O exemplo a seguir exibe um texto de ajuda para o campo de texto que atualmente tem foco:

<p>Nome: <input type="text" data-help="Seu nome completo"></p>
<p>Idade: <input type="text" data-help="Sua idade em anos"></p>
<p id="help"></p>

<script>
  let help = document.querySelector("#help");
  let fields = document.querySelectorAll("input");
  for (let field of Array.from(fields)) {
    field.addEventListener("focus", event => {
      let text = event.target.getAttribute("data-help");
      help.textContent = text;
    });
    field.addEventListener("blur", event => {
      help.textContent = "";
    });
  }
</script>

O objeto `window` receberá eventos "focus" e "blur" quando o usuário mudar para ou a partir da aba ou janela do navegador em que o documento está exibido.

Evento de carregamento

Quando uma página termina de carregar, o evento "load" é disparado no objeto window e no corpo do documento. Isso é frequentemente usado para agendar ações de inicialização que exigem que todo o documento tenha sido construído. Lembre-se de que o conteúdo das tags <script> é executado imediatamente quando a tag é encontrada. Isso pode ser cedo demais, por exemplo, quando o script precisa fazer algo com partes do documento que aparecem depois da tag <script>.

Elementos como imagems e tags de script que carregam um arquivo externo também têm um evento "load" que indica que os arquivos referenciados foram carregados. Assim como os eventos relacionados a foco, eventos de carregamento não propagam.

Quando você fecha a página ou navega para fora dela (por exemplo, seguindo um link), um evento "beforeunload" é disparado. O principal uso desse evento é evitar que o usuário perca trabalho acidentalmente ao fechar um documento. Se você impedir o comportamento padrão desse evento e definir a propriedade returnValue do objeto de evento como uma string, o navegador mostrará um diálogo perguntando se o usuário realmente quer sair da página. Esse diálogo pode incluir sua string, mas como alguns sites maliciosos tentam usar esses diálogos para confundir pessoas e fazê-las permanecer na página para ver anúncios suspeitos, a maioria dos navegadores não exibe mais essa mensagem.

Eventos e o loop de eventos

No contexto do loop de eventos, como discutido no Capítulo 11, manipuladores de eventos do navegador se comportam como outras notificações assíncronas. Eles são agendados quando o evento ocorre, mas precisam esperar que outros scripts em execução terminem antes de terem a chance de rodar.

O fato de que eventos só podem ser processados quando nada mais está em execução significa que, se o loop de eventos estiver ocupado com outro trabalho, qualquer interação com a página (que acontece por meio de eventos) será atrasada até que haja tempo para processá-la. Portanto, se você agendar trabalho demais, seja com manipuladores longos ou muitos manipuladores curtos, a página ficará lenta e difícil de usar.

Para casos em que você realmente quer fazer algo demorado em segundo plano sem travar a página, os navegadores fornecem algo chamado web workers. Um worker é um processo JavaScript que roda ao lado do script principal, em sua própria linha do tempo.

Imagine que elevar um número ao quadrado é uma computação pesada e demorada que queremos realizar em uma thread separada. Poderíamos escrever um arquivo chamado code/squareworker.js que responde a mensagens calculando um quadrado e enviando uma resposta de volta.

addEventListener("message", event => {
  postMessage(event.data * event.data);
});

Para evitar problemas de múltiplas threads acessando os mesmos dados, workers não compartilham seu escopo global nem quaisquer outros dados com o ambiente do script principal. Em vez disso, você precisa se comunicar com eles enviando mensagens de ida e volta.

Este código cria um worker executando aquele script, envia algumas mensagens e mostra as respostas.

let squareWorker = new Worker("code/squareworker.js");
squareWorker.addEventListener("message", event => {
  console.log("O worker respondeu:", event.data);
});
squareWorker.postMessage(10);
squareWorker.postMessage(24);

A função postMessage envia uma mensagem, o que fará com que um evento "message" seja disparado no receptor. O script que criou o worker envia e recebe mensagens por meio do objeto Worker, enquanto o worker conversa com o script que o criou enviando e ouvindo diretamente em seu escopo global. Apenas valores que podem ser representados como JSON podem ser enviados como mensagens—o outro lado receberá uma cópia deles, em vez do valor original.

Temporizadores

A função setTimeout que vimos no Capítulo 11 agenda outra função para ser chamada mais tarde, após um determinado número de milissegundos. Às vezes você precisa cancelar uma função que agendou. Pode fazer isso armazenando o valor retornado por setTimeout e chamando clearTimeout com ele.

let bombTimer = setTimeout(() => {
  console.log("BOOM!");
}, 500);

if (Math.random() < 0.5) { // 50% de chance
  console.log("Desarmado.");
  clearTimeout(bombTimer);
}

A função cancelAnimationFrame funciona da mesma forma que clearTimeout. Chamá-la com um valor retornado por requestAnimationFrame cancelará aquele frame (desde que ainda não tenha sido chamado).

Um conjunto semelhante de funções, setInterval e clearInterval, é usado para definir temporizadores que devem se repetir a cada X milissegundos.

let ticks = 0;
let clock = setInterval(() => {
  console.log("tick", ticks++);
  if (ticks == 10) {
    clearInterval(clock);
    console.log("parar.");
  }
}, 200);

Debounce

Alguns tipos de eventos têm potencial de disparar rapidamente muitas vezes seguidas, como "mousemove" e "scroll". Ao lidar com esses eventos, você deve tomar cuidado para não fazer nada muito demorado ou seu manipulador consumirá tanto tempo que a interação com o documento ficará lenta.

Se você realmente precisar fazer algo não trivial em um manipulador desses, pode usar setTimeout para garantir que não o fará com muita frequência. Isso geralmente é chamado de debouncing do evento. Existem várias abordagens ligeiramente diferentes para isso.

Por exemplo, suponha que queremos reagir quando o usuário digitou algo, mas não queremos fazer isso imediatamente para cada evento de input. Quando ele está digitando rapidamente, queremos apenas esperar até que haja uma pausa. Em vez de executar uma ação imediatamente no manipulador, definimos um timeout. Também limpamos o timeout anterior (se houver) para que, quando eventos ocorram próximos entre si (mais próximos do que nosso atraso), o timeout do evento anterior seja cancelado.

<textarea>Digite algo aqui...</textarea>
<script>
  let textarea = document.querySelector("textarea");
  let timeout;
  textarea.addEventListener("input", () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => console.log("Digitado!"), 500);
  });
</script>

Passar um valor indefinido para clearTimeout ou chamá-lo em um timeout que já disparou não tem efeito. Assim, não precisamos ser cuidadosos sobre quando chamá-lo, e simplesmente o fazemos para cada evento.

Podemos usar um padrão ligeiramente diferente se quisermos espaçar as respostas para que fiquem separadas por pelo menos um certo intervalo de tempo, mas ainda dispará-las durante uma sequência de eventos, não apenas depois. Por exemplo, podemos querer responder a eventos "mousemove" mostrando as coordenadas atuais do mouse, mas apenas a cada 250 milissegundos.

<script>
  let scheduled = null;
  window.addEventListener("mousemove", event => {
    if (!scheduled) {
      setTimeout(() => {
        document.body.textContent =
          `Mouse em ${scheduled.pageX}, ${scheduled.pageY}`;
        scheduled = null;
      }, 250);
    }
    scheduled = event;
  });
</script>

Resumo

Manipuladores de eventos tornam possível detectar e reagir a eventos que acontecem em nossa página web. O método addEventListener é usado para registrar esses manipuladores.

Cada evento tem um tipo ("keydown", "focus" e assim por diante) que o identifica. A maioria dos eventos é chamada em um elemento específico do DOM e depois se propaga para os ancestrais desse elemento, permitindo que manipuladores associados a esses elementos também os tratem.

Quando um manipulador de evento é chamado, ele recebe um objeto de evento com informações adicionais sobre o evento. Esse objeto também tem métodos que nos permitem interromper a propagação (stopPropagation) e impedir o tratamento padrão do navegador (preventDefault).

Pressionar uma tecla dispara eventos "keydown" e "keyup". Pressionar um botão do mouse dispara "mousedown", "mouseup" e "click". Mover o mouse dispara "mousemove". Interações em telas sensíveis ao toque resultam em "touchstart", "touchmove" e "touchend".

A rolagem pode ser detectada com o evento "scroll", e mudanças de foco com "focus" e "blur". Quando o documento termina de carregar, um evento "load" é disparado na janela (window).

Exercícios

Balão

Escreva uma página que exiba um balão (usando o emoji de balão, 🎈). Quando você pressionar a seta para cima, ele deve inflar (crescer) 10%. Quando pressionar a seta para baixo, deve esvaziar (encolher) 10%.

Você pode controlar o tamanho do texto (emojis são texto) definindo a propriedade CSS font-size (style.fontSize) no elemento pai. Lembre-se de incluir uma unidade no valor—por exemplo, pixels (10px).

Os nomes das teclas de seta são "ArrowUp" e "ArrowDown". Certifique-se de que as teclas afetem apenas o balão, sem rolar a página.

Depois que isso funcionar, adicione um recurso em que, se você aumentar o balão além de um certo tamanho, ele “explode”. Nesse caso, explodir significa que ele é substituído por um emoji 💥, e o manipulador de evento é removido (para que você não possa mais inflar ou esvaziar a explosão).

<p>🎈</p>

<script>
  // Seu código aqui
</script>
Mostrar dicas...

Você vai querer registrar um manipulador para o evento "keydown" e olhar event.key para descobrir se a seta para cima ou para baixo foi pressionada.

O tamanho atual pode ser mantido em uma ligação para que você possa basear o novo tamanho nele. Será útil definir uma função que atualize o tamanho—tanto a ligação quanto o estilo do balão no DOM—para que você possa chamá-la no manipulador e possivelmente também uma vez no início, para definir o tamanho inicial.

Você pode transformar o balão em uma explosão substituindo o nó de texto por outro (usando replaceChild) ou definindo a propriedade textContent do nó pai para uma nova string.

Rastro do mouse

Nos primeiros dias do JavaScript, que foram o auge das páginas iniciais chamativas com muitas imagens animadas, as pessoas criaram formas realmente criativas de usar a linguagem. Uma delas era o rastro do mouse—uma série de elementos que seguiam o ponteiro conforme você o movia pela página.

Neste exercício, quero que você implemente um rastro de mouse. Use elementos <div> posicionados absolutamente, com tamanho fixo e cor de fundo (consulte o código na seção “Cliques do mouse” para um exemplo). Crie vários desses elementos e, quando o mouse se mover, exiba-os no rastro do ponteiro.

Há várias abordagens possíveis aqui. Você pode tornar seu rastro tão simples ou complexo quanto quiser. Uma solução simples para começar é manter um número fixo de elementos de rastro e alternar entre eles, movendo o próximo para a posição atual do mouse toda vez que um evento "mousemove" ocorrer.

<style>
  .trail { /* className para os elementos do rastro */
    position: absolute;
    height: 6px; width: 6px;
    border-radius: 3px;
    background: teal;
  }
  body {
    height: 300px;
  }
</style>

<script>
  // Seu código aqui.
</script>
Mostrar dicas...

Criar os elementos é melhor feito com um loop. Anexe-os ao documento para que apareçam. Para poder acessá-los depois e mudar sua posição, você vai querer armazená-los em um array.

Alternar entre eles pode ser feito mantendo uma variável contadora e adicionando 1 a ela cada vez que o evento "mousemove" disparar. O operador de resto (% elements.length) pode então ser usado para obter um índice válido do array para escolher o elemento que você quer posicionar em um determinado evento.

Outro efeito interessante pode ser obtido modelando um sistema simples de física. Use o evento "mousemove" apenas para atualizar um par de ligações que acompanham a posição do mouse. Em seguida, use requestAnimationFrame para simular os elementos do rastro sendo atraídos para a posição do ponteiro. A cada passo da animação, atualize a posição deles com base na posição relativa ao ponteiro (e, opcionalmente, uma velocidade armazenada para cada elemento). Encontrar uma boa forma de fazer isso fica a seu critério.

Abas

Painéis com abas são comuns em interfaces de usuário. Eles permitem selecionar um painel escolhendo entre várias abas “sobressaindo” acima de um elemento.

Implemente uma interface simples com abas. Escreva uma função asTabs que recebe um nó do DOM e cria uma interface com abas mostrando os elementos filhos desse nó. Ela deve inserir uma lista de elementos <button> no topo do nó, um para cada elemento filho, contendo texto obtido do atributo data-tabname do filho. Todos os filhos originais, exceto um, devem ser ocultados (com estilo display igual a none). O nó visível atualmente pode ser selecionado clicando nos botões.

Quando isso funcionar, estenda para estilizar o botão da aba atualmente selecionada de forma diferente, para que fique claro qual aba está ativa.

<tab-panel>
  <div data-tabname="one">Aba um</div>
  <div data-tabname="two">Aba dois</div>
  <div data-tabname="three">Aba três</div>
</tab-panel>
<script>
  function asTabs(node) {
    // Seu código aqui.
  }
  asTabs(document.querySelector("tab-panel"));
</script>
Mostrar dicas...

Um problema que você pode encontrar é que não pode usar diretamente a propriedade childNodes do nó como coleção de abas. Por um lado, quando você adiciona os botões, eles também se tornam nós filhos e acabam nesse objeto porque ele é uma estrutura de dados dinâmica. Por outro, os nós de texto criados para os espaços em branco entre os elementos também estão em childNodes, mas não devem virar abas. Você pode usar children em vez de childNodes para ignorar nós de texto.

Você pode começar montando um array de abas para ter acesso fácil a elas. Para implementar a estilização dos botões, você pode armazenar objetos que contenham tanto o painel quanto seu botão.

Recomendo escrever uma função separada para trocar de abas. Você pode armazenar a aba previamente selecionada e alterar apenas os estilos necessários para ocultá-la e mostrar a nova, ou simplesmente atualizar o estilo de todas as abas toda vez que uma nova for selecionada.

Talvez você queira chamar essa função imediatamente para que a interface comece com a primeira aba visível.