ぺったんRフレームワークのコールバックのお作法

pettanR ライブラリへの修正をコミットしました。(2016-10-21)


MyLoader のサンプルを動かしたところバグとタイプミスを発見したため修正いたしました。また pettanR ライブラリ本体にも修正が入りましたが現時点で OSDN には未コミットです…(2016-10-19)

本記事はほぼぺったんR フレームワークに固有の内容になります。一般的な事柄ではありません、悪しからず。

だらだら書きましたが、まとめると次の表です。

コールバック第2引数第3引数第4引数
this+関数objectfunction
btn.listen('click', this, callback); //登録
callback.call(this);//発火
this+関数+付加引数objectfunctionarray
btn.listen('click', this, callback, ['user', 1]);//登録
callback.apply(this, ['user', 1]);//発火
thisobject
btn.listen('click', this);//登録
this.handleEvent();//発火
this+付加引数objectarray
btn.listen('click', this, ['user', 1]);//登録
this.handleEvent.apply(this, ['user', 1]);//発火
--
btn.listen('click');//登録
btn.handleEvent();//発火
付加引数
array
btn.listen('click', callback, ['user', 1]);//登録
btn.handleEvent.apply(btn, ['user', 1]);//発火
関数function
btn.listen('click', callback);//登録
callback.call(btn);//発火
関数+付加引数functionarray
btn.listen('click', callback, ['user', 1]);//登録
callback.apply(btn, ['user', 1]);//発火

this のコントロール手段提供を全ての非同期 API に徹底

一般に js 開発ではメモリ効率の良い prototype 継承を多用します。その際に重要になるのがコールバック時の this コンテキストのコントロールです。

ぺったんR フレームワークではコールバックの登録の際に this コンテキストを指定できる仕組みを全ての非同期 API に徹底しています。

僕はこの特徴がぺったんR フレームワーク上で作られたコードに独特なアトモスフィアを与えているように思いますが…

さて、今回はそんなぺったんR フレームワークのコールバック登録作法のご紹介です。

非同期動作の最中にインスタンスを破棄してみる

本題の前に以前にご紹介したサンプルですが、実は一か所で手抜きをしています。

ぺったんR フレームワークの X.Class で作られたクラスは kill メソッドを持ちます。(this.kill() の働きは名前の通りインスタンスの破棄です)

この kill が一連の非同期動作の途中で呼ばれた場合の処理を追加します。処理を入れない場合…

  1. 通信はキャンセルされず無用なアクセスが発生してしまいます
  2. その上、通信(X.Net)へのイベントリスナは解除されていないため破棄された MyLoader インスタンスにコールバックまでされてしまいます
  3. また、フェイズが“タイマーで1秒後にアラートする”に移っていた場合はタイマーの解除も必要です

破棄(kill)時の通信・タイマー解放処理を追加した非同期サンプル

var MyLoader = X.EventDispatcher.inherits('MyLoader', X.Class.NONE, {
 textLoader : null,
 jsonLoader : null,
 result : '',
 timerID : 0,

 Constructor : function(url) {
  this.textLoader = X.Net({
   xhr : url
  }).listen([X.Event.SUCCESS, X.Event.ERROR], this);
  this.listen(X.Event.KILL_INSTANCE);
 },

 handleEvent : function(e) {
  switch( e.type ) {
   case X.Event.SUCCESS :
    if (e.target === this.textLoader) {
     this.jsonLoader = X.Net({
      xhr : e.response,
      dataType : 'json'
     }).listen([X.Event.SUCCESS, X.Event.ERROR], this);
     delete this.textLoader;
    } else {
     this.result = e.response;
     this.timerID = X.Timer.once(1000, this, this.doAlert);
     delete this.jsonLoader;
    };
    break;
 
   case X.Event.ERROR :
    if (e.target === this.textLoader) {
     this.result = 'get text error';
     delete this.textLoader;
    } else {
     this.result = 'get json error';
     delete this.jsonLoader;
    };
    this.timerID = X.Timer.once(1000, this, this.doAlert);
    break;
    
   case X.Event.KILL_INSTANCE :
    this.textLoader && this.textLoader.kill();
    this.jsonLoader && this.jsonLoader.kill();
    this.timerID && X.Timer.remove(this.timerID);
    break;
  };
 },

 doAlert : function() {
  alert(this.result);
 }
}); 

う~~ん、これは開発用のオプションでいいと思うけど、イベントリスナの登録先を覚えておいて kill 時に解除を忘れたら警告する仕組みが欲しいかも…ちなみに自身に登録されたイベントリスナは kill 時に全て破棄されます。

