2020.09.23

JavaScript超基礎講座!イベント処理を学ぶ


本記事では、JavaScript 初心者の方向けに、ブラウザにおける JavaScript 開発の基礎について書いていきます。

最近は React や Vue.js などのフレームワークがスタンダードになって、「生の」JavaScript を扱うことは少なくなっているかもしれません。しかし、フレームワークの裏側で動く仕組みは当然同じです。技術トレンドが移り変わっても対応できる「基礎知識」を身につける、一助になれば幸いです。

  • 少なくとも、HTML / CSS と、JavaScript の文法知識を前提としています。
  • フロントエンド、つまりブラウザを実行環境とする JavaScript を扱います。Node.js などのサーバサイドの話題は含みません。

JavaScript におけるイベント

イベントは、DOM 操作に並んで、とても重要な話題です。

ブラウザにおける JavaScript 開発とは、基本的に、ユーザーと Web ブラウザの間のインタラクションの実装です。

ユーザーと Web ブラウザの間のインタラクション

インタラクションとは、ユーザーがブラウザに対して行う操作に対して、ブラウザが反応して、ユーザーにフィードバックを与える、双方向の、相互作用です。

たとえば、「画面上のこの部分をクリックしたときに、ここを表示してここを隠す」という処理です。このような処理は、ユーザーが特定の操作を行った際に実行される処理を、あらかじめ登録しておく形で実装されます。それらの処理は:

  • たいてい DOM の操作が含まれている。
  • 登録された時点では実行されない。ブラウザ操作が行われてはじめて実行される。

ここでいう「ブラウザ操作」を、「イベント」と呼びます。

イベントの具体例は、クリックやインプット要素への入力、マウスオーバーなど。「JavaScript イベント」で検索すれば、より詳しい一覧を得られるでしょう。

つまり、フロントエンド JavaScript 開発では、「いつ(=イベント)」「何が起こる(=DOM 操作)」か、という軸で発想していくことになります。

イベントの登録

addEventListener

イベントは、addEventListener メソッドで登録します。

// DOM 検索メソッドにより、HTML 要素の参照を得る
const button = document.querySelector('#btn-1');

// HTML 要素に対して、イベントのハンドラー関数を登録する
button.addEventListener('click', function(event) {
  // ここに、要素がクリックされたときに実行されるべき処理を記述する
});

addEventListener の引数は、以下の通りです:

# 内容 必須?
第一引数 文字列 イベント名 YES
第二引数 関数 イベントハンドラー YES
第三引数 真偽 or オブジェクト オプション NO

上のコード例は、「ボタンのクリックイベントに、第二引数のハンドラー関数を登録する」という意味になります。

イベントの登録

イベントハンドラーを登録する対象は、上記の例のボタンのような HTML 要素のほか、window オブジェクトや document オブジェクトもあります。

イベントが実際に生じることを、イベントが発火する、と表現します。そして、イベントが発火した際に実行される関数を、イベントハンドラー、またはイベントリスナーと呼びます。

イベントオブジェクト

イベントハンドラーの第一引数は、イベントオブジェクトです。

イベントオブジェクトには、発生したイベントについての情報を保持するいくつかのプロパティと、イベントを操作できるメソッドが実装されています。

button.addEventListener('click', function(event) {
  // イベントオブジェクトのプロパティの例
  console.log(event.currentTarget); // イベントの登録対象(この例ではボタン要素)
});

イベントハンドラーと this

イベントハンドラーの中では、thisイベントの登録対象を示すという特性も覚えておきましょう。

button.addEventListener('click', function(event) {
  this.classList.toggle('hello'); // this はボタン要素
});

this が特別な意味を持つため、イベントハンドラーをアロー関数で書く場合に、注意が必要になります。ざっくり言うと、アロー関数では、function キーワードでの定義と異なり、内部の this の特別な意味が消えます。つまり、関数外のスコープの this と同じ意味になるということです。

