イベントの処理

汝の心は汝の支配下にある ― 外界の出来事ではない。このことを理解すれば、汝は強さを見出すだろう。

マルクス・アウレリウス、自省録
Illustration showing a Rube Goldberg machine involving a ball, a see-saw, a pair of scissors, and a hammer, which affect each other in a chain reaction that turns on a lightbulb.

プログラムの中には、マウスやキーボード操作といった、ユーザーからの直接の入力を使って動作するものがあります。このような入力は、事前に整理されたデータ構造として利用できるわけではなく、リアルタイムで断片的に送られてくるため、プログラムはそれが発生したときに応答しなければなりません。

イベントハンドラ

キーボードのキーが押されているかどうかを知る唯一の方法が、そのキーの現在の状態を読み取ることしかないインターフェースを想像してみてください。キー押下に対応するためには、キーが再び離される前にそれを捕捉するために、キーの状態を常に読み取らなければなりません。キー押下を見逃してしまう可能性があるため、他の時間のかかる計算を実行することは危険です。

一部の原始的なマシンは、このように入力を処理します。これより一歩進んだ方法として、ハードウェアまたはオペレーティングシステムがキー押下を検知し、それをキューに格納する方法があります。プログラムは、定期的にキューに新しいイベントがないかを確認し、そこで見つかったイベントに対応することができます。

もちろん、プログラムはキューを確認することを覚えておかなければならず、しかも頻繁に確認する必要があります。キーが押されてからプログラムがイベントに気付くまでの時間によって、ソフトウェアの応答性が低下してしまうからです。このアプローチはポーリングと呼ばれます。ほとんどのプログラマーはこれを避けることを好みます。

より良いメカニズムは、イベントが発生したときにシステムがコードにアクティブに通知することです。ブラウザは、特定のイベントのハンドラとして関数を登録できるようにすることで、これを実現しています。

<p>Click this document to activate the handler.</p>
<script>
  window.addEventListener("click", () => {
    console.log("You knocked?");
  });
</script>

window バインディングは、ブラウザによって提供される組み込みオブジェクトを参照します。これは、ドキュメントを含むブラウザウィンドウを表します。その addEventListener メソッドを呼び出すと、最初の引数で記述されたイベントが発生するたびに、2 番目の引数が呼び出されるように登録されます。

イベントと DOM ノード

ブラウザの各イベントハンドラは、コンテキスト内で登録されます。前の例では、ウィンドウ全体にハンドラを登録するために、window オブジェクトに対して addEventListener を呼び出しました。このようなメソッドは、DOM 要素や他のタイプのオブジェクトにも存在します。イベントリスナーは、イベントが登録されているオブジェクトのコンテキスト内で発生した場合にのみ呼び出されます。

<button>Click me</button>
<p>No handler here.</p>
<script>
  let button = document.querySelector("button");
  button.addEventListener("click", () => {
    console.log("Button clicked.");
  });
</script>

この例では、ボタンノードにハンドラをアタッチしています。ボタンをクリックするとそのハンドラが実行されますが、ドキュメントの残りの部分をクリックしても実行されません。

ノードに onclick 属性を指定すると、同様の効果が得られます。これはほとんどの種類のイベントで機能します。on を前に付けたイベント名と同じ名前の属性を通じてハンドラをアタッチできます。

しかし、ノードは onclick 属性を 1 つしか持つことができないため、この方法ではノードごとに 1 つのハンドラしか登録できません。addEventListener メソッドを使用すると、任意の数のハンドラを追加できるため、要素に既に別のハンドラが存在する場合でも、安全にハンドラを追加できます。

addEventListener と同様の引数で呼び出される removeEventListener メソッドは、ハンドラを削除します。

<button>Act-once button</button>
<script>
  let button = document.querySelector("button");
  function once() {
    console.log("Done.");
    button.removeEventListener("click", once);
  }
  button.addEventListener("click", once);
</script>

