第3版が利用可能です。 ここで読む

第14章
イベント処理

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

マルクス・アウレリウス、自省録

プログラムの中には、マウスやキーボードの操作など、ユーザーからの直接の入力で動作するものがあります。このような入力のタイミングや順序は、事前に予測することはできません。そのため、これまで使用してきた制御フローとは異なるアプローチが必要となります。

イベントハンドラ

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

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

もちろん、キューを確認することを忘れずに、頻繁に行う必要があります。キーが押されてからプログラムがイベントに気付くまでの間に時間がかかると、ソフトウェアの反応が遅く感じられるからです。このアプローチは、ポーリングと呼ばれます。ほとんどのプログラマーは、可能な限りこれを避けます。

より良いメカニズムは、基盤となるシステムが、イベントが発生したときに私たちのコードがそれに反応する機会を与えることです。ブラウザは、特定のイベントのハンドラとして関数を登録できるようにすることで、これを実現しています。

<p>Click this document to activate the handler.</p>
<script>
  addEventListener("click", function() {
    console.log("You clicked!");
  });
</script>

addEventListener関数は、第1引数で記述されたイベントが発生するたびに、第2引数が呼び出されるように登録します。

イベントとDOMノード

ブラウザの各イベントハンドラは、コンテキスト内で登録されます。前述のようにaddEventListenerを呼び出す場合、ブラウザではグローバルスコープがwindowオブジェクトと同等であるため、ウィンドウ全体に対してメソッドとして呼び出しています。すべてのDOM要素には、独自のaddEventListenerメソッドがあり、その要素を具体的にリッスンすることができます。

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

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

ノードにonclick属性を指定しても同様の効果があります。しかし、ノードにはonclick属性が1つしかないため、その方法ではノードごとに1つのハンドラしか登録できません。addEventListenerメソッドでは、任意の数のハンドラを追加できるため、既に登録されているハンドラを誤って置き換えることはありません。

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

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

ハンドラ関数の登録を解除できるように、addEventListenerremoveEventListenerの両方に渡せるように、名前(例:once)を付けます。

イベントオブジェクト

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

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

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

伝播

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

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

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

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

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

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

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

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

デフォルトアクション

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

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

これは、独自のキーボードショートカットやコンテキストメニューを実装するために使用できます。また、ユーザーが期待する動作を不快に妨害するためにも使用できます。たとえば、次に示すリンクはたどることができません。

