作って納得! DOM 2 Events2007年03月23日 22時14分

ブラウザ上でのプログラミングで避けては通れないのがイベント処理。その仕組みは DOM Level 2 Events にて規定されています。しかし、とりあえず addEventListener メソッドを使ってはいるものの、それがどのような意味を持つか詳しくは知らないといったことはありませんか。そこでここでは、DOM 2 Events のイベントモデルを理解し、ブラウザが裏で何をしているのかを把握するために、実際にそのイベントモデルを実装してみることにします。具体的には、仕様書に定められたインターフェースを JavaScript で実装し、それらを組み合わせてイベントの発生をシミュレートしてみます。

Event インターフェース

まずは Event インターフェースを実装してみましょう。これはイベントリスナに引数として渡されるイベントオブジェクトに相当します。なお、以下インターフェースを実装するオブジェクトには、便宜的に "JS" の接頭辞をつけることとします。

function JSEvent() {
  this.type = "";
  this.target = null;
  this.currentTarget = null;
  this.eventPhase = 0;
  this.bubbles = false;
  this.cancelable = false;
  this.timeStamp = new Date();
  this._propagationStopped = false;
  this._defaultPrevented = false;
}

コンストラクタでプロパティを設定します。各プロパティについてはそのつど説明していきますが、ここで設定する値は一時的なものであって特に意味はありません。ただし timeStamp プロパティには現在時刻を表す Date オブジェクトを設定します。このプロパティはイベントオブジェクトが作成された時間を表すものです。また、仕様にはないけれど内部的に使用するプロパティには、アンダースコアから始まる名前をつけています。

JSEvent.prototype.stopPropagation =
function () {
  this._propagationStopped = true;
};

stopPropagation メソッドはその名のとおりイベントの伝播を停止します。イベントの伝播については後で説明します。このメソッドが呼び出されたからといって即何かが起こるわけではありませんが、呼び出されたことを記録しておくために _propagationStopped プロパティを true に設定しておきます。

JSEvent.prototype.preventDefault =
function () {
  if (this.cancelable)
    this._defaultPrevented = true;
};

イベントの中にはそれに関連付けられたデフォルトアクションを持つものがあります。たとえば、リンク上での click イベントは、リンク先のページに移動するというデフォルトアクションを持ちます。preventDefault メソッドはそうしたデフォルトアクションをキャンセルするためのメソッドです。ただし、すべてのイベントでデフォルトアクションをキャンセルできるわけではないので、キャンセル可能なイベントでのみキャンセルすることを記録しておきます。

JSEvent.prototype.initEvent =
function (eventTypeArg, canBubbleArg, cancelableArg) {
  this.type = eventTypeArg;
  this.bubbles = canBubbleArg;
  this.cancelable = cancelableArg;
};

initEvent メソッドはその名のとおりイベントオブジェクトを初期化します。といってもイベントリスナに渡されるイベントオブジェクトはすでに初期化されているので、普段使用することはあまりなかったかと思います。引数に従ってイベントタイプ ("click""load" など)、バブルするかどうか (後述)、キャンセル可能かどうかを設定します。

JSEvent.CAPTURING_PHASE = 1;
JSEvent.AT_TARGET = 2;
JSEvent.BUBBLING_PHASE = 3;

イベントの発生にはキャプチャリングフェーズ、ターゲットフェーズ、バブリングフェーズの 3 つの段階があります。各段階の詳細については後述しますが、Event インターフェースにはこれらイベントフェーズを表す定数があり、eventPhase プロパティの値として使われます。

EventListener インターフェース

EventListener インターフェースを実装するオブジェクトは実際にイベントを受け取り、それに対する処理を行います。JavaScript では関数オブジェクトがこれに相当します。イベントは引数として受け取り、返り値は必要ありません。

EventTarget インターフェース