removeEventListener に渡される関数は、addEventListener に渡される関数と同じ関数値でなければなりません。ハンドラの登録を解除する必要がある場合は、両方のメソッドに同じ関数値を渡せるように、ハンドラ関数に名前 (例では once) を付ける必要があります。

イベントオブジェクト

これまで無視してきましたが、イベントハンドラ関数には引数が渡されます。それがイベントオブジェクトです。このオブジェクトは、イベントに関する追加情報を保持します。たとえば、どのマウスボタンが押されたかを知りたい場合は、イベントオブジェクトの button プロパティを確認できます。

<button>Click me any way you want</button>
<script>
  let button = document.querySelector("button");
  button.addEventListener("mousedown", event => {
    if (event.button == 0) {
      console.log("Left button");
    } else if (event.button == 1) {
      console.log("Middle button");
    } else if (event.button == 2) {
      console.log("Right button");
    }
  });
</script>

イベントオブジェクトに格納される情報は、イベントの種類によって異なります。(この章の後半で、さまざまな種類について説明します。) オブジェクトの type プロパティは、常にイベントを識別する文字列 ("click""mousedown" など) を保持します。

伝播

ほとんどのイベントタイプでは、子を持つノードに登録されたハンドラは、子で発生したイベントも受信します。段落内のボタンがクリックされると、段落のイベントハンドラもクリックイベントを認識します。

しかし、段落 と ボタン の両方にハンドラがある場合、より具体的なハンドラ、つまりボタンのハンドラが最初に実行されます。イベントは、発生したノードからそのノードの親ノードへ、そしてドキュメントのルートへと外側に伝播すると言われています。最終的に、特定のノードに登録されたすべてのハンドラが順番に実行された後、ウィンドウ全体に登録されたハンドラがイベントに応答する機会を得ます。

イベントハンドラは、イベントオブジェクトの stopPropagation メソッドを呼び出すことで、上位のハンドラがイベントを受信するのを防ぐことができます。これは、たとえば、クリック可能な要素の中にボタンがあり、ボタンのクリックによって外側の要素のクリック動作をアクティブにしたくない場合に役立ちます。

次の例では、ボタンとそれを囲む段落の両方に "mousedown" ハンドラを登録しています。マウスの右ボタンでクリックすると、ボタンのハンドラが stopPropagation を呼び出し、段落のハンドラが実行されないようにします。ボタンを別のマウスボタンでクリックすると、両方のハンドラが実行されます。

<p>A paragraph with a <button>button</button>.</p>
<script>
  let para = document.querySelector("p");
  let button = document.querySelector("button");
  para.addEventListener("mousedown", () => {
    console.log("Handler for paragraph.");
  });
  button.addEventListener("mousedown", event => {
    console.log("Handler for button.");
    if (event.button == 2) event.stopPropagation();
  });
</script>

ほとんどのイベントオブジェクトには、イベントが発生したノードを参照する target プロパティがあります。このプロパティを使用して、処理したくないノードから伝播してきたイベントを誤って処理していないことを確認できます。

また、target プロパティを使用して、特定の種類のイベントを広く捕捉することもできます。たとえば、多数のボタンを含むノードがある場合、すべてのボタンに個別にハンドラを登録するよりも、外側のノードに 1 つのクリックハンドラを登録し、target プロパティを使用してボタンがクリックされたかどうかを判断する方が便利な場合があります。

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

デフォルトの動作

多くのイベントには、デフォルトの動作があります。リンクをクリックすると、リンクのターゲットに移動します。下矢印キーを押すと、ブラウザはページを下にスクロールします。右クリックすると、コンテキストメニューが表示されます。などなど。

ほとんどの種類のイベントでは、JavaScript のイベントハンドラはデフォルトの動作が実行されるに呼び出されます。ハンドラが既にイベントの処理を完了しているため、この通常の動作が発生しないようにしたい場合は、イベントオブジェクトの preventDefault メソッドを呼び出すことができます。

これは、独自のキーボードショートカットやコンテキストメニューを実装するために使用できます。また、ユーザーが期待する動作に迷惑な干渉をするためにも使用できます。たとえば、次にクリックできないリンクを示します。

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

