JS_Randezvousを高速化した

cho45さんがJSDeferredを高速化した手法を少し改良して、JS_Randezvousも高速化してみた。

具体的には、キューイングが遅いsetTimeout(引数に0を渡しても大分待たされる)の代わりに、Imageオブジェクトのイベントを使う。

Imageオブジェクトの本来の用途は画像のプリフェッチだと思われる。srcプロパティを設定すると、オブジェクトをdocumentオブジェクトに追加しなくても自動で読み込み処理が開始されるため、documentオブジェクトに余計なノードを追加しなくても非同期なイベント(loaderror)を発生させることが出来る。読み込む画像ファイルのURLを空のdataスキームにすると、少なくとも現行のブラウザではsetTimeoutより数倍速く非同期処理を行うことが出来る。

src属性に空のdataスキームを渡すと、大体のブラウザは画像ファイルを開くことが出来ないと判断してerrorイベントを発生させるようだ。ただし、例えばブラウザが画像を読み込まない設定になっている場合などは、src属性の値に関係なくloadイベントを発生させることもあるらしい。手元で確認した範囲では、少なくともSafariはそのように動作する。JSDeferredの実装ではloaderrorの両方をaddEventListenerすることでこの問題を解決している。

ところで、dataスキームに対応していないIE6などではこの手法を利用することが出来ない。そこで、src属性を空のjavascriptスキームにしたscript要素をdocumentオブジェクトに追加することで非同期イベントを(setTimeoutに比べれば)高速に発生させている。イベントの発生はonreadystatechangeに登録されたハンドラで取得できる。ただし、このイベントは非同期ではあるものの、UIスレッドにスイッチする合間を持たないようなので、実際にはsetTimeoutと組み合わせて利用しないと、UIが更新されない。

さて、問題はこれらの手法をどう切り替えるかである。JSDeferredでは簡単なブラウザ判定を行って実行時に切り替えるような実装になっている。がしかし、この方法は若干怪しいような気もする。ブラウザの設定次第では正しく動作しないかもしれないので(UserAgent偽装など)、JS_Randezvousでは実際にイベントを発生させて確認するように改善してみた。

// 1度全部の方式をメソッドとして定義する。
Task.Statement.prototype.__execEnterDefault =  function(task, args) {
    setTimeout(this.__body(this, task, args), 0);
};

Task.Statement.prototype.__execEnterImageError = function(task, args) {
    var img = new Image();
    var that = this;
    img.addEventListener("error", this.__body(this, task, args), false);
    img.src= "data:,=";
};

Task.Statement.prototype.__execEnterImageLoad = function(task, args) {
    var img = new Image();
    var that = this;
    img.addEventListener("load", this.__body(this, task, args), false);
    img.src= "data:,=";
};

Task.Statement.prototype.__execEnterOnreadystatechange = function(task, args) {
    var now = new Date();
    var handler = this.__body(this, task, args);
    if (now - task.__prevOnreadystatechange < 200) {
       var script = document.createElement("script");
        script.type = "text/javascript";
        script.src  = "javascript:";
        script.onreadystatechange = handler;
    } else {
        setTimeout(handler, 0);
        task.__prevOnreadystatechange = now;
    }
};

// とりあえず、デフォルトを初期値として採用
Task.Statement.prototype.__execEnter =
    Task.Statement.prototype.__execEnterDefault;

// 実際にイベントを取得できるようならば切り替える。
try  {
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.src  = "javascript:";
    script.onreadystatechange = function () {
        if (Task.Statement.prototype.__execEnter
            != Task.Statement.prototype.__execEnterDefault) return;
        Task.Statement.prototype.__execEnter =
            Task.Statement.prototype.__execEnterOnreadystatechange;
    };
} catch (e) {
}
try {
    var img = new Image();
    img.addEventListener(
        "error",
        function() {
            if (Task.Statement.prototype.__execEnter
                != Task.Statement.prototype.__execEnterDefault) return;
            Task.Statement.prototype.__execEnter =
                Task.Statement.prototype.__execEnterImageError;
        },
        false
    );
    img.addEventListener(
        "load",
        function() {
            if (Task.Statement.prototype.__execEnter
                != Task.Statement.prototype.__execEnterDefault) return;
            Task.Statement.prototype.__execEnter =
                Task.Statement.prototype.__execEnterImageLoad;
        },
        false
    );
    img.src= "data:,=";
} catch(e) {
}

この方法ならば、若干コードが冗長になるにせよ、イベントが上手く発生しなくて困ることはないだろう。JSDeferredと異なり、errorloadでメソッドを分けているのは念のためなので、ここまで細かく分ける必要はないかもしれない。なお、切り替え自体が非同期に行われるため、タイミングによっては初回だけデフォルトのsetTimeout版が使われてしまうことも考えられる。しかし、次移行は高速版に切り替わっているはずなので、実際には問題はないはずだ。

なお、間に挟まっていたゆのさんはcho45さんの元に残してきたので安心である。