これでできる! クロスブラウザJavaScript入門

第7回JavaScriptとHTMLとDOMの基本#2 イベント編

こんにちは、太田です。前回はJavaScriptからみたHTMLの基本を中心に解説しました。今回はまず、イベントについて解説します。JavaScript、DOMにおいてイベントは極めて重要です。ブラウザ上のJavaScriptでは必ずといってよいほどイベントが絡んでいますし、ウェブアプリケーションをコントロールする根幹的な技術と言えるほどです。

JavaScriptとイベント

ブラウザはscriptタグで指定されたJavaScriptを解釈して実行します。その時、関数などを定義するだけにして、実際にその処理が行われるのはユーザーがボタンをクリックした時や、何かを入力した時など、ユーザーの何らかのアクションに関連付けてJavaScriptを実行させることができます。さらには、ユーザーのアクションだけでなく、ページの読み込みや通信処理の完了後など、ブラウザ上で起こるあらゆるイベントについて処理を行うのがJavaScriptの特徴です。このようにイベントを中心としたプログラミングをイベントドリブン(イベント駆動型)プログラミングと呼びます。特に、ユーザーのアクションに対応して適切な処理することはJavaScriptの醍醐味ともいえます。

さて、イベントを扱うにあたって、大きく2つの方法があります。1つはHTML4.01などで定義されているシンプルでHTMLと直に結びついた方法(便宜的に以降はonclick方式と呼びます)で、もう1つはDOM Level 2 Eventsで定義された高機能で抽象化された方法(DOM方式)です。onclick方式はシンプルで扱いやすい上にそのままで(ほぼ)クロスブラウザに扱えるため便利ですが、シンプルすぎるゆえの問題もあります。一方、DOM方式は前回も少し書いたように、IEがDOM Level 2相当のAPIを独自仕様で実装しているためクロスブラウザ対応に手間がかかります。使用頻度の高いイベント周りの処理をIEの独自仕様に対応しなければいけない点がクロスブラウザの難易度を大きく引き上げているという面があり、逆に言えばこのイベント周りをしっかり押さえておけばクロスブラウザが一気に簡単になります。

まずはonclick方式を簡単に解説します。onclick方式の最大の特徴はHTML中に書くことができるという点です。

HTMLタグでのonclick方式
<body onload="init();">
<input id="url-alert" type="button" onclick="alert(location.href);" value="URLをアラート">
</body>

見た目のままの通り、onload(ページ全体の読み込みの完了)でinitという関数が呼ばれ、ボタンをクリックするとURLがアラートで表示されます。また、JavaScriptからonclick、onloadを設定することも可能です。

JavaScriptからのonclick方式
window.onload = function(){
  var ua = document.getElementById('url-alert');
  ua.onclick = function(){
    alert(location.host);
  };
};
function init(){
  alert(location.href);
}

このonclick方式の問題点は、ある要素のあるアクションに対して1つの処理しか登録できないという点です。つまり、上記の2つのコードを組み合わせたとき、HTML上に書かれたonloadとJavaScriptで設定されたonloadが重複しています。どちらが有効になるかはJavaScriptが実行されるタイミング、この場合scriptタグを記述する場所に左右されます。script要素がbodyタグの前(head要素の中)に記述した場合はinit関数が呼ばれ、script要素がbodyタグの中に記述されていた場合はwindow.onloadで定義した処理が実行されます。

そのため、HTMLがシンプルであったり、イベント処理が重複しないように配慮できるのであればonclick方式を使っても問題ありませんが、大きなHTMLであったり、複数人で編集するようなケースではこの方法はおすすめできません。

なおonclickは属性なので、setAttributeメソッドを使って定義することも可能ですが、その場合文字列で関数を定義することになるのでおすすめできません。また、IEのsetAttributeメソッドは node[name] = value と書いた場合とまったく同じ動作をするので、少々厄介です。詳しくはIE の getAttribute / setAttribute: Days on the Moonで説明されていますが、クロスブラウザを考えたらsetAttributeは避けたほうがよいと覚えておくとよいでしょう。

DOM Event