本当に正当な理由がない限り、このようなことはしないでください。期待される動作が壊れると、ページを使用する人々にとって不快なものになります。

ブラウザによっては、イベントをまったくインターセプトできない場合があります。たとえば、Chrome では、現在のタブを閉じるためのキーボードショートカット (Ctrl-W または Command-W) は JavaScript で処理できません。

キーイベント

キーボードのキーが押されると、ブラウザは "keydown" イベントを発生させます。キーが離されると、"keyup" イベントが発生します。

<p>This page turns violet when you hold the V key.</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>

その名前にもかかわらず、"keydown" はキーが物理的に押されたときだけでなく、キーが押されて保持されているときにも、キーがリピートされるたびにイベントが再び発生します。場合によっては、これに注意する必要があります。たとえば、キーが押されたときにボタンを DOM に追加し、キーが離されたときに再び削除する場合、キーが長く押されたときに誤って数百個のボタンを追加してしまう可能性があります。

前の例では、イベントオブジェクトのkeyプロパティを調べて、イベントがどのキーに関するものかを確認しました。このプロパティには、ほとんどのキーで、そのキーを押すと入力されるものに対応する文字列が格納されます。Enterなどの特殊キーの場合、キーの名前を表す文字列(この場合は"Enter")が格納されます。キーを押しながらShiftキーを押すと、キーの名前にも影響する場合があります。"v""V"になり、キーボードでShift-1を押すと"!"になる場合があります。

ShiftCtrlAltMeta(MacではCommand)などの修飾キーは、通常のキーと同様にキーイベントを生成します。キーの組み合わせを探す場合、キーボードイベントとマウスイベントのshiftKeyctrlKeyaltKeymetaKeyプロパティを調べることで、これらのキーが押されているかどうかを確認することもできます。

<p>Press Control-Space to continue.</p>
<script>
  window.addEventListener("keydown", event => {
    if (event.key == " " && event.ctrlKey) {
      console.log("Continuing!");
    }
  });
</script>

キーイベントが発生するDOMノードは、キーが押されたときにフォーカスを持っている要素によって異なります。ほとんどのノードは、tabindex属性を指定しない限りフォーカスを持つことができませんが、リンク、ボタン、フォームフィールドなどはフォーカスを持つことができます。フォームフィールドについては第18章で改めて説明します。特に何もフォーカスがない場合、document.bodyがキーイベントのターゲットノードとして機能します。

ユーザーがテキストを入力しているときに、キーイベントを使用して入力内容を把握するのは問題があります。Androidフォンの仮想キーボードなど、一部のプラットフォームではキーイベントが発生しません。また、昔ながらのキーボードを使用している場合でも、キーボードに収まらない文字を使用する人が使用する_入力メソッドエディタ_(_IME_)ソフトウェアなど、複数のキーストロークを組み合わせて文字を作成するなど、一部のテキスト入力はキー押下と直接一致しない場合があります。

何かが入力されたことを認識するために、<input>タグや<textarea>タグなど、入力可能な要素は、ユーザーが内容を変更するたびに"input"イベントを発生させます。実際に入力された内容を取得するには、フォーカスされているフィールドから直接読み取るのが最善です。これについては、第18章で説明します。

ポインターイベント

現在、画面上のものを指すために広く使用されている方法は2つあります。マウス(タッチパッドやトラックボールなど、マウスのように動作するデバイスを含む)とタッチスクリーンです。これらは異なる種類のイベントを生成します。

マウスクリック

マウスボタンを押すと、いくつかのイベントが発生します。"mousedown"イベントと"mouseup"イベントは、"keydown"イベントと"keyup"イベントに似ており、ボタンが押されたときと離されたときに発生します。これらは、イベントが発生したときにマウスポインターの直下にあるDOMノードで発生します。

"mouseup"イベントの後、ボタンの押下と解放の両方を含んでいた最も具体的なノードで"click"イベントが発生します。たとえば、ある段落でマウスボタンを押してから、ポインターを別の段落に移動してボタンを離すと、"click"イベントは両方の段落を含む要素で発生します。