button.addEventListener('click', event =>  {
  // ❌ このコードは意図通りには動かない
  this.classList.toggle('hello');
});

この性質を知ることは、コードリーディングにも役立つことと思います。

イベントハンドラーが追加される仕組み

addEventListener は、実行するたびに、対象にイベントハンドラーを追加していきます。

たとえば、以下のコードを実行すると…

button.addEventListener('click', function() { console.log('Hello'); });
button.addEventListener('click', function() { console.log('Hello'); });
button.addEventListener('click', function() { console.log('Hello'); });

下図のように、3つのハンドラーが登録されます。仮に関数の内容が同一であっても、置き換えなどではなく、単純に追加されます。結果として、クリックしたときに、3つの関数が実行され、3つのログが出力されるでしょう。

イベントハンドラーを複数回登録する

この挙動は問題になることがあります。

たとえば、次のような関数があるとしましょう。この foo 関数は、複数回実行される可能性があります。そして、内部で window に対して resize イベントにハンドラーを登録しています。

function foo() {
  window.addEventListener('resize', function(event) {
    // 中略…
  });
  // 処理が続く…
}

foo 関数が複数回実行されたとき、windowresize イベントにはハンドラーがどんどん追加されていきます。当然、それが意図した仕様であれば OK ですが、そうではなく、ハンドラー自体は一度だけ登録→実行されてほしい場合は、問題になります。

解決策の例としては、以下のようにハンドラーを削除してから登録する=必ず一度だけ登録されている状態にすることが考えられます。

// ハンドラーは foo のスコープ外に定義する
const handleResize = () => {/* ... */};

function foo() {
   // 初回実行時はまだハンドラーが登録されていないが、
   // removeEventListener はマッチする関数がなくてもエラーにはならない
  window.removeEventListener('resize', handleResize);
  window.addEventListener('resize', handleResize);
  // ...
}

この例は各論なので、プロジェクトによっていろいろなケースがあると思います。ここで言いたいのは、イベントハンドラーが追加されていく仕様を覚えておくと、少し例外的なケースでも、問題解決を図れるということです。

登録されたイベントハンドラーの取得

addEventListener で登録されたイベントハンドラーをあとから取り出したり、そもそもイベントハンドラーが登録されているかどうかを調べたい場合があるかもしれません。

しかし、それらを実現する方法は、JavaScript には 提供されていません

登録済みのイベントを確認したくなるケースのうちの一部は、イベントハンドラーの取り消しや、ハンドラーを一度だけ実行させるパターンで解決できるように思われます。

イベントハンドラーの取り消し

addEventListener で登録されたイベントハンドラーは、removeEventListener で取り消すことができます。

function sayHello() {
  console.log('Hello');
}

button.addEventListener('click', sayHello);
button.removeEventListener('click', sayHello);

注意点として、addEventListener で登録したのと同一の関数を指定する必要があります。つまり、以下のコードは追加したハンドラーを削除してくれません。(たまたま)内容が同じだけの、別の関数オブジェクトとして扱われるからです。

// ❌ 意図通りに動作しない
button.addEventListener('click', function() { console.log('Hello'); });
button.removeEventListener('click', function() { console.log('Hello'); });

removeEventListener を利用したい場合は、無名関数ではなく、一度ハンドラーを名前付きで関数定義してから指定しましょう。以下のように、アロー関数で記述しても同じ意味です。

const sayHello = () => console.log('Hello');
button.addEventListener('click', sayHello);
button.removeEventListener('click', sayHello);

イベントハンドラーの取り消しは主に、上で説明した、ハンドラー追加の挙動が問題になる場合に用いられることが多いです。

一度きりのイベント

イベントハンドラーを一度だけ実行してから削除してしまう実装も、上で説明した問題の解決策として使えることがあります。

addEventListener の第三引数の once オプションに true を指定すると、一度実行されたハンドラが自動的に削除されます。

element.addEventListener('click', function(event) {
  // ...
}, { once: true });