var loader = new MyLoader('hoge.txt');
// 以下は他のインスタンス等によって通信をキャンセルする場合
loader.kill();
loader = null;

コールバックの登録の仕方

ようやく本題に入ります。

1. this コンテキスト+関数

dispatcher.listen( 'ready', this, this.onready );

これを解除するには、登録時と同じ組み合わせを渡します。

dispatcher.unlisten( 'ready', this, this.onready );

kill 時にも全てのリスナが解除されます。

dispatcher.kill();
コールバックの戻り値

コールバックの戻り値で解除することもできます。

dispatcher.listen( 'ready', this, onReady );

function onReady(){
  return X.Callback.UN_LISTEN; // コールバックの戻り値で解除
}

コールバックの戻り値にはこの他に、X.Callback.STOP_PROPAGATION や X.Callback.PREVENT_DEFAULT などがあります。これらの実態は数値(ビットフラグ)で |(論理和) で並べて複数指定することもできます。

function onReady(e){
  return X.Callback.UN_LISTEN | X.Callback.STOP_PROPAGATION | X.Callback.PREVENT_DEFAULT;
}
listenOnce

一度呼んだら解除するように登録の時点で指定することもできます。

dispatcher.listenOnce( 'ready', this, this.onready );

2. this コンテキスト+関数+追加引数

続いてコールバックに追加の引数(配列)を指定してみます。コールバックのデフォルトの引数の後に追加される点に注意が必要です。

このパターンの解除は面倒です。あらかじめ配列を控えて置いて解除時に併せて指定する必要があります。 配列を与えなかった場合一致するリスナが見つからず解除に失敗します。(dispatcher.kill() で解除するケースでは配列を控える必要はありません。)

var supp = [ 'init', 3 ];
dispatcher.listen( 'ready', this, onReady, supp );

function onReady(e, supp1, supp2){
//
}

dispatcher.unlisten( 'ready', this, onReady, supp );

そうそう、配列ライクなオブジェクトである arguments を誤って与えないように注意しましょう。

大関技として、配列の内容を変えればリスナ登録後にも引数の内容を変えることができます。とはいえ、コードが追いずらくなるので辞めておきましょう。

いずれにせよ配列を控えるのが面倒なうえに、this コンテキストにアクセスできれば十分なケースが多いため、このパターンはフレームワーク内でもあまり使われません。

3. EventListenr オブジェクト

イベントリスナとしてオブジェクトだけを登録することができます。その際は handleEvent という名前のメソッドにコールバックします。

handleEvent メソッドの存在確認はイベントディスパッチ時に行うため次の場合でも動作します。

  1. 登録時点では関数が無い > X.Type.isObject(o) === true であれば登録される。
  2. 途中で handleEvent = null 等になった > コールバックは呼ばれない、呼ばれなくても listenOnce の場合解除される。
  3. handleEvent が別の関数で上書きされた > 上書きされた関数が呼ばれる
dispatcher.listen( 'ready', this, this.handleEvent );
// 上と下は等価です
dispatcher.listen( 'ready', this );

this.handleEvent = function(e){
 switch(e.type){
  case 'ready' :
   //
   break;
  case 'click' :
  case 'complete' :

 };
};

先のような書き方をすると、一つの関数にイベント毎の処理をズラズラと並べることができます。

4. 複数のイベントを一挙に登録

また、同一のイベントリスナで複数のイベントタイプを監視したい場合、イベントタイプを配列で渡すこともできます。

dispatcher.listen( 'ready', this )
 .listen( 'click', this )
 .listen( 'complete', this );
// 上と下は等価です
dispatcher.listen( ['ready', 'click', 'complete'], this );

// 解除も配列で
dispatcher.unlisten( ['ready', 'click', 'complete'], this );

5. 省略形 その1

this を与えなかった場合、コールバックの this はイベントディスパッチャー自身になります。この決まりごとにより次の省略した書き方ができます。

dispatcher.listen( 'ready', dispatcher, onReady );
// 上と下は等価です
dispatcher.listen( 'ready', onReady );

function onReady(e){
// this === dispatcher
};

dispatcher.unlisten( 'ready', onReady ); // 解除

解除の際も同様に省略できます。

6. 省略形 その2

handleEvent をコールバックにする場合も同様に省略が可能です。とてもあっさりしてしまって初見では何が何やら…

dispatcher.listen( 'ready', dispatcher );
// 上と下は等価です
dispatcher.listen( 'ready' );

dispatcher.handleEvent = function(e){
// this === dispatcher
};

dispatcher.unlisten( 'ready' ); // 解除

この書き方が板についてしまったらあなたも立派なぺったんR 使いです、ファイトォ~!

this コンテキスト+メソッド名