2回のクリックが短時間で行われた場合、2回目のクリックイベントの後で"dblclick"(ダブルクリック)イベントも発生します。

マウスイベントが発生した場所の正確な情報を取得するには、ウィンドウの左上隅を基準としたイベントの座標(ピクセル単位)を含むclientXプロパティとclientYプロパティ、またはドキュメント全体の左上隅を基準としたpageXプロパティとpageYプロパティ(ウィンドウがスクロールされている場合は異なる場合があります)を確認します。

次のプログラムは、プリミティブな描画アプリケーションを実装しています。ドキュメントをクリックするたびに、マウスポインターの下にドットが追加されます。

<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* rounds corners */
    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>

より高度な描画アプリケーションは第19章で作成します。

マウスの動き

マウスポインターが移動するたびに、"mousemove"イベントが発生します。このイベントを使用して、マウスの位置を追跡できます。これが役立つ一般的な状況は、ある種のドラッグ機能を実装する場合です。

例として、次のプログラムはバーを表示し、このバーを左右にドラッグすると幅が狭くなったり広くなったりするようにイベントハンドラーを設定します。

<p>Drag the bar to change its width:</p>
<div style="background: orange; width: 60px; height: 20px">
</div>
<script>
  let lastX; // Tracks the last observed mouse X position
  let bar = document.querySelector("div");
  bar.addEventListener("mousedown", event => {
    if (event.button == 0) {
      lastX = event.clientX;
      window.addEventListener("mousemove", moved);
      event.preventDefault(); // Prevent selection
    }
  });

  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>

"mousemove"ハンドラーはウィンドウ全体に登録されていることに注意してください。サイズ変更中にマウスがバーの外に出ても、ボタンが押されている限り、サイズを更新する必要があります。

マウスボタンが離されたら、バーのサイズ変更を停止する必要があります。そのためには、現在押されているボタンに関する情報を提供するbuttonsプロパティ(複数形に注意)を使用できます。0の場合は、ボタンは押されていません。ボタンが押されている場合、buttonsプロパティの値は、それらのボタンのコードの合計です。左ボタンのコードは1、右ボタンは2、中央ボタンは4です。たとえば、左右のボタンが押されている場合、buttonsの値は3になります。

これらのコードの順序は、中央ボタンが右ボタンの前に来たbuttonで使用されている順序とは異なることに注意してください。前述のように、一貫性はブラウザのプログラミングインターフェースの強みではありません。

タッチイベント

私たちが使用しているグラフィカルブラウザのスタイルは、タッチスクリーンが珍しかった時代に、マウスインターフェースを念頭に置いて設計されました。初期のタッチスクリーンフォンでWebを「動作」させるために、これらのデバイスのブラウザは、ある程度まで、タッチイベントがマウスイベントであるかのように見せかけました。画面をタップすると、"mousedown""mouseup""click"イベントが発生します。

しかし、この錯覚はそれほど堅牢ではありません。タッチスクリーンはマウスのようには機能しません。複数のボタンがなく、画面に指がないときは指を追跡できず("mousemove"をシミュレートするため)、複数の指を同時に画面に置くことができます。

マウスイベントは、単純な場合にのみタッチインタラクションをカバーします。ボタンに"click"ハンドラーを追加すると、タッチユーザーは引き続き使用できます。しかし、前の例のサイズ変更可能なバーのようなものは、タッチスクリーンでは機能しません。

タッチインタラクションによって発生する特定のイベントタイプがあります。指が画面に触れ始めると、"touchstart"イベントが発生します。タッチ中に移動すると、"touchmove"イベントが発生します。最後に、画面から指が離れると、"touchend"イベントが発生します。

多くのタッチスクリーンは複数の指を同時に検出できるため、これらのイベントには1組の座標が関連付けられていません。代わりに、イベントオブジェクトにはtouchesプロパティがあり、これはポイントの配列のようなオブジェクトを保持します。各ポイントには、独自のclientXclientYpageXpageYプロパティがあります。