では、続いてDOM仕様に沿ったイベント処理(の登録周り)を解説します。⁠既に連載の中では登場していますが)DOM Level 2 Events(とDOM Level 3 Eventsではイベント登録用メソッドとしてaddEventListener(IE独自仕様ではattachEvent)が定義されています。登録した処理を削除するのはremoveEventListener(同じくIEはdetachEvent)です。なお、IEとの互換性のために、OperaなどもattachEventを実装しています。そのため、両方のメソッドを使うと2回呼ばれてしまうので注意が必要です。

※公開当初、Safari、ChromeもattachEventをサポートしていると記述していましたが、これは誤りでした。お詫びして訂正いたします。

addEventListenerはElementのメソッドとして定義(Element.prototype.addEventListener)されており、すべてのHTML要素はElementを継承しています。このようにDOM仕様には継承という概念がはっきりと存在しています。JavaもDOMをサポートしているように、DOM周りは比較的仕様が厳密に定義されています。

addEventListenerの第3引数

addEventListenerには3つ引数がありますが、attachEventは2つしかありません。そのため、この3つ目の引数はクロスブラウザにおいてはfalseにして置くとだけ覚えておけばとりあえずは十分です。

ただ、折角なのでもう少し詳しいところも解説します。まず、HTMLはhtmlタグを最上位として、その中にheadタグ、bodyタグを持ち、さらにその中に……、というように階層的な構造をしています。より正確にはツリー構造と言われています。またツリー構造をした要素の集合を文書木と表現することもあります。あるウェブページで適当な要素をピックアップしてみると、その要素は必ずそのページを構成する文書木に属しており、すなわち親要素、祖先要素を持っています。

さて、あるボタンをクリックした時、そのボタンが含まれるdiv要素だったり、form要素だったり、さらにはbody要素、html要素まで、ボタンを含む(階層的に上にある)要素もクリックされた要素として認識されます。これはHTMLが階層構造をしているため必然的にそうなります。そのボタンがhtml, body, form, divなどの各要素という「箱」でラッピングされており、そのボタンに触れるためにはすべての「箱」を開封することをイメージしてください。ここで、イベントには「箱」を開いていくプロセスと、箱を閉じていくプロセスがあります。箱を開いていくプロセスをキャプチャリングフェーズ、箱を閉じていくプロセスをバブリングフェーズと呼びます。

図1 HTMLの階層構造とイベントの伝播
図1 HTMLの階層構造とイベントの伝播

このどちらのプロセスを見るのかを決定するのがaddEventListenerの第3引数です。仮に、イベントの伝搬を止めたい場合、上位のDOMノードのキャプチャリングフェーズでイベントを止めれば、より早くイベントを止めることが可能となります。ただし、前述の通りIEのattachEventはバブリングフェーズでしかイベントを扱えないため、addEventListenerの第3引数を使うケースは多くありません。

さて、addEventListenerといえば既にaddEvent関数をなんども取り上げていますね。改めて、addEvent関数を見てみましょう。

イベント登録関数
var addEvent = (function(){
  if(document.addEventListener) {
    return function(node,type,handler){
      node.addEventListener(type,handler,false);
    };
  } else if (document.attachEvent) {
    return function(node,type,handler){
      node.attachEvent('on' + type, function(evt){
        handler.call(node, evt);
      });
    };
  }
})();

このaddEvent関数はイベントはもちろん、高階関数にクロージャも絡んでおり、さらに実装による振り分け、仕様の差を埋めるための処理を入れたりなど、クロスブラウザのエッセンスのほとんどが含まれています。是非、この関数をしっかりと噛み砕いて理解してみてください。理解できたかの確認には誰かに説明してみることを想定してみるとよいかもしれません。

HTMLよるフォーム操作の導入

ここでJavaScriptがよく用いられるフォーム操作の実例(今回はあくまでよくない例なのでご注意を)を少しだけ見てみましょう。まず、あえてレガシーな書き方をしてみます。

レガシーなフォーム処理#1
<html>
<body>
<form name="cartform" onsubmit="return cart_check();">
<input type="checkbox" name="check" value="A">商品A
<input type="submit" value="送信">
</form>
<script>
function cart_check(){
  var form = document.cartform;
  if(!form.check.checked){
    alert('商品がチェックされていません');
    return false;
  }
}
</script>

サンプル1

こちらは購入したい商品にチェックして送信するフォームのサンプルです。フォームを送信しようとしたとき、商品がチェックされているかどうか、チェックしています。もしチェックされていなければcart_check関数はfalseを返し、onsubmitもfalseを返すことになります。イベントとして登録された関数自体がfalseを返すとき、そのイベントをキャンセルすることになっています。従って、フォームの送信がキャンセルされることになります。

しかし、この実装には少々問題があります。form要素をdocument.cartformで、さらにそのフォーム要素からform.checkのようにしてチェックした要素にアクセスしていますが、この方法では上手くアクセスできないケースがあります。具体的にはname属性の値が同じ要素がある時に、少々厄介な挙動をします。

レガシーなフォーム処理#1の失敗ケース
<html>
<body>
<form name="cartform" onsubmit="return cart_check();">
<input type="checkbox" name="check" value="A">商品A
<input type="checkbox" name="check" value="B" >商品B
<input type="submit" value="送信">
</form>
<script>
function cart_check(){
  var form = document.cartform;
  alert(form.check);
  if(!form.check.checked){
    return false;
  }
}
</script>

サンプル1-2

form.checkは同じnameを持つ要素があると要素配列(HTMLCollection)になり、1つだけの時は要素自身になります。この動作はバグの温床となります。そのためこの方法は避けるべきですが、あえてこの方針のまま修正すると次のようになります。

レガシーなフォーム処理#1の修正
function cart_check(){
  var form = document.cartform;
  var check = false;
  if(form.check.length > 1){
    for(var i = 0; i < form.check.length;i++){
      if (form.check[i].checked){
        check = true;
        break;
      }
    }
  } else {
    check = form.check.checked;
  }
  if(!check){
    alert('商品がチェックされていません');
    return false;
  }
}

このようにname属性が重複したときの処理が煩雑になってしまいます。こういったケースではdocument.getElementsByNameを使うことで少しすっきりさせることができます。

レガシーなフォーム処理#1-3
function cart_check(form){
  var check = false;
  var checks = document.getElementsByName('check');
  for(var i = 0; i < checks.length;i++){
    var checkbox = checks[i];
    if (checkbox.form == form && checkbox.checked){
      check = true;
      break;
    }
  }
  if(!check){
    alert('商品がチェックされていません');
    return false;
  }
}

サンプル1-3

もしくは、getElementsTagNameとname属性のチェックを組み合わせる方法もあります。

レガシーなフォーム処理#1-4
function cart_check(){
  var check = false;
  var form = document.getElementsByName('cartform')[0];
  var inputs = form.getElementsByTagName('input');
  for(var i = 0; i < inputs.length;i++){
    var input = inputs[i];
    if (input.name === 'check' && input.checked){
      check = true;
      break;
    }
  }
  if(!check){
    alert('商品がチェックされていません');
    return false;
  }
}

document.getElementsByNameはform要素が複数あるときに確認したい親フォームではないフォームに含まれる要素を含んでしまう可能性があるので、form属性をチェックしています。一方、form.getElementsByTagNameでは、親フォームが複数あっても動作しますが、今度はname属性をチェックする必要が出てきてしまいます。

上記ではあえてレガシーな書き方を選んでおり、クロスブラウザで動作はしますが将来的に動くかどうか、新しいブラウザで動くかどうかは少々不安があります。これをなるべくモダンな書き方に直してみましょう。

フォーム処理#2
<!doctype html>
<html>
<body>
<form id="cart" name="cartform">
<input type="checkbox" name="check" value="A">商品A
<input type="checkbox" name="check" value="B" >商品B
<input type="submit" value="送信">
</form>
<script>
var cart = document.getElementById('cart');
addEvent(cart,'submit',function(evt){
  var checks = document.getElementsByName('check');
  for(var i = 0; i < checks.length;i++){
    var checkbox = checks[i];
    if (checkbox.form == cart && checkbox.checked){
      check = true;
      break;
    }
  }
  if(!check){
    alert('商品がチェックされていません');
    if (evt.preventDefault) {
      evt.preventDefault();
    } else {
      evt.returnValue = false;
    }
  }
});
</script>

サンプル2

addEvent関数の定義を含めると少々コードが増えています。しかも、submitを制御していることがHTML上から確認できず、どこで定義されているのかも追いにくくなってしまっている面があります。onsubmitについてはHTMLに直接記述することも検討してもよいかもしれません(HTMLに直接記述する方法自体はHTML4.01、HTML5で定義されているので仕様的にも問題ありません⁠⁠。端的に言って、フォームが1つ以上チェックされているかどうかという処理はこれ以上簡潔にするのは難しい面があります。ここでjQueryを使って実装してみます。

フォーム処理#3
jQuery(function($){
  $('[name=cartform]').submit(function(evt){
    var check = !($(this).find('input[name="check"]').filter(function(){
      return this.checked;
    }).length);
    if (check){
      alert('商品がチェックされていません');
      return false;
    }
  });
});

サンプル3

かなりシンプルになりました。jQueryではonclick方式と同様にreturn falseでデフォルトイベントをキャンセルできる点がポイントです。

もう一つ、あえて古いブラウザへの対応を考えずに、HTML5、ECMAScript 5とDOM Level 2, 3などに準拠した新しいAPIを使った記述を試してみます。

document.addEventListener('DOMContentLoaded',function(){
  var forms = document.querySelectorAll('form[name=cartform]');
  Array.prototype.forEach.call(forms, formcheck);
  function formcheck(form){
    form.addEventListener('submit',function(evt){
      var checks = form.querySelectorAll('input[name="check"]');
      if (!Array.prototype.some.call(checks,checked)){
        alert('商品がチェックされていません');
        evt.preventDefault();
      }
    },false);
  }
  function checked(checkbox){
    return checkbox.checked;
  }
},false);

まず、querySelectorAllはHTML5で定義されるCSSのセレクタと同じルールで要素を取得できる便利なメソッドです。こちらはIE 8, Firefox 3, Safari 3.1, Chrome 1, Opera 10.0などがサポートしています。Array.prototype.forEachはECMAScript 5で追加された配列を走査するメソッドで、IE以外のブラウザは既にサポートしています。Array.prototype.someも同じくECMAScript 5で追加されたメソッドで、配列を走査して、最初にtrueを返したところで走査を中断してtrueを返し、1つもtrueを返さなかった場合はfalseを返すというメソッドです。やはり、IE以外のブラウザは既にサポートしています。2010年6月現在にリリースされているInternet Explorer Platform Previewでは、DOMContentLoadedとforEach, someをサポートしていないため動きませんが、将来的にはIEでも動くようになると期待できます。

イベントとinnerHTML

onclick方式でHTMLタグにイベントを記述した場合、そのタグがinnerHTMLなどで書き換えられたとしてもイベントが残るというメリットがあります。逆に、DOM方式はHTMLを書き換えた際にイベントが消えてしまうというリスクがあります。

イベント登録とinnerHTML
<div id="Parent">
<input id="url-alert" type="button" onclick="alert(location.href);" value="URLをアラート">
</div>
<script>
var Parent = document.getElementById('Parent');
var ua = document.getElementById('url-alert');
ua.onclick = function(){
  alert(location.protocol);
};
Parent.innerHTML = Parent.innerHTML.replace('href','pathname');
var new_ua = document.getElementById('url-alert');
alert([new_ua == ua, ua, new_ua]);
// false,[object HTMLInputElement],[object HTMLInputElement]
</script>

このサンプルではボタンにonclickイベントを2つの方法で登録しています。innerHTMLがなければprotocolがアラートされますが、innerHTMLによってJavaScriptで設定したイベントは消えてしまいます。これはイベントを設定した変数uaが参照している要素と、innerHTMLで書き換えられて作られた要素が別の要素として認識されるためです。また、変数uaが参照している要素はDOMツリーから切り離されていますが、変数としては参照されているのでガベージコレクションの対象とならず、場合によってはメモリーリークを起こすこともあります。このように、innerHTMLによる書き換えは影響範囲が大きいため慎重に行う必要があります。

まとめ

今回はJavaScriptでのイベント処理を中心に解説しました。次回は引き続きイベントとDOMについて実用的なコードを解説していきたいと思います。

おすすめ記事

記事・ニュース一覧