once オプションは IE11 ではサポートされていないので、レガシーな環境に対応するためには、removeEventListener を用いた実装が必要です。

element.addEventListener('click', function handleResize() {
  element.removeEventListener('click', handleResize);
  // 後続処理...
});

addEventListener のハンドラーを名前付きで定義して、その中で、その関数名で removeEventListener を呼び出すパターンです。一見ややこしいですが、イディオムとして覚えてもよいでしょう。

以下は、あるモーダルウィンドウライブラリの実装で見かけた例です。モーダルウィンドウを閉じる操作で、フワッと消えるアニメーションを待ってから、実際に閉じる処理を行う箇所でした。

modalContent.addEventListener('animationend', function close() {
  modalContent.removeEventListener('animationend', close);
  // ...
});

removeEventListener を呼ばなければ、閉じる操作が行われるたびに、無駄に animationend イベントのハンドラーが追加されることになるでしょう。

イベントのキャンセル

イベントオブジェクトの preventDefault メソッドを実行すると、デフォルトのイベントの挙動がキャンセルされます。

「デフォルトの挙動」はイベントによって異なりますが、よく使われるパターンを2つ紹介します。

まずはアンカーリンクの画面遷移をキャンセルするパターンです。たとえばタブなど、<a> 要素に、本来のリンクとしての役割ではなく、ボタン的な挙動を求めるケースです。

// link は <a> 要素とする
link.addEventListener('click', function(event) {
  // 画面遷移をキャンセル
  event.preventDefault();
  // 後続処理...
});

次に、フォーム送信のキャンセルです。これは、送信前にバリデーションチェックを挟んだり、送信データを追加・加工したり、送信先の情報を設定したり、など、フォームの動作をカスタマイズしたいときに使えるパターンです。

// form は <form> 要素とする
form.addEventListener('submit', function(event) {
  // 送信処理をキャンセル
  event.preventDefault();

  // バリデーションチェックや送信先の設定など...

  // 送信
  event.currentTarget.submit();
});

送信ボタン(type="submit")に対するイベント設定でも、送信をキャンセルできます。

// 送信ボタンに対する
submitButton.addEventListener('click', function(event) {
  // 送信処理をキャンセル
  event.preventDefault();

  // 中間処理...

  // なんらかの方法でフォーム要素を取得してから...
  form.submit();
});

(実用的かは別として)以下は、ボタンごとにフォームの送信先を切り替えるコード例です。

<form id="the-form" method="post">
  <input type="text" name="foo" />
  <button type="submit" data-action="/page-a">ページAに送信</button>
  <button type="submit" data-action="/page-b">ページBに送信</button>
</form>

<script>
  const form = document.querySelector('#the-form');

  const submitButtons = document.querySelectorAll('button[type="submit"]');

  Array.prototype.forEach.call(submitButtons, submitButton => {
    submitButton.addEventListener('click', event => {
      event.preventDefault();
      const action = event.currentTarget.dataset.action;
      form.setAttribute('action', action);
      form.submit();
    });
  });
</script>

イベントは伝播する

イベントは、子要素から親要素へ「伝播(Propagate)」する性質を持っています。そして伝播はイベントオブジェクトの stopPropagation メソッドでキャンセルできます。

まず、伝播の性質について説明します。以下の HTML があるとしましょう。

<div id="one">
  #one
  <div id="two">
    #two
    <div id="three">
      #three
    </div>
  </div>
</div>

下図のような見た目をイメージしてください。

そして、それぞれに以下のイベントハンドラーを登録します。ちなみに target プロパティは、イベントの発生源を示すプロパティです。

const one = document.querySelector('#one');
one.addEventListener('click', function(e) {
  console.log('One handler', e.target.id);
});

const two = document.querySelector('#two');
two.addEventListener('click', function(e) {
  console.log('Two handler', e.target.id);
});

const three = document.querySelector('#three');
three.addEventListener('click', function(e) {
  console.log('Three handler', e.target.id);
});