EventTarget インターフェースはイベントが発生しうる対象を表します。DOM 2 Events では Node インターフェースを実装するオブジェクト、すなわち要素ノードオブジェクトや文書ノードオブジェクト (document) が EventTarget インターフェースも実装することになっています。

function JSEventTarget() {
  this._listeners = {};
}

コンストラクタで _listeners プロパティを初期化します。このプロパティは、このターゲットに登録されたイベントリスナを格納するハッシュテーブルを表します。

JSEventTarget.prototype.addEventListener =
function (type, listener, useCapture) {
  var listeners = this._listeners[type + !!useCapture];
  if (!listeners)
    listeners = this._listeners[type + !!useCapture] = [];

  // Don't register duplicate listeners
  for (var i = 0; i < listeners.length; i++)
    if (listeners[i] == listener)
      return;

  listeners.push(listener);
};

イベントリスナを登録する、おなじみ addEventListener メソッドです。引数 useCapture は、登録するイベントリスナが呼び出されるイベントフェーズを示しています。実装としては、同一のイベントタイプおよびイベントフェーズで呼び出されるリスナを、配列に格納しておきます。これにより、同じイベントタイプに複数のリスナを関連付けることが可能になります。ただし、あるターゲット上で、ひとつのリスナを同じイベントタイプ、イベントフェーズで二重に登録することはできません。

JSEventTarget.prototype.removeEventListener =
function (type, listener, useCapture) {
  var listeners = this._listeners[type + !!useCapture];
  if (!listeners) return;

  for (var i = 0; i < listeners.length; i++) {
    if (listeners[i] == listener) {
      listeners.splice(i, 1);
      return;
    }
  }
};

removeEventListener メソッドはその名のとおりターゲットからリスナを削除します。ターゲット上に、指定されたイベントタイプ、イベントフェーズと関連付けられたリスナが存在しない場合は何もしません。

JSEventTarget.prototype.dispatchEvent =
function (evt) {
  if (!evt.type)
    throw new JSEventException(JSEventException.UNSPECIFIED_EVENT_TYPE_ERR);

  var targets = [this];
  var ancestor = this;
  while ((ancestor = ancestor.parentNode))
    targets.unshift(ancestor);
  if (evt.bubbles)
    targets.push.apply(targets, targets.slice(0, -1).reverse());

  evt.target = this;
  evt.eventPhase = JSEvent.CAPTURING_PHASE;

  for (var i = 0; i < targets.length; i++) {
    var currentTarget = evt.currentTarget = targets[i];
    var isTargetPhase = (currentTarget == evt.target);
    if (isTargetPhase)
      evt.eventPhase = JSEvent.AT_TARGET;

    var isCapturingPhase = (evt.eventPhase == JSEvent.CAPTURING_PHASE);
    var listeners = currentTarget._listeners[evt.type + isCapturingPhase];
    if (listeners) {
      for (var j = 0; j < listeners.length; j++)
        listeners[j].call(currentTarget, evt);

      if (evt._propagationStopped) break;
    }

    if (isTargetPhase)
      evt.eventPhase = JSEvent.BUBBLING_PHASE;
  }

  var defaultPrevented = evt._defaultPrevented;
  evt.target = null;
  evt.currentTarget = null;
  evt.eventPhase = 0;
  evt._propagationStopped = false;
  evt._defaultPrevented = false;

  return !defaultPrevented;
};

dispatchEvent メソッドはイベントを発生させるメソッドです。あまりなじみがないかもしれませんが、これこそ DOM 2 Events の中枢といってもいいでしょう。実装もボリュームが多めになっていますが、順に見ていくことにします。

まずはイベントタイプをチェックします。イベントタイプが設定されていない、すなわちイベントが初期化されていない場合は例外 (後述) を投げます。