次のようにして、すべてのタッチしている指の周りに赤い円を表示できます。

<style>
  dot { position: absolute; display: block;
        border: 2px solid red; border-radius: 50px;
        height: 100px; width: 100px; }
</style>
<p>Touch this page</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>

タッチイベントハンドラーでpreventDefaultを呼び出して、ブラウザのデフォルトの動作(スワイプ時のページのスクロールなど)をオーバーライドし、マウスイベントが発生しないようにすることがよくあります。マウスイベントにもハンドラーを設定している場合があります。

スクロールイベント

要素がスクロールされるたびに、その要素で"scroll"イベントが発生します。これは、ユーザーが現在何を見ているかを知ること(画面外のアニメーションを無効にしたり、スパイレポートを悪の司令部に送信したりするため)、または進行状況の兆候を示すこと(目次の一部を強調表示したり、ページ番号を表示したりする)など、さまざまな用途に使用されます。

次の例では、ドキュメントの上にプログレスバーを描画し、下にスクロールすると埋められるように更新します。

<style>
  #progress {
    border-bottom: 2px solid blue;
    width: 0;
    position: fixed;
    top: 0; left: 0;
  }
</style>
<div id="progress"></div>
<script>
  // Create some content
  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>

要素にpositionfixedに設定すると、absoluteと同様に動作しますが、ドキュメントの他の部分と一緒にスクロールしないようになります。この効果を利用して、プログレスバーを常に上部に表示します。現在の進捗状況を示すために、幅が変更されます。要素のサイズをページ幅に対して相対的に設定するために、幅を設定する際には単位としてpxではなく%を使用します。

グローバルなinnerHeightバインディングはウィンドウの高さを提供します。ドキュメントの最下部に到達するとスクロールできなくなるため、スクロール可能な高さ全体からこの高さを差し引く必要があります。ウィンドウ幅にはinnerWidthも存在します。現在のスクロール位置であるpageYOffsetを最大スクロール位置で割り、100を掛けると、プログレスバーのパーセンテージが得られます。

スクロールイベントでpreventDefaultを呼び出しても、スクロールの発生を防ぐことはできません。実際、イベントハンドラはスクロールが発生した後にのみ呼び出されます。

フォーカスイベント

要素がフォーカスを取得すると、ブラウザはその要素に対して"focus"イベントを発生させます。フォーカスを失うと、要素は"blur"イベントを取得します。

前述のイベントとは異なり、これら2つのイベントは伝播しません。子要素がフォーカスを取得または喪失しても、親要素のハンドラには通知されません。

次の例は、現在フォーカスを持っているテキストフィールドのヘルプテキストを表示します。

<p>Name: <input type="text" data-help="Your full name"></p>
<p>Age: <input type="text" data-help="Your age in years"></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>

ドキュメントが表示されているブラウザタブまたはウィンドウに対してユーザーが移動すると、ウィンドウオブジェクトは"focus"および"blur"イベントを受け取ります。

ロードイベント

ページの読み込みが完了すると、ウィンドウとドキュメントボディオブジェクトに対して"load"イベントが発生します。これは、ドキュメント全体が構築される必要がある初期化アクションをスケジュールするためによく使用されます。<script>タグの内容は、タグが検出されたときにすぐに実行されることに注意してください。これは、たとえば、スクリプトが<script>タグの後に表示されるドキュメントの一部を操作する必要がある場合など、早すぎる可能性があります。

外部ファイルを読み込む画像やスクリプトタグなどの要素にも、参照しているファイルが読み込まれたことを示す"load"イベントがあります。フォーカス関連イベントと同様に、ロードイベントは伝播しません。