この他にメソッド(function)ではなくメソッド名(string)を指定する方法も用意しています。handleEvent と同じく途中で関数が書き換わった場合に新しい関数でコールバックします。

prototype を置き換えていくようなハイパー動的なコーディングスタイルを想定してみましたが… Closure Compiler の ADVANCED_OPTIMIZATIONS と相性が悪そうだしでこの方向は追求しません…

イベントリスナは2重に登録できない

同じ引数でイベントリスナを登録することはできません。2回目以降の登録は無視されます。

dispatcher.listen( 'ready', this, onReady )
  .listen( 'ready', this, onReady ); // 無視

また存在しないイベントリスナの解除は単に無視されます。エラー等はありません。

EventDispatcher(EventTarget)の細かな動作

ところで、EventDispatcher の働きといえばイベントリスナをハッシュや配列に蓄えて dispatch されたらコールバックするだけのものです。

こういうと語るほどのこともないと思われるかもしれません。しかしいざ実装してみるとオヤ?と思う点が多々発生します。

ということで EventDispatcher の細かな動作についてみていきますと…

イベントディスパッチ(発送)中のイベントの登録

ぺったんR では次の挙動を踏襲しています。また浮上フェーズがないため(jQuery でも行っている IE イベントモデルとの互換性のため)ディスパッチ中に追加されたリスナが現在のイベントディスパッチ中に発火することはありません。

MDN > 開発者向けのWeb技術 > Web API インターフェイス > EventTarget > EventTarget.addEventListener イベント発送中のリスナーの追加

EventListener がイベント処理中に EventTarget に追加された場合、それが現在のアクションによって実行されることはありませんが、浮上フェーズのように、後の段階のイベントフローで実行されるかもしれません。

ディスパッチ中に登録されたイベントリスナは一旦登録待ちリスト控えて全てのディスパッチ後に改めて登録しています。ディスパッチ内でディスパッチされることもあるので、現在の EventDispatcher の全てのディスパッチを待ちます…ホラ、結構ややこしいでしょう?

イベントディスパッチ(発送)中のイベントの解除

ぺったんR では次の挙動を踏襲しています。

MDN > 開発者向けのWeb技術 > Web API インターフェイス > EventTarget > EventTarget.removeEventListener 注記

イベントリスナーが イベントを処理中であるイベントターゲットから削除された場合、現在のアクションによってそのイベントリスナーが実行されることはありません。

イベントリスナーは、決して削除された後に実行されることはありません。

イベントターゲット上にある現在のどのイベントリスナーも指定していない引数付きの removeEventListener は、何の効果もありません。

ディスパッチ中に解除されたイベントリスナは解除待ちリストに控えてそのディスパッチ中には呼ばれないようにします。リスナ配列の index がずれると嫌なので、現在の EventDispatcher の全てのディスパッチを待って実際の解除が行われます。

さて、イベントディスパッチ中に同じイベントリスナで登録と解除が行われたらどうすればよいでしょう?解除待ちリストと登録待ちリストを夫々操作して…結構ややこしいです。一度解除されたイベントリスナが解除待ちリストから抜けたことでやっぱり呼ばれるケースも発生します。

頭が少し湯立ちました…

東西 EventDispatcher 比べ

以上を踏まえてここでぺったんR 以外の EventDispatcher を見てみましょう。

今回取り上げますは CreateJS というライブラリの一部になります。ブラウザゲームを作るためのライブラリとして紹介されているのを目にします。ActionScript界の神、gskinner先生が開発されているそうです。

CreateJS の EventDispatcher.js

僅かに400行と少し、コメントがきっちり・たっぷり書かれているのでコードはさらにその半分になります。

ちゃっちゃっと眺めることのできるコード量です。

一方のぺったんR は800行、コメントは少な目…EventDispatcher 相当の機能に加えクロスブラウザで EventTarget オブジェクトを触るメソッドもあるとはいえでかいです…

// p._dispatchEvent = function(eventObj, eventPhase) {
// 略
arr = arr.slice(); // to avoid issues with items being removed or added during the dispatch
for (var i=0; i<l && !eventObj.immediatePropagationStopped; i++) {
 var o = arr[i];
 if (o.handleEvent) { o.handleEvent(eventObj); }
 else { o(eventObj); }
 if (eventObj.removed) {
  this.off(eventObj.type, o, eventPhase==1);
  eventObj.removed = false;
 }
}

さて、イベント発火中のイベントリスナ解除の扱いを見てみますと…解除したイベントもコールバックしています。

速度が身上のゲームライブラリですので些細なことは気にしない感じでしょうか…

10人10EventDispatcher,,, ではでは~☆ミ