次にリスナが呼び出されるターゲットを取得します。先に言ったようにイベントの発生には 3 つの段階があり、最初のキャプチャリングフェーズでは dispatchEvent メソッドが呼び出されたターゲットの祖先ノード、次のターゲットフェーズは dispatchEvent メソッドが呼び出されたターゲット自身、最後のバブリングフェーズでは再び祖先ノードのリスナが呼び出されます。ただし、祖先ノードは複数存在するので、キャプチャリングフェーズではルートノード (ルート要素ノードではなく文書ノード) から直近の親ノードの順、バブリングフェーズではその逆順でリスナを呼び出していきます。このことを指してイベントが伝播していくともいいます。引数として渡されたイベントがバブルしないものだった場合は、バブリングフェーズは存在しません。

イベントオブジェクトの target プロパティには dispatchEvent メソッドが呼び出されたターゲットを指定します。eventPhase プロパティには最初の段階であるキャプチャリングフェーズを表す定数を指定します。

さて、いよいよ実際にリスナを呼び出す場面です。配列に収めたターゲットを順にたどっていき、それぞれのリスナを取得してさらにそれらを順に呼び出していきます。イベントオブジェクトの currentTarget プロパティには現在呼び出すリスナを持つターゲットを、eventPhase プロパティには適切なイベントフェーズを表す定数を指定します。コードを見てもらえばわかるとおり、addEventListener メソッドで引数 useCapturetrue を指定して登録したリスナは、キャプチャリングフェーズでしか呼び出されません。逆に false を指定して登録したリスナは、ターゲットフェーズまたはバブリングフェーズでしか呼び出されなくなります。また、あるターゲットのリスナをすべて呼び出した後に、それらリスナ中でイベントオブジェクトの stopPropagation メソッドが呼び出されたかどうかをチェックします。もし呼び出されていたのならば、イベントの伝播をそこで中止して後処理に移行します。

後処理ではイベントオブジェクトのプロパティを初期値に戻していきます。また、dispatchEvent メソッドは、イベントにデフォルトアクションが関連付けられており、かつそれがキャンセルされた場合は false を、そうでない場合は true を返します。具体的には、preventDefault メソッドによりデフォルトアクションがキャンセルされていれば _defaultPrevented プロパティが true になっているので、その否定値を返してやります。

DocumentEvent インターフェース

DocumentEvent インターフェースはイベントオブジェクトを作成するためのインターフェースです。通常は文書ノード (document) がこのインターフェースを実装しています。

function JSDocumentEvent() {
  this._constructors = {};
}

イベントオブジェクトのコンストラクタを格納するハッシュテーブルを指定します。

JSDocumentEvent.prototype.createEvent =
function (eventType) {
  if (eventType in this._constructors)
    return new this._constructors[eventType]();

  throw new JSDOMException(JSDOMException.NOT_SUPPORTED_ERR);
};

createEvent メソッドでは引数に従ってイベントオブジェクトを作成します。引数 eventType"click""load" といった個々のイベントタイプではなく、"Event""MouseEvent" といったイベントオブジェクトのインターフェースを表す文字列です。指定されたインターフェースのイベントオブジェクトを作成できない場合は例外 (後述) を投げます。

JSDocumentEvent.prototype._registerEvent =
function (eventType, constructor) {
  this._constructors[eventType] = constructor;
};

ここでは、インターフェースを表す文字列と、そのインターフェースを実装するイベントオブジェクトのコンストラクタを登録するために、_registerEvent メソッドを使用することにします。

DOMException インターフェース

function JSDOMException(code) {
  // DOM
  this.code = code;
  // ECMAScript
  this.message = this._messages[code] || "";
}

JSDOMException.NOT_SUPPORTED_ERR = 9;

JSDOMException.prototype = new Error();
JSDOMException.prototype.constructor = JSDOMException;
JSDOMException.prototype.name = "DOMException";
JSDOMException.prototype._messages = {
  9: "Implementation does not support requested operation"
};