<a href="https://developer.mozilla.org/">MDN</a>
<script>
  var link = document.querySelector("a");
  link.addEventListener("click", function(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>
  addEventListener("keydown", function(event) {
    if (event.keyCode == 86)
      document.body.style.background = "violet";
  });
  addEventListener("keyup", function(event) {
    if (event.keyCode == 86)
      document.body.style.background = "";
  });
</script>

その名前にもかかわらず、"keydown"はキーが物理的に押されたときだけ発生するわけではありません。キーを押したままにすると、キーがリピートするたびにイベントが再び発生します。たとえば、矢印キーが押されたときにゲームキャラクターの加速度を上げ、キーが離されたときに加速度を下げたい場合など、キーが繰り返されるたびに再び加速度を上げないように注意する必要があります。そうしないと、意図せずに巨大な値になってしまう可能性があります。

前の例では、イベントオブジェクトのkeyCodeプロパティを調べました。これは、どのキーが押されたか、または離されたかを識別する方法です。残念ながら、数値キーコードを実際のキーに変換する方法は、必ずしも明確ではありません。

文字キーと数字キーの場合、関連付けられたキーコードは、キーに印刷された(大文字の)文字または数字に関連付けられたUnicode文字コードになります。文字列のcharCodeAtメソッドを使用すると、このコードを見つけることができます。

console.log("Violet".charCodeAt(0));
// → 86
console.log("1".charCodeAt(0));
// → 49

他のキーは、キーコードの予測が困難です。必要なコードを見つける最良の方法は、通常、実験することです。取得したキーコードをログに記録するキーイベントハンドラを登録し、目的のキーを押します。

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

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

"keydown"イベントと"keyup"イベントは、押されている物理キーに関する情報を提供します。しかし、実際に入力されているテキストに興味がある場合はどうでしょうか?キーコードからそのテキストを取得するのは面倒です。代わりに、別のイベントである"keypress"があります。これは、"keydown"の直後に発生し(キーが押されている間は"keydown"とともに繰り返し発生しますが)、文字入力を生成するキーに対してのみ発生します。イベントオブジェクトのcharCodeプロパティには、Unicode文字コードとして解釈できるコードが含まれています。String.fromCharCode関数を使用して、このコードを実際の1文字の文字列に変換できます。

<p>Focus this page and type something.</p>
<script>
  addEventListener("keypress", function(event) {
    console.log(String.fromCharCode(event.charCode));
  });
</script>

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

マウスクリック

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

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

2回のクリックが近い間隔で行われると、2回目のクリックイベントの後に"dblclick"(ダブルクリック)イベントも発生します。

マウスイベントが発生した場所の正確な情報を取得するには、ドキュメントの左上隅を基準としたイベントの座標(ピクセル単位)を含む、そのpageXおよびpageYプロパティを確認できます。

以下は、原始的な描画プログラムを実装したものです。ドキュメントをクリックするたびに、マウスポインターの下にドットが追加されます。より洗練された描画プログラムについては、第19章を参照してください。

<style>
  body {
    height: 200px;
    background: beige;
  }
  .dot {
    height: 8px; width: 8px;
    border-radius: 4px; /* rounds corners */
    background: blue;
    position: absolute;
  }
</style>
<script>
  addEventListener("click", function(event) {
    var 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>

clientXおよびclientYプロパティは、pageXおよびpageYに似ていますが、ドキュメントの現在スクロールされて表示されている部分を基準としています。これらは、同じくビューポートを基準とした座標を返すgetBoundingClientRectによって返される座標とマウス座標を比較する場合に役立ちます。

マウスの動き

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

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

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

  function buttonPressed(event) {
    if (event.buttons == null)
      return event.which != 0;
    else
      return event.buttons != 0;
  }
  function moved(event) {
    if (!buttonPressed(event)) {
      removeEventListener("mousemove", moved);
    } else {
      var dist = event.pageX - lastX;
      var newWidth = Math.max(10, rect.offsetWidth + dist);
      rect.style.width = newWidth + "px";
      lastX = event.pageX;
    }
  }
</script>

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

マウスボタンが離されたときに、バーのサイズ変更を停止する必要があります。残念ながら、すべてのブラウザが"mousemove"イベントに意味のあるwhichプロパティを提供しているわけではありません。同様の情報を提供するbuttonsという標準プロパティがありますが、これもすべてのブラウザでサポートされているわけではありません。幸いなことに、すべての主要なブラウザはbuttonsまたはwhichのいずれかをサポートしているため、この例のbuttonPressed関数は最初にbuttonsを試み、それが利用できない場合はwhichにフォールバックします。

マウスポインターがノードに入るかノードから出ると、"mouseover"または"mouseout"イベントが発生します。これらの2つのイベントは、特に、ホバー効果を作成し、マウスが特定の要素上にあるときに何かを表示またはスタイルを設定するために使用できます。

残念ながら、このような効果を作成することは、"mouseover"で効果を開始し、"mouseout"で効果を終了するほど単純ではありません。マウスがノードからその子ノードの1つに移動すると、マウスが実際にノードの範囲から出ていないにもかかわらず、親ノードで"mouseout"が発生します。さらに悪いことに、これらのイベントは他のイベントと同様に伝播されるため、ハンドラーが登録されているノードの子ノードの1つからマウスが離れると、"mouseout"イベントも受信します。

この問題を回避するには、これらのイベントに対して作成されたイベントオブジェクトのrelatedTargetプロパティを使用できます。これは、"mouseover"の場合、ポインターが以前にどの要素の上にあり、"mouseout"の場合、どの要素に移動するかを示します。relatedTargetがターゲットノードの外側にある場合にのみ、ホバー効果を変更します。その場合にのみ、このイベントは実際にノードの外側から内側への(またはその逆の)*クロスオーバー*を表します。

<p>Hover over this <strong>paragraph</strong>.</p>
<script>
  var para = document.querySelector("p");
  function isInside(node, target) {
    for (; node != null; node = node.parentNode)
      if (node == target) return true;
  }
  para.addEventListener("mouseover", function(event) {
    if (!isInside(event.relatedTarget, para))
      para.style.color = "red";
  });
  para.addEventListener("mouseout", function(event) {
    if (!isInside(event.relatedTarget, para))
      para.style.color = "";
  });
</script>

isInside関数は、ドキュメントの最上部に到達する(nodeがnullになる)か、探している親が見つかるまで、指定されたノードの親リンクをたどります。

このようなホバー効果は、次の例に示すように、CSS*擬似セレクター*:hoverを使用することで、はるかに簡単に実現できることを付け加えておきます。ただし、ホバー効果にターゲットノードのスタイルを変更するよりも複雑な処理が含まれる場合は、"mouseover"および"mouseout"イベントを使用する必要があります。

<style>
  p:hover { color: red }
</style>
<p>Hover over this <strong>paragraph</strong>.</p>

スクロールイベント

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

次の例では、ドキュメントの右上隅にプログレスバーを描画し、下にスクロールすると塗りつぶされるように更新します。

<style>
  .progress {
    border: 1px solid blue;
    width: 100px;
    position: fixed;
    top: 10px; right: 10px;
  }
  .progress > div {
    height: 12px;
    background: blue;
    width: 0%;
  }
  body {
    height: 2000px;
  }
</style>
<div class="progress"><div></div></div>
<p>Scroll me...</p>
<script>
  var bar = document.querySelector(".progress div");
  addEventListener("scroll", function() {
    var max = document.body.scrollHeight - innerHeight;
    var percent = (pageYOffset / max) * 100;
    bar.style.width = percent + "%";
  });
</script>

要素にfixedpositionを指定すると、absoluteの位置と同様に機能しますが、ドキュメントの残りの部分と一緒にスクロールすることも防ぎます。その効果として、プログレスバーが隅に留まります。その中には別の要素があり、現在の進行状況を示すようにサイズ変更されます。要素がバー全体を基準にしてサイズ変更されるように、幅を設定するときに単位としてpxではなく%を使用します。

グローバルなinnerHeight変数はウィンドウの高さを提供します。これは、合計スクロール可能な高さから差し引く必要があります。ドキュメントの一番下に到達すると、スクロールし続けることはできません。(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="Age in years"></p>
<p id="help"></p>

<script>
  var help = document.querySelector("#help");
  var fields = document.querySelectorAll("input");
  for (var i = 0; i < fields.length; i++) {
    fields[i].addEventListener("focus", function(event) {
      var text = event.target.getAttribute("data-help");
      help.textContent = text;
    });
    fields[i].addEventListener("blur", function(event) {
      help.textContent = "";
    });
  }
</script>

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

ロードイベント

ページの読み込みが完了すると、ウィンドウとドキュメント本文オブジェクトで"load"イベントが発生します。これは、ドキュメント全体が構築されている必要がある初期化アクションをスケジュールするためによく使用されます。<script>タグの内容は、タグが見つかるとすぐに実行されることに注意してください。これは、スクリプトが<script>タグの後に表示されるドキュメントの一部で何かを行う必要がある場合など、多くの場合早すぎます。

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

ページが閉じられたり、(たとえば、リンクをたどることで)移動されたりすると、"beforeunload"イベントが発生します。このイベントの主な用途は、ユーザーがドキュメントを閉じて誤って作業を失うことを防ぐことです。ページのアンロードを防止するには、予想どおり、preventDefaultメソッドを使用するのではなく、ハンドラーから文字列を返すことで行います。文字列は、ページに留まるか、ページを離れるかをユーザーに尋ねるダイアログで使用されます。このメカニズムにより、ユーザーは、悪意のあるスクリプトを実行している場合でも、ページを離れることができます。悪意のあるスクリプトは、怪しげな減量広告を見せるために、ユーザーを永遠にそこに留めておこうとする可能性があります。

スクリプト実行タイムライン

スクリプトの実行を開始させる原因となるものはさまざまです。<script>タグを読み取ることもその1つです。イベントの発生もその1つです。第13章では、次のページの再描画前に関数を呼び出すようにスケジュールするrequestAnimationFrame関数について説明しました。これもスクリプトの実行を開始する方法の1つです。

イベントはいつでも発生する可能性がありますが、単一のドキュメント内の2つのスクリプトが同時に実行されることはないことを理解することが重要です。スクリプトがすでに実行されている場合、イベントハンドラーと他の方法でスケジュールされたコードは、順番が来るまで待機する必要があります。これが、スクリプトが長時間実行されるとドキュメントがフリーズする理由です。ブラウザは、現在のスクリプトの実行が完了するまでイベントハンドラーを実行できないため、ドキュメント内のクリックやその他のイベントに反応できません。

一部のプログラミング環境では、複数の*実行スレッド*を同時に実行できます。複数のことを同時に行うことで、プログラムを高速化できます。ただし、複数の actors がシステムの同じ部分を同時に操作する場合、プログラムについて考えることは少なくとも桁違いに難しくなります。

JavaScriptプログラムが一度に1つのことだけを行うという事実は、私たちの生活を楽にします。ページをフリーズさせることなく、*本当に*時間のかかる処理をバックグラウンドで実行したい場合、ブラウザは*Webワーカー*と呼ばれるものを提供します。ワーカーは、ドキュメントのメインプログラムと一緒に実行され、メッセージの送受信によってのみ通信できる、分離されたJavaScript環境です。

code/squareworker.jsというファイルに次のコードがあるとします。

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

数値の2乗算は、バックグラウンドスレッドで実行したい、負荷の高い長時間実行の計算であると想像してください。このコードは、ワーカーを生成し、いくつかのメッセージを送信し、応答を出力します。

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

postMessage関数はメッセージを送信します。これにより、受信側で"message"イベントが発生します。ワーカーを作成したスクリプトは、Workerオブジェクトを介してメッセージを送受信しますが、ワーカーは、作成したスクリプトと、グローバルスコープ(元のスクリプトと共有されていない*新しい*グローバルスコープ)で直接送受信することで通信します。

タイマーの設定

setTimeout関数は、requestAnimationFrameに似ています。後で呼び出される別の関数をスケジュールします。ただし、次の再描画時に関数を呼び出す代わりに、指定されたミリ秒数だけ待機します。このページは2秒後に青色から黄色に変わります。

<script>
  document.body.style.background = "blue";
  setTimeout(function() {
    document.body.style.background = "yellow";
  }, 2000);
</script>

スケジュールした関数をキャンセルする必要がある場合があります。これは、setTimeoutによって返された値を保存し、clearTimeoutを呼び出すことで行います。

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

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

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

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

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

デバウンス

イベントの種類によっては、非常に速く、何度も連続して発生する可能性があります(例えば、"mousemove" イベントや "scroll" イベント)。このようなイベントを処理する際には、時間のかかる処理を行わないように注意する必要があります。そうしないと、ハンドラが多くの時間を消費し、ドキュメントとのインタラクションが遅く、ぎこちなくなってしまいます。

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

最初の例では、ユーザーが入力したときに何らかの処理を行いたいのですが、すべてのキーイベントに対して即座に処理を行いたくありません。ユーザーが高速で入力している場合は、一時停止が発生するまで待ちたいだけです。イベントハンドラで即座にアクションを実行する代わりに、タイムアウトを設定します。また、前のタイムアウト(もしあれば)をクリアすることで、イベントが短い間隔(タイムアウト遅延よりも短い間隔)で発生した場合、前のイベントのタイムアウトがキャンセルされます。

<textarea>Type something here...</textarea>
<script>
  var textarea = document.querySelector("textarea");
  var timeout;
  textarea.addEventListener("keydown", function() {
    clearTimeout(timeout);
    timeout = setTimeout(function() {
      console.log("You stopped typing.");
    }, 500);
  });
</script>

clearTimeout に未定義の値を渡したり、既に実行されたタイムアウトに対して呼び出したりしても、何も起こりません。そのため、いつ呼び出すかについて注意する必要はなく、すべてのイベントに対して単純に呼び出すことができます。

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

<script>
  function displayCoords(event) {
    document.body.textContent =
      "Mouse at " + event.pageX + ", " + event.pageY;
  }

  var scheduled = false, lastEvent;
  addEventListener("mousemove", function(event) {
    lastEvent = event;
    if (!scheduled) {
      scheduled = true;
      setTimeout(function() {
        scheduled = false;
        displayCoords(lastEvent);
      }, 250);
    }
  });
</script>

まとめ

イベントハンドラを使用すると、直接制御できないイベントを検出して反応することができます。addEventListener メソッドは、そのようなハンドラを登録するために使用されます。

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

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

キーを押すと、"keydown""keypress""keyup" イベントが発生します。マウスボタンを押すと、"mousedown""mouseup""click" イベントが発生します。マウスを動かすと、"mousemove" イベント、場合によっては "mouseenter" イベントと "mouseout" イベントが発生します。

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

JavaScript プログラムは、一度に 1 つだけ実行できます。そのため、イベントハンドラやその他のスケジュールされたスクリプトは、他のスクリプトが終了するまで待機する必要があります。

演習

検閲キーボード

1928 年から 2013 年まで、トルコの法律では、公式文書で文字 _Q_、_W_、_X_ を使用することが禁じられていました。これは、クルド文化を抑制するためのより広範な取り組みの一環でした。これらの文字はクルド人が使用する言語には存在しますが、イスタンブールトルコ語には存在しません。

技術を使ってばかげたことをする練習として、これらの文字を入力できないテキストフィールド(<input type="text"> タグ)をプログラムするように依頼します。

(コピーアンドペーストなどの抜け穴については心配しないでください。)

<input type="text">
<script>
  var field = document.querySelector("input");
  // Your code here.
</script>

この演習の解決策は、キーイベントのデフォルトの動作を防ぐことです。"keypress" または "keydown" のいずれかを処理できます。いずれかで preventDefault が呼び出されると、文字は表示されません。

入力された文字を識別するには、keyCode プロパティまたは charCode プロパティを調べ、フィルターする文字のコードと比較する必要があります。"keydown" では、押されたキーのみを識別するため、小文字と大文字を区別する必要はありません。入力された実際の文字を識別する "keypress" を処理する場合は、両方のケースをテストする必要があります。そのための 1 つの方法は次のとおりです。

/[qwx]/i.test(String.fromCharCode(event.charCode))

マウストレイル

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 を加算します。剰余演算子(% 10)を使用して、特定のイベント中に配置する要素を選択するための有効な配列インデックスを取得できます。

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

タブ

タブ付きインターフェースは一般的なデザインパターンです。要素の上に「突き出ている」複数のタブから選択することで、インターフェースパネルを選択できます。

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

動作したら、現在アクティブなボタンのスタイルも変更するように拡張します。

<div id="wrapper">
  <div data-tabname="one">Tab one</div>
  <div data-tabname="two">Tab two</div>
  <div data-tabname="three">Tab three</div>
</div>
<script>
  function asTabs(node) {
    // Your code here.
  }
  asTabs(document.querySelector("#wrapper"));
</script>

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

これを回避するには、まず、nodeType が 1 であるラッパー内のすべての子の実際の配列を作成することから始めます。

ボタンにイベントハンドラを登録する場合、ハンドラ関数は、どのタブ要素がボタンに関連付けられているかを知る必要があります。通常のループで作成された場合、関数内からループインデックス変数にアクセスできますが、その変数はループによってさらに変更されるため、正しい番号は得られません。

簡単な回避策は、forEach メソッドを使用し、forEach に渡された関数内からハンドラ関数を作成することです。その関数の 2 番目の引数として渡されるループインデックスは、通常のローカル変数になり、それ以上の反復によって上書きされることはありません。