さてここで、一番子どもの要素である #three をクリックすると、どのようなコンソール出力が得られるでしょうか?

結果は以下の出力になります。

"Three handler", "three"
"Two handler", "three"
"One handler", "three"

クリックしたのは #three だけですが、three → two → one の順に、イベントハンドラーが実行されていますね。それぞれ、イベントの発生源は three と出力されています。

これは、イベントが子要素から親要素に伝播するからです。その仕組みは、下図のようになります。

まず、親子要素は、上図の左のように、3次元のZ軸方向に重なっているとイメージしてください。一番上の要素(つまり子要素)のイベントは、下(つまり親)に伝播していきます。

つまり、クリックイベントで言うと、上(子)をクリックすると、下(親)をクリックしたことになります。紙を何枚か重ねて、一番上を強く押すと下の紙にも跡が残るようなイメージでしょうか。

この伝播の性質は、無視できる場合が多いですが、子にも親にも同じイベントにハンドラーを設定している場合などは、stopPropagation で電波をストップさせることができます。

element.addEventListener('click', function(event) {
  event.stopPropagation();
});

stopPropagation を実行した階層で、伝播は止まります。

たとえば、#twostopPropagation を実行すると(上図の左のイメージ)…

const two = document.querySelector('#two');
two.addEventListener('click', function(e) {
  e.stopPropagation();
  console.log('Two handler', e.target.id);
});

#two で伝播は止まって #one にはイベントが到達しないので、コンソールには2行しか出力されません。

"Three handler", "three"
"Two handler", "three"

次に、#threestopPropagation を実行すると(上図の右のイメージ)…

const three = document.querySelector('#three');
three.addEventListener('click', function(e) {
  e.stopPropagation();
  console.log('Three handler', e.target.id);
});

#three で伝播は止まって #two#one にはイベントが到達しないので、コンソールには1行しか出力されません。

"Three handler", "three"

ページの読み込みイベント

ここでは、2種類のページ読み込みイベントを紹介します。

上で学んだイベント登録の処理や DOM 操作は、そもそも対象の要素が DOM に読み込まれていないと実行できません。ブラウザは HTML を上から順番に読んでいくので、以下の例のように、記載箇所によっては、コードが思うように動きません。

<body>
  <script>
    // このスクリプトが実行される段階ではボタンは読み込み前なので、
    // 意図通りに要素を取得することはできない
    const btn = document.querySelector('#the-button');
  </script>

  <button id="the-button">Hello</button>
</body>

そのため、ページ読み込みイベントを使って、「ページが読み込まれたら、この処理を実行する」というコードを記述します。

ページが読み込まれる仕組み

前提知識として、ページが読み込まれる仕組みを簡単に押さえておきましょう。

  1. ブラウザから Web サーバーに、特定のページに対するリクエストを送信すると、まず HTML ファイルがレスポンスとして返ってきます。
  2. ブラウザはまずその HTML ファイルを DOM のなかに読み込んでいきます。
  3. 上から順に読み込みながら、<link><img> など、さらに別のリソースが指定された要素を見つけると、対象のサーバにリクエストを発行します。
  4. 必要なリソースが返ってくると、CSS や画像など、それぞれ処理します。

JavaScript では、次の2種類のイベントが用意されています。

  • HTML の内容だけは DOM に読み込まれたとき
  • 依存リソースまですべて読み込んでページが完成したとき

DOMContentLoaded

document オブジェクトの DOMContentLoaded イベントは、「HTML の内容だけは DOM に読み込まれたとき」に相当します。

document.addEventListener('DOMContentLoaded', function() {
  // ここに書かれた処理は、HTML の読み込み後に実行される
});

このタイミングでは、DOM の取得が可能になっています。たいていの場合は、このイベントを用いれば OK でしょう。

ただし、まだ画像などが読み込み終わっていないので、たとえば、特定の画像の高さを測りたいとか、要素に適用されているスタイルを調べたいなどの処理はここでは実行できません。(そういった処理はレアケースだと思いますけどね。)