DOMException インターフェースは DOM 2 Core で規定されています。ここでは Error オブジェクトを継承し、例外の種類を表す番号とエラーメッセージをプロパティとして持つことにします。本当はもっと多くの例外が規定されているのですが、DOM 2 Events で使われるのは NOT_SUPPORTED_ERR だけなので、それだけを指定しておきます。

EventException インターフェース

function JSEventException(code) {
  JSDOMException.apply(this, arguments);
}

JSEventException.UNSPECIFIED_EVENT_TYPE_ERR = 0;

JSEventException.prototype = new JSDOMException();
JSEventException.prototype.constructor = JSEventException;
JSEventException.prototype.name = "EventException";
JSEventException.prototype._messages = {
  0: "Event's type is not specified"
};

EventException インターフェースは DOMException インターフェースを継承し、イベントにまつわる例外を表します。DOM 2 Events では UNSPECIFIED_EVENT_TYPE_ERR しか規定されていないので、それだけを実装しておきます。

実際に使ってみる (その 1)

基本的なインターフェースの実装が終わったので実際にイベントモデルをシミュレートしてみることにしましょう。まずは JSEventTarget オブジェクトを継承する JSNode オブジェクトを作ります。

function JSNode(name, parent) {
  JSEventTarget.apply(this);
  this.name = name;
  this.parentNode = parent;
}

JSNode.prototype = new JSEventTarget();
JSNode.prototype.constructor = JSNode;

コンストラクタ内で JSEventTarget コンストラクタを適用するのを忘れないようにしてください。

function logEvent(evt) {
  print("type: " + evt.type + ", " +
        "phase: " + evt.eventPhase + ", " +
        "current target: " + evt.currentTarget.name);
}

var parent = new JSNode("parent", null);
var self = new JSNode("self", parent);

parent.addEventListener("foo", logEvent, true);
parent.addEventListener("foo", logEvent, false);
self.addEventListener("foo", logEvent, true);
self.addEventListener("foo", logEvent, false);

とりあえずは親と子を作り、それぞれにイベントリスナを追加します。

var doc = new JSDocumentEvent();
doc._registerEvent("Event", JSEvent);

イベントオブジェクトを作成するための下準備です。イベントタイプ名 "Event"JSEvent オブジェクトを結び付けます。

var evt = doc.createEvent("Event");
evt.initEvent("foo", true, false);
self.dispatchEvent(evt);

そして実際にイベントオブジェクトを作成し初期化、self 上でそのイベントを発生させます。イベントは parent (キャプチャリングフェーズ)、self (ターゲットフェーズ)、parent (バブリングフェーズ) と伝播し、以下のような出力が得られます。

type: foo, phase: 1, current target: parent
type: foo, phase: 2, current target: self
type: foo, phase: 3, current target: parent

実際に使ってみる (その 2)

別の例としてタイマーを作ってみましょう。このタイマーは一定の時間ごとに timer イベントを発生させます。timer イベントはキャンセル可能であり、キャンセルするとそれ以降の timer イベントは発生しません。また、何度イベントが発生したかを示す count プロパティを持ちます。

function TimerEvent() {
  JSEvent.apply(this);
  this.count = 0;
}

TimerEvent.prototype = new JSEvent();
TimerEvent.prototype.constructor = TimerEvent;

TimerEvent.prototype.initTimerEvent =
function (eventTypeArg, canBubbleArg, cancelableArg, countArg) {
  this.initEvent(eventTypeArg, canBubbleArg, cancelableArg);
  this.count = countArg;
};

TimerEvent オブジェクトは JSEvent オブジェクトを継承し、さらに initTimerEvent メソッドを持っています。DOM 2 Events でも、MouseEvent インターフェースには initMouseEvent メソッドといったように、Event インターフェースから派生するインターフェースは、初期化のための専用のメソッドを備えています。

function Timer(interval) {
  JSEventTarget.apply(this);
  var count = 0;
  var self = this;
  setTimeout(function () {
    var evt = self._doc.createEvent("TimerEvent");
    evt.initTimerEvent("timer", true, true, ++count);
    var doesContinue = self.dispatchEvent(evt);
    if (doesContinue)
      setTimeout(arguments.callee, interval);
  }, interval);
}

