俺式、js開発で陥りがちなコールバック地獄にならない法

MyLoader のサンプルに誤りがあったため修正しました。(2016/10/21)

ダラダラ書いていますが要約すると…

  1. 非同期な API をイベントターゲット風に加工する
  2. イベントリスナの登録の際にコールバックの this コンテキストを指定する仕組みを用意する
  3. シングルトンでは js の欠点が露呈しにくい。クラス(風)の中で非同期処理を書いて検討する

コールバック地獄怖い

コールバック地獄には一度痛い目に遭ったことがあります…あとを引き取ったコードにはコールバックの深い深い入れ子が…。

その時に jQuery.Deferred なども見てみましたが、クロージャを完全に排除できていなかったため関心を持てませんでした。

jQuery.Deferred での非同期な関数の加工

mixpanel という web 解析サービスにデータを投げその完了を受け取る。これを jQuery.Deferred のスタイルで加工する

var dfdMixpanel = function (event, props) {
  var dfd = jQuery.Deferred();
  mixpanel.track(event, props, function () {
    dfd.resolve();
  });
  return dfd.promise();
};

jQuery.Deferred にしろその他のライブラリにしろ、結局のところ非同期な処理を加工して使用しています。僕はおなじ加工するのならイベントターゲット風に加工してしまうことを好みます。

イベントターゲット風に加工した上で、イベントリスナの登録の際にコールバックの this コンテキストを指定する仕組みを用意する。これが僕のコールバック地獄の回避法になります。

複雑な処理も綺麗に書けて、精神衛生上とてもよろしい開発ができています。

先駆例

先の仕組みの先駆例として、有名どころでは Closure Library があります。

次に引用した記事が Closure Library での this コンテキスト付きイベントリスナ登録について分かりやすく解説してくださいっています。

このような先駆例がありながら、未だに多くのコードで醜いクロージャの入れ子が見られるのはどうしてなんでしょう…

僕がクロージャの入れ子を追えるのは、バリバリ開発していて脳汁が出ているときだけです。

イベントをスコープ付きで割り当てる

// jQuery
$(elem).on('click', $.proxy(func, scope));

// Closure Library
goog.events.listen(
    elem,
    goog.events.EventType.CLICK,
    func,
    scope
);

クロージャが共犯という話

クロージャはコールバック地獄の共犯者です。

このような認識から極度にクロージャを避けるコーディングスタイルに辿りつきました。クロージャを使っているのはどうしても避けられないフレームワークのコアの一部分などに限られます。

また、クロージャには関数スコープ分のコストが掛かります。その上どのタイミングで破棄されているのか?分かり辛いのが嫌いです。

そんなクロージャは私感ですが、インスタンスなどと比べあまり意識されること無く多用されているように思います。

次の記事はそんなクロージャの仕組みとコストについて判りやすく解説しています。ぜひ目を通しておきましょう。

アクティベーションオブジェクトとは関数のコールが発生した際に、自動的に生成されるオブジェクトです。アクティベーションオブジェクトには、引数、ローカル変数だけでなく、argumentsオブジェクト、thisが格納されます。

アクティベーションオブジェクトはGCの対象となっているので、通常は関数の実行終了時にメモリ上から解放されます。ただし、対象の関数がメモリ上に生存している間は保持され続けます。(略)

この性質を利用して、スコープチェーンによりローカル変数の値を参照し続けるデータ構造を、クロージャと呼びます。

なぜコールバック地獄になるか?

なぜコールバック地獄が起きるのか?これには javascript とブラウザ API に夫々次の問題があるように思います。

  1. インスタンスでなく DOM などが this として返るから
  2. DOM を拡張してメソッドやオブジェクトを生やすのはタブー
  3. コールバックの指定の仕方が複数ある

1.DOM が this としてかえる

prototype 継承を活かし this コンテキストをコントロールするのがハイパフォーマンスな js 開発の肝です。

しかし、DOM からのコールバックでは this コンテキストが変わってしまっているので(HTMLElement や window 等)インスタンスのメンバーにアクセスできません。ちなみに ActionScript3 などはコンテキストが失われないため、随分綺麗に開発できますね。

ちなみに、メモリに優しいという点でハイパフォーマンスですが protoype チェーンを辿るので最速というわけではありません。

コンテキストを指定できるようにラップする

僕は HTMLElement はもとよりタイマーや XHR までラップしてそのまま利用することはありません。

これらの登録部分には一貫した方法でコールバック中の this コンテキストを指定する仕組みを入れています。次に(1)タイマーと (2)HTMLElement の例をそれぞれ紹介します。

(1)タイマーの例
// 生jsの場合
var timerID = setTimeout(function(){myCallback.call(myContext)}, 100);

// pettanR (俺々フレームワーク)の場合
var timerID = X.Timer.once(100, myContext, myCallback);

そうそう、あまり意識されることはありませんが、タイマー( setTimeout ) や継承( prototype と __proto__ 周り ) にもしっかりとブラウザ毎の差異があります。

そこで僕は API は必ずラップして触るようにしています。クロスブラウザと将来の変化に対応力がありますが、パフォーマンスとのトレードオフにはなってしまいます。

トレードオフを埋め合わせて余りある気の利いたラップをしたいところです。

(2)HTMLElement の例

#myButton を管理下におくインスタンスのメソッド中で定義している、という想定のため、生 js の例では this を that 名で保持しそれにアクセスできるクロージャを返しています。

こんな入り組んだ function がずらずらっと続くコードの相手をしたことがありましたが、最近はトントそんなこともないので、生 js の方が動くのか?実は自信がないです…