ちなみに、単純ですが、以下のように、<body> 内の一番下にスクリプトを記載しても同じ効果が得られます。上から順に読み込みが行われるためです。

<body>
  <button id="the-button">Hello</button>

  <script>
    const btn = document.querySelector('#the-button');
  </script>
</body>

load

window オブジェクトの load イベントは、「依存リソースまですべて読み込んでページが完成したとき」に相当します。

window.addEventListener('load', function() {
  // ここに書かれた処理は、ページ全体の読み込み後に実行される
});

window オブジェクトとは、ページを表す document オブジェクトよりも上位の存在で、ページが表示されているブラウザのウィンドウ(タブ)を表しています。

2種類のイベントの使い分けですが、基本的には DOMContentLoaded を使えばよいです。なるべく早いタイミングで JavaScript を読み込ませたほうが効率的だからです。

DOMContentLoaded ではうまくいかないケースがあれば、load を使いましょう。

読み込み状況を調べる

document オブジェクトの readyState プロパティで、ページの読み込み状況を確認できます。利用頻度は低いと思いますが、一応書いておきます。

意味
loading 読み込み中です。
interactive HTML の読み込みが完了した。上記 DOMContentLoaded と同じ。
complete ページが完成した。上記 load と同じ。
const something = () => { /* ... */ };

if (document.readyState === 'loading') {
  // ページが読み込み中なら、イベント登録する
  document.addEventListener('DOMContentLoaded', something);
} else {
  // もう読み込まれているなら、それを待っても実行されないので、すぐに実行する
  something();
}

ページを離れるとき

読み込みとは逆に、ページを離れるときのイベントも用意されています。beforeunload イベントは、ページ遷移する直前に呼び出されます。

よく使われるのは、以下のパターンでしょう。

window.addEventListener('beforeunload', function(event) {
  event.preventDefault();
  event.returnValue = ''; // Chrome ではこの記述も必要
});

このスクリプトは、ページを離れる際に確認ダイアログを表示させます。

ちなみにこのダイアログのメッセージを変更することはできません。ならば独自のモーダルを出したいと思うかもしれませんが、このタイミングではそれもできません。

カスタムイベント

イベントは、あらかじめ用意されているものだけではなく、自分で作ることもできます。応用的な話題ですが、以下の記事にカスタムイベントについては書いているので、興味があれば読んでみてください。

JavaScript中級編!?カスタムイベントを使おう

スクロールやリサイズのテクニック

最後に、scrollresize イベントを扱う際に使えるテクニックを紹介…しようと思いましたが、他にそのテクニックについて書いた記事がとてもたくさんあったので、ここでは触りだけ書いておきます。

どういうことかというと、scrollresize イベントにハンドラーを登録すると、そのハンドラーは、スクロール(またはウィンドウのリサイズ)している間じゅう、連続して実行され続けます。これではブラウザに負荷がかかってしまいます。

そもそも上記の処理を書くときは、「スクロールされている間」ではなく、「スクロールが終わったあと」に何かしたい場合が多いと思います。しかし、ブラウザからすると、スクロールが「終わった」かどうかの判断ができません。(スクロールして、10秒止めて、またスクロールしたら、どこで終わったとみなすべきか、機械的な基準がない。)

そのような場合に、プログラムで疑似的に終了を判断してハンドラーの実行回数を減らすテクニックがあります。具体的な内容は、「JavaScript スクロール イベント 減らす」「JavaScript スクロール イベント 終わり」などで検索してみましょう。「スクロール」を「リサイズ」に変えても同様の方法が出てきます。


以上、本記事では、JavaScript の超基礎講座として、イベント処理の実装方法を見ていきました。

ここで出てきたキーワードに関しては、ぜひ自分でも調べてみてください。初心者の方は、書籍や、最近だと動画も含めて、いろいろな説明を吸収して、総合的に解釈するのが良いと思います。もちろん、手を動かすのも大切です!