作って納得! DOM 2 Events ― 2007年03月23日 22時14分
ブラウザ上でのプログラミングで避けては通れないのがイベント処理。その仕組みは DOM Level 2 Events にて規定されています。しかし、とりあえず addEventListener メソッドを使ってはいるものの、それがどのような意味を持つか詳しくは知らないといったことはありませんか。そこでここでは、DOM 2 Events のイベントモデルを理解し、ブラウザが裏で何をしているのかを把握するために、実際にそのイベントモデルを実装してみることにします。具体的には、仕様書に定められたインターフェースを JavaScript で実装し、それらを組み合わせてイベントの発生をシミュレートしてみます。
Event
インターフェースEventListener
インターフェースEventTarget
インターフェースDocumentEvent
インターフェースDOMException
インターフェースEventException
インターフェース- 実際に使ってみる (その 1)
- 実際に使ってみる (その 2)
- デモ
- まとめ
- この実装の注意点
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
メソッドで引数 useCapture
に true
を指定して登録したリスナは、キャプチャリングフェーズでしか呼び出されません。逆に 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: http://nanto.asablo.jp/blog/2007/03/23/1339498/tb
コメントをどうぞ
※メールアドレスとURLの入力は必須ではありません。 入力されたメールアドレスは記事に反映されず、ブログの管理者のみが参照できます。
※投稿には管理者が設定した質問に答える必要があります。