Timer.prototype = new JSEventTarget();
Timer.prototype.constructor = Timer;

Timer.prototype._doc = new JSDocumentEvent();
Timer.prototype._doc._registerEvent("TimerEvent", TimerEvent);

Timer オブジェクトは指定時間後に timer イベントを発生させます。また、このイベントにはタイマーを続けるというデフォルトアクションがあります。このデフォルトアクションはキャンセル可能なので、dispatchEvent メソッドの返り値が true のときのみ実行することになります。

var timer = new Timer(1000);
timer.addEventListener("timer", function (evt) {
  print(evt.count);
  if (evt.count == 5) evt.preventDefault();
}, false);

この例だと、1 から 5 までの数値が 1 秒ごとに出力されます。

デモ

「実際に使ってみる」その 1 とその 2 をあわせたデモを作りました。実際にイベントが伝播するさまを確かめてください。

まとめ

このように、イベントを発生させるためには、イベントオブジェクトを作成し、それを初期化し、発生源となるターゲットの dispatchEvent メソッドを呼び出してやる必要があります。これは組み込みのイベントでも同じであり、私たちが普段 load イベントや click イベントを受け取っている裏では、ブラウザがこれら一連の操作を行っている (あるいは行ったかのように振舞っている) のです。また、この仕組みがわかっていれば、Firefox のように EventTarget インターフェースを実装した XMLHttpRequest オブジェクトを、ほかのブラウザでも実装するといったこともできるようになります。

この実装の注意点

DOM 2 Events では、現在処理中のターゲットに追加されたリスナは、そのフェーズでは呼び出されないことになっています。また、現在処理中のターゲットから削除されたリスナが呼び出されることもありません。しかし、コードを簡単にするため、この実装ではこれらの動作が起こることを想定していません。

DOM 2 Events ECMAScript Language Binding では、イベントオブジェクトの timeStamp プロパティの型は Date オブジェクトであるとされています。しかし、多くのブラウザではミリ秒数を表す数値型として実装されており、DOM 3 Events 草案でも数値型に変更されました。この実装では DOM 2 Events に従い、Date オブジェクトをその値に使用しています。

Event インターフェースを実装するイベントオブジェクトを作成するために createEvent メソッドに渡す文字列は、DOM 2 Events では "HTMLEvents" ですが、DOM 3 Events 草案では "Event" になっています。ここでは DOM 3 Events 草案に従い、文字列 "Event" を渡すと JSEvent オブジェクトが作成されるようにしました。

DOM 2 Events では、dispatchEvent メソッドの返り値は、preventDefault メソッドが呼び出されていたら false、呼び出されていなかったら true となっています。Opera 9 はこれに従っていますが、Firefox 2 および Safari 2 は、イベントがキャンセル可能でない場合 (そもそもその場合 preventDefault メソッドの呼び出しは意味を持ちませんが)、リスナが preventDefault メソッドを呼び出していても true を返します。ここでは Firefox 2 および Safari 2 に従い、preventDefault メソッドが呼び出されており、かつその呼び出しが意味を持つ場合のみ、dispatchEvent メソッドが false を返すようにしています。

また、各ブラウザの実装との差異については、「DOM Events とブラウザの実装」も参考になるかと思います。

コメント

コメントをどうぞ

※メールアドレスとURLの入力は必須ではありません。 入力されたメールアドレスは記事に反映されず、ブログの管理者のみが参照できます。

※投稿には管理者が設定した質問に答える必要があります。

名前:
メールアドレス:
URL:
次の質問に答えてください:
「ハイパーテキストマークアップ言語」をアルファベット4文字でいうと?

コメント:

トラックバック

このエントリのトラックバックURL: http://nanto.asablo.jp/blog/2007/03/23/1339498/tb