// 生jsの場合
document.getElementById('myButton')
 .addEventListener('click', (function(that){
  return function(){
   myCallback.call(that);
  };
 })(this), false);

// pettanR (俺々フレームワーク)の場合
X('#myButton').listen('click', this, myCallback);
ご参考:handleEvent

this コンテキストを失いたくない場合 addEventListener のコールバックにオブジェクトを渡す、という手もあります。必ず handleEvent 関数にコールバックされるという縛りもありますが、条件が揃えば素直で高速です。

イベントハンドリングに関しては、Function#bind や $.proxy() でゴニョゴニョせず、 handleEvent ベースで思考すると JavaScript をもっと楽しめると思います。

IE以外ならDOM標準の機能を使うため、内部で余計なクロージャを何個も作ったりせずにすみますし、JavaScript に限らず、イベントハンドリング周りはボトルネックになりがちなので、本来はできるだけ軽く仕上げなきゃだめな部分です。

IE以外なら、とありますがこの他にも使用可能なブラウザに制限があります。次の記事で言及されている Safari2 以下に加え、Opera8 以下(または7以下?) と NetFront3.4 でも使えなかったように思います。

Safari 2 では関数オブジェクトしか EventListener として使えませんが、Safari のナイトリービルドでは handleEvent メソッドを持つオブジェクトも EventListener として使えるようです。

2. DOM を拡張してメソッドやオブジェクトを生やすのはタブー

DOM もプロパティを生やすことのできるオブジェクトですので、これを拡張して使っていくのは妥当なことに思えます。

ActionScript3 では Sprite という描画要素クラスを拡張しながら開発しますし。

しかし、DOM に生やしたプロパティへのアクセスは低速です。またメモリリークの懸念もあるため一般に行われていません。

『JavaScript Ninjaの極意』の次の部分は、多分そういうことを述べているんじゃないでしょうか…

それぞれの要素ごとに情報を格納する方が自然だと思われるかもしれないが、データを1個のストアに集めることによって、Internet Explorer で発生する可能性のあるメモリリークを予防できる(IE では、DOM ノードへのクロージャを持っている DOM 要素に関数をアタッチすると、そのページから離れた後でメモリを回収できなくなる可能性があるのだ)。

3.コールバックの指定の仕方が複数ある

これはあまり指摘を見ませんが、非同期な操作に対してコールバックの指定の仕方が 2 ないし 3 通りある、というのもどうかと思っています。

(1)イベントターゲット・スタイル
elm.addEventListener('click', callback, false);
// ie5-8
elm.attachEvent('click', callback);

ちなみにイベントターゲットについては次の MDN の記事を参照

EventTarget is an interface implemented by objects that can receive events and may have listeners for them.

Element, document, and window are the most common event targets, but other objects can be event targets too, for example XMLHttpRequest, AudioNode, AudioContext, and others.

(2)DOM 0 イベント・スタイル
xhr.onreadystatechange = function(){}
(3)イベントターゲットでないもの

終了時や成功時のコールバックと、エラー時のコールバックを渡すアレです。

最初期から存在する setTimeout などはいいとしても、最近のものでは AudioContext.decodeAudioData や window.requestFileSystem などもそうです。

AudioContext.decodeAudioData(audioData, successCallback, errorCallback);

これらイベントターゲットでないものもイベントターゲット風に加工して使用することができます。

ところで、どうしてイベントターゲットでないのでしょうか?誰か教えてください m(__)m

非同期処理が連続する MyLoader クラスを書いてみる

次の例を元に url を与えるとアラートする MyLoader クラス(風)を定義してみます。

pettanR というオレオレフレームワークを使用しています。

url.txtに書かれているURLを取得し、そのURLのリソースの内容を取得し、1秒後にその内容をalertするという例(のための例)を考える。

まずは普通に書いてみる。

var client = new XMLHttpRequest;
client.open('GET', 'url.txt');
client.onload = function (event) {
    var url = client.response;
    client.open('GET', url);
    client.onload = function (event) {
        setTimeout(function () {
            alert(client.response);
        }, 1000);
    };
    client.send(null);
};
client.send(null);

大規模開発ではクラス(風)を定義しながら~ということが多いため、クラス(風)で綺麗に書けるか?検討するのがよいと思います。

ちなみにシングルトンで済むケースでは js の欠点がほとんど顕在しません。その上ずっと簡単に書けると思います。

var MyLoader = X.EventDispatcher.inherits(
 'MyLoader',
 X.Class.NONE,
 {
  url        : '',
  textLoader : null,
  jsonLoader : null,
  result     : '',
  
  Constructor : function( url ){
   this.url = url;
   this.textLoader = X.Net( { xhr : url } )
        .listen( [ X.Event.SUCCESS, X.Event.ERROR ], this, this.handleEvent );
  },
  
  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, this.handleEvent );
     } else {
      this.result = e.responce;
      X.Timer.once( 1000, this, this.doAlert );
     };
     return;
    
    case X.Event.ERROR :
     this.result = e.target === this.textLoader ? 'get text error' : 'get json error';
     X.Timer.once( 1000, this, this.doAlert );
   };
  },
  
  doAlert : function(){
   alert( this.result );
  }
 }
);

元にしたコードから大分長くなってしまっていますが、エラーハンドリングや MSXML(ActiveX 版 XHR) や XDomainRequest へのフォールバックもオレオレ・フレームワーク内できちんとしていますので一旦これで勘弁してください…

var loader = new MyLoader('hoge.txt');