ページを閉じたり、(たとえば、リンクをたどることで)ページから移動したりすると、"beforeunload"イベントが発生します。このイベントの主な用途は、ユーザーがドキュメントを閉じて誤って作業内容を失うことを防ぐことです。このイベントのデフォルトの動作を回避し、かつイベントオブジェクトのreturnValueプロパティを文字列に設定すると、ブラウザはユーザーにページを本当に離れたいかどうかを確認するダイアログを表示します。そのダイアログには文字列が含まれる場合がありますが、一部の悪意のあるサイトはこれらのダイアログを使用して、怪しいダイエット広告を見るためにページに留まらせるように人々を混乱させようとするため、ほとんどのブラウザはそれらを表示しなくなりました.

イベントとイベントループ

第11章で説明したイベントループのコンテキストでは、ブラウザのイベントハンドラは他の非同期通知と同様に動作します。イベントが発生したときにスケジュールされますが、実行される前に実行中の他のスクリプトが完了するまで待機する必要があります。

イベントは他に何も実行されていないときにのみ処理できるという事実から、イベントループが他の作業で占有されている場合、(イベントを介して行われる)ページとのインタラクションは、処理する時間があるまで遅延することになります。そのため、実行時間の長いイベントハンドラを使用したり、実行時間の短いハンドラを多数使用したりして、過剰な作業をスケジュールすると、ページの動作が遅くなり、使いにくくなります。

ページをフリーズさせることなく、バックグラウンドで時間のかかる処理を本当に実行したい場合のために、ブラウザはWebワーカーと呼ばれるものを提供しています。ワーカーは、メインスクリプトと並行して、独自のタイムラインで実行されるJavaScriptプロセスです。

数値の二乗が、別のスレッドで実行したい負荷の高い、時間のかかる計算であるとします。メッセージに応答して二乗を計算し、メッセージを送り返すcode/squareworker.jsというファイルを作成できます。

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

複数のスレッドが同じデータを操作することによる問題を回避するために、ワーカーはグローバルスコープやその他のデータをメインスクリプトの環境と共有しません。代わりに、メッセージをやり取りすることによって通信する必要があります。

このコードは、そのスクリプトを実行するワーカーを生成し、いくつかのメッセージを送信し、応答を出力します。

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

postMessage関数はメッセージを送信し、受信側で"message"イベントが発生します。ワーカーを作成したスクリプトはWorkerオブジェクトを介してメッセージを送受信しますが、ワーカーはグローバルスコープで直接送受信することによって、作成したスクリプトと通信します。JSONとして表現できる値のみがメッセージとして送信できます。受信側では、値自体ではなく、値のコピーを受け取ります。

タイマー

第11章で説明したsetTimeout関数は、指定されたミリ秒数の後に別の関数を呼び出すようにスケジュールします。スケジュールした関数をキャンセルする必要がある場合があります。setTimeoutによって返された値を保存し、それに対してclearTimeoutを呼び出すことによって、これを行うことができます。

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

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

cancelAnimationFrame関数は、clearTimeoutと同じように機能します。requestAnimationFrameによって返された値に対して呼び出すと、(まだ呼び出されていないと仮定して)そのフレームがキャンセルされます。

同様の関数セットであるsetIntervalclearIntervalは、Xミリ秒ごとに繰り返すタイマーを設定するために使用されます。

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

デバウンス

"mousemove"イベントや"scroll"イベントなど、一部の種類のイベントは、連続して何度も高速に発生する可能性があります。このようなイベントを処理する場合、時間のかかる処理を行わないように注意する必要があります。そうしないと、ハンドラが多くの時間を消費し、ドキュメントとのインタラクションが遅く感じられるようになります。

このようなハンドラで重要な処理を行う必要がある場合は、setTimeoutを使用して、処理が頻繁に行われないようにすることができます。これは通常、イベントのデバウンスと呼ばれます。これには、いくつかのわずかに異なるアプローチがあります。

たとえば、ユーザーが入力したときに反応したいが、すべての入力イベントに対してすぐには反応したくないとします。ユーザーが高速に入力している場合は、一時停止が発生するまで待機したいだけです。イベントハンドラでアクションをすぐに実行する代わりに、タイムアウトを設定します。また、前のタイムアウト(ある場合)をクリアして、イベントが接近して(タイムアウト遅延よりも近い)発生した場合に、前のイベントのタイムアウトがキャンセルされるようにします。

