IEなどにおけるXMLHttpRequestで、複数リクエストをawaitするPromise.all的な処理を行いたい。


はじめに

「複数のリクエストを送り、その結果を全部取得できたら次に進みたい」というコードを書く場合、今時はfetchPromise.allを用いるのが一般的かと思われます。

(async()=>{
    var jsons = await Promise.all(
        ['a.json', 'b.json', 'c.json'].map(v=> fetch(v).then(p=>p.json()) )
    );
    /* 全リクエストが処理されてからログ出力 */
    console.log(jsons);
})();

非同期処理を同期処理かの如く書けて、非常に便利ですね。

ですが、かの邪悪なるIEにはfetchasync/awaitはおろか、Promiseすら存在しません。polyfillを使え、と言う方もいらっしゃるでしょうが、それを使えるかどうかは環境やら相手方の要望次第です。そうなってくると、必然的にリクエストにはXMLHttpRequestXHR)を使い、コールバック関数を用意する必要がありますね。ですがXHRではイベントハンドラを書き込まなければならないなど、「一つだけファイルをGETする、一つだけリクエストをPOSTする」ならともかくも、複数リクエストをするにはその数だけイベントハンドラも用意するなど、ややこしいことをしなければなりません。

…「forなりなんなりを使えばいいじゃない」と思った方もいるかもしれませんね。ですが、非同期通信をforで回すのはご法度ですよ。

では、一体どうすればよいのでしょうか。

ダメな例

先に挙げた通り、「forなりなんなり」を使ってみましょう。

function callback(jsons) {
    //jsonsがすべてそろった時に動く関数
}
var jsons = [];
for(var i = 0, u = ['a.json', 'b.json', 'c.json']; i < u.length; i++) {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(e) {
        if(this.status!==200) return;
        jsons.push( JSON.parse(this.responseText) );
        jsons.length==u.length && callback(jsons);
    };
    xhr.open('GET', u);
    xhr.send();
}

パッと見たとき、なんだか上手くいくように見えます。ですがこの時jsonsの中身は

jsonsの中身
[<c.json>, <c.json>, <c.json>]

大変なことになっています。

非同期通信のキューは同期していないため、forが回る度にそれらのキューの終了を待たずに次々と書き変わってしまい、最終的に最後以外の全操作が、最後の「c.jsonの取得」に書き換わってしまうのです。最悪ですね。

…forループそれぞれの中身って同じ名前空間なの?と疑問に思ったそこのあなた。F12でコンソールを開いて以下のコードを書いてみてください。

for(var a of [1,2,3,4]) {
    var n = n || a;
    console.log(n);
}

こんな結果が出力されるはずです。

1
1
1
1

次ループにnが持ち越されているのがわかりますね。

よくある例

再帰関数をfor的に使おうというもの。再帰する毎に名前空間が切り替わるので、forみたいに書き換えは起こらないというわけですね。

function callback(jsons) {
    //jsonsがすべてそろった時に動く関数
}
var jsons = [];
(function req(urls, index=0) {
    var url = urls[index];
    if(!url) return;
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function(e) {
        if(this.status!==200) return;
        jsons.push( JSON.parse(this.responseText) );
        jsons.length==urls.length && callback(jsons);
    };
    xhr.open('GET', url);
    xhr.send();
    req(urls, index + 1);
})(['a.json', 'b.json', 'c.json'])

問題なく動きます。ちなみにですが、このようにリクエスト用関数を用意したうえであれば、ダメな例で挙げたfor文も使えます。

for(うんたらかんたら) {
    req(引数);
}

「関数に引数を渡す」までは同期処理ですし、渡された引数で非同期処理を開始するのはそれぞれ異なる名前空間です。ただまあ、forと関数を併せて書くとなると、コード量が増大します。それをするくらいなら、即時関数を再帰させた方が良いと思います。


…ここまで、というよりは上の「よくある例」を読んで、「気持ち悪いな」と感じた方はいらっしゃいますでしょうか。

私は気持ち悪いと思います。主に二つの理由で。

気持ち悪いと感じる一つ目の理由: 「直感的でない」

一番上に挙げた「今時の例」、fetchPromise.allを使った例を見てください。何をしているのかがハッキリしていますね。各jsonのURLの配列をmapでリクエストに整形し、Promise.allで全ての処理が終わるのを待っています。

ですが「よくある例」はどうでしょう。再帰というものはぱっと見では理解しづらく、そうでなくとも、forのようなループを当然慣れ親しんでいる我々からすれば、非常に迂遠に感じるはずです。「XMLをパースする」みたいな用途というわけでもなく、ただただループさせるためだけに再帰を使っているためです。

今回は単純なコードなのでまだマシですが、そこへリクエストエラーの時にリクエストを再送させる処理やらなんやらを付け足してみると、なんということでしょう、仕様書を読まないと何をしているか分からない、豪華なスパゲッティの出来上がりです。

気持ち悪いと感じる二つ目の理由: 「リザルトの順序がバラバラ」

非同期通信ですから、jsonsにpushされるタイミングはバラバラ、つまるところリザルトの順序もバラバラです。

「通信が終わったものから順次処理していってね」といった趣旨のコードならば、別段何も問題はないでしょう。ですがこれはタイトルにあるように、「複数リクエストをawaitするPromise.all的な処理を行いたい」がためのコードです。畢竟、順序も保証されていてほしいですよね。


では、この「気持ち悪さ」を解決してみましょう。

解決策

  • mapを使おう
    • URLの配列を整形したものなら、リザルトの順番は保証されます。
  • 何を配列とすべきかを理解しよう
    • mapの返り値をresponseTextにしようとすると詰まりますよ。functionの入れ子、しかもイベントハンドラのfunctionの中から外にreturnを飛ばすのは、並大抵のやり口では不可能です。であれば、いっそのことxhrそのものを返してやりましょう。
    • callbackに渡す際には別のmapを使い、xhrのマップされた配列をresponseTextに整形し直しましょう。
function callback(jsons) {
    //jsonsがすべてそろった時に動く関数
}

var urls = ['a.json', 'b.json', 'c.json'],
reqs = urls.map(function(u) {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if(this.status!==200) return;

        var notend = reqs.filter(function(v) {//200 okじゃないpendingのリクエストがあるかfilterで探す(ieにはfindが無い)
            return v.status!==200;
        });

        if(!notend.length) {//200 okじゃないリクエストが無いなら全リクエスト終了
            callback(
                reqs.map(function(v) {//xhr格納配列をresponseTextにそれぞれ整形・提出
                    return JSON.parse(v.responseText);
                })
            );
        }

    };
    xhr.open('GET', u);
    xhr.send();
    return xhr;//xhrを返す
});

読みやすくするために不必要にインデントや変数宣言を増やしていますが、全部まとめれば、「よくある例」と比べてもそこまでコード量に違いはありません。

function callback(jsons) {
    //jsonsがすべてそろった時に動く関数
}

var reqs = ['a.json', 'b.json', 'c.json'].map(function(u) {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
        if(this.status!==200) return;
        reqs.filter(function(v){return v.status!==200;}).length || callback(reqs.map(function(v) {return JSON.parse(v.responseText)}));
    };
    xhr.open('GET', u);
    xhr.send();
    return xhr;
});

何よりcallbackに渡されたJSONの配列は、urlの配列の並び順に準拠したものになります。


ご清覧ありがとうございます。