<textarea>Type something here...</textarea>
<script>
  let textarea = document.querySelector("textarea");
  let timeout;
  textarea.addEventListener("input", () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => console.log("Typed!"), 500);
  });
</script>

clearTimeoutに未定義値を指定したり、すでに発生したタイムアウトで呼び出したりしても、効果はありません。したがって、いつ呼び出すかについて注意する必要はなく、すべてのイベントに対して呼び出すだけです。

応答を少なくとも一定時間の間隔で区切りたいが、一連のイベントのではなくに発生させたい場合は、わずかに異なるパターンを使用できます。たとえば、マウスの現在の座標を表示することによって"mousemove"イベントに応答したいが、250ミリ秒ごとに応答したい場合があります。

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

まとめ

イベントハンドラを使用すると、Webページで発生するイベントを検出し、反応することができます。addEventListenerメソッドは、このようなハンドラを登録するために使用されます。

各イベントには、イベントを識別するタイプ("keydown""focus"など)があります。ほとんどのイベントは特定のDOM要素で呼び出され、その要素の先祖に伝播され、それらの要素に関連付けられたハンドラがイベントを処理できるようにします。

イベントハンドラが呼び出されると、イベントに関する追加情報を含むイベントオブジェクトが渡されます。このオブジェクトには、さらなる伝播を停止する(stopPropagation)メソッドと、ブラウザによるイベントのデフォルト処理を防ぐ(preventDefault)メソッドもあります。

キーを押すと、"keydown"イベントと"keyup"イベントが発生します。マウスボタンを押すと、"mousedown""mouseup"、および"click"イベントが発生します。マウスを移動すると、"mousemove"イベントが発生します。タッチスクリーンのインタラクションは、"touchstart""touchmove"、および"touchend"イベントを発生させます。

スクロールは"scroll"イベントで検出でき、フォーカスの変更は"focus"イベントと"blur"イベントで検出できます。ドキュメントの読み込みが完了すると、ウィンドウで"load"イベントが発生します。

演習

バルーン

バルーン(バルーンの絵文字🎈を使用)を表示するページを作成します。上矢印キーを押すと、10%膨らむ(大きくなる)必要があります。下矢印キーを押すと、10%しぼむ(小さくなる)必要があります。

親要素のfont-size CSSプロパティ(style.fontSize)を設定することにより、テキストのサイズ(絵文字はテキストです)を制御できます。値に単位(たとえば、ピクセル(10px))を含めることを忘れないでください。

矢印キーのキー名は"ArrowUp""ArrowDown"です。キーがページをスクロールさせることなく、風船だけを変更するようにしてください。

それが動作したら、風船を一定のサイズ以上に膨らませると「爆発」する機能を追加します。この場合、爆発とは、💥絵文字に置き換えられ、イベントハンドラが削除されること(爆発を膨らませたり縮ませたりできないようにするため)を意味します。

<p>🎈</p>

<script>
  // Your code here
</script>
ヒントを表示...

"keydown"イベントのハンドラを登録し、event.keyを見て、上矢印キーまたは下矢印キーが押されたかどうかを判断する必要があります。

現在のサイズはバインディングに保持できるため、新しいサイズをそのサイズに基づいて設定できます。サイズ(バインディングとDOMの風船のスタイルの両方)を更新する関数を定義すると便利です。イベントハンドラから呼び出すことができ、初期サイズを設定するために起動時にも呼び出すことができます。

テキストノードを別のノードに置き換える(replaceChildを使用)か、親ノードのtextContentプロパティを新しい文字列に設定することで、風船を爆発に変更できます。

マウストレイル

JavaScriptの初期の頃、アニメーション画像がたくさんある派手なホームページが全盛期だった頃、人々は言語を使用する真に刺激的な方法をいくつか考案しました。その1つが*マウストレイル*です。ページ上をマウスポインタを移動すると、一連の要素がマウスポインタを追跡します。

この演習では、マウストレイルを実装していただきます。固定サイズと背景色を持つ絶対配置された<div>要素を使用します(「マウスクリック」セクションのコードを参照)。これらの要素をいくつか作成し、マウスが移動したら、マウスポインタの軌跡にそれらを表示します。

ここでは、さまざまなアプローチが考えられます。トレイルは、単純なものから複雑なものまで、自由に作成できます。最初に始める簡単な解決策は、固定数のトレイル要素を保持し、それらを循環させ、"mousemove"イベントが発生するたびに次の要素をマウスの現在の位置に移動することです。

<style>
  .trail { /* className for the trail elements */
    position: absolute;
    height: 6px; width: 6px;
    border-radius: 3px;
    background: teal;
  }
  body {
    height: 300px;
  }
</style>

<script>
  // Your code here.
</script>
ヒントを表示...

要素の作成は、ループを使用して行うのが最適です。それらをドキュメントに追加して表示させます。後でそれらにアクセスして位置を変更できるように、要素を配列に格納する必要があります。

それらを循環させるには、カウンタ変数を保持し、"mousemove"イベントが発生するたびに1を追加することで実行できます。剰余演算子(% elements.length)を使用して、特定のイベント中に配置する要素を選択するための有効な配列インデックスを取得できます。

単純な物理システムをモデル化することで、別の興味深い効果を実現できます。"mousemove"イベントを使用して、マウスの位置を追跡するバインディングのペアを更新するだけです。次に、requestAnimationFrameを使用して、後続の要素がマウスポインタの位置に引き付けられるようにシミュレートします。アニメーションステップごとに、ポインタに対する相対位置(およびオプションで、各要素に格納されている速度)に基づいて位置を更新します。これを行うための良い方法を見つけるのはあなた次第です。

タブ

タブ付きパネルは、ユーザーインターフェイスでよく使用されます。要素の上に「突き出ている」多数のタブから選択することで、インターフェースパネルを選択できます。

単純なタブ付きインターフェースを実装します。DOMノードを取得し、そのノードの子要素を表示するタブ付きインターフェースを作成する関数asTabsを記述します。ノードの上部に<button>要素のリストを挿入する必要があります。子要素ごとに1つずつ、子のdata-tabname属性から取得したテキストが含まれています。元の子要素の1つ以外はすべて非表示にする必要があります(displayスタイルをnoneに設定)。現在表示されているノードは、ボタンをクリックすることで選択できます。

それが機能したら、現在選択されているタブのボタンのスタイルを異なって拡張して、どのタブが選択されているかが明確になるようにします。

<tab-panel>
  <div data-tabname="one">Tab one</div>
  <div data-tabname="two">Tab two</div>
  <div data-tabname="three">Tab three</div>
</tab-panel>
<script>
  function asTabs(node) {
    // Your code here.
  }
  asTabs(document.querySelector("tab-panel"));
</script>
ヒントを表示...

遭遇する可能性のある落とし穴の1つは、ノードのchildNodesプロパティをタブノードのコレクションとして直接使用できないことです。1つは、ボタンを追加すると、それらも子ノードになり、ライブデータ構造であるため、このオブジェクトに含まれることです。もう1つは、ノード間の空白用に作成されたテキストノードもchildNodesにありますが、独自のタブを取得するべきではありません。テキストノードを無視するには、childNodesの代わりにchildrenを使用できます。

タブの配列を構築することから始めることができます。そうすれば、タブに簡単にアクセスできます。ボタンのスタイリングを実装するには、タブパネルとそのボタンの両方を含むオブジェクトを格納できます。

タブの変更には、別の関数を記述することをお勧めします。以前に選択したタブを格納し、それを非表示にして新しいタブを表示するために必要なスタイルのみを変更するか、新しいタブが選択されるたびにすべてのタブのスタイルを更新するだけです。

インターフェースを最初のタブが表示された状態で開始するには、この関数をすぐに呼び出すことができます。