メインコンテンツまでスキップ

fetch API から XMLHttpRequest への置き換えを決意した話

Yamagishi Kazutoshi
Software Engineer

最近 fetch API をヘビーに使うようになっていて、いろいろと勘所もわかってきていて、Promise ベースなのはやっぱりすごく便利なんだけれども、現状だと機能が全然足りないなあ、と。

XMLHttpRequestUpload 相当がないのは知っていたし、困ったなあと思っていたんだけれども、XMLHttpRequestUpload 自体がだいぶレア目のヤツで使うような機会もまあめったにないので実害としてはそこまで大きくなかった。

んで、だ、XMLHttpRequestUpload 相当がないのは良いとしても、ReadableStreamXMLHttpRequest で言う progress イベント相当のことをしようとしたときに、発火時にトータルの容量がわからんつう問題が発生した。

fetch API で ReadableStream を使って progress の状況を取るときは

function consume(reader) {
var total = 0;
return (function pump() {
return reader.read().then(function(args) {
if (!args.done) {
total += args.value.byteLength;
return pump();
}
});
})();
};

fetch('/path/to').then(function(response) {
var reader = response.body.getReader();
return consume(reader);
});

といったコードを使う。データを受信するごとに total 変数に、受信したデータのバイト数を追加していくことしかできない。

forbidden headers の中に Content-Length があるからしゃあないちゃしゃあないような気もするんだけれども、どうしたもんかまじでわからん。

トータルの容量がわからんとなにが困るって、進捗の表示ができないんだよ。

xhr.onprogress = function(e) {
result.textContent = e.loaded / e.total;
};

ていうので XMLHttpRequest だったら一瞬で進捗状況を表示させられていたけど、fetch API だとできない。いや仕様的には [[totalQueuedBytes]] つうのがあるんだけれども internal slot 扱いで JavaScript から取ることはできない。

これ、誰も困っていないのかな。困るよなあ。困るよなあ。どうすんのまじでコレ。

っていうのと今日実装している最中であーってなったのは XMLHttpRequest.abort() 相当のものがないってこと。まじでどうすんだよこれ!!! 今週の土日で今書いているプロダクトで fetch API を使っている箇所を XMLHttpRequest に置き換えるわ。やっぱり世界に fetch API は早かった。XMLHttpRequest ですよ、やっぱり。

XMLHttpRequest 最高!!!

……

という話でおわるのも酷いので補足をしますが、ちゃんと要所を抑えて使うと fetch API は非常に便利です。

Promise ベースというのはイベントベースの API よりもはるかに記述が単純で済みます。コードが煩雑になってしまうのを防ぎますし、この記事を書いている 2016 年 7 月時点では stage 3 の Async Functions (ES 2017 にはいるとうれしいですね) といっしょに使うことによって、コールバックファンクションばかりになってしまうということも避けられます。

今後も Promise ベースの流れが続くのが幸いだと思いつつも、現状の fetch API は「つらいなあ」というお話です。そもそも fetch API は XMLHttpRequest の代替というわけではないので、お門違いも甚しいんですけどね。

fetch API は Living Standard です。今後も改良が続けられていきます。この記事で上げている XMLHttpRequest との差異もいづれ埋められて行くでしょう。わたしはこの記事が陳腐化してしまうことを切に祈っています。

世界がもっと平和になりますように。

2016 年 7 月 21 日 20 時 30 分 追記

Jxck さんのご指摘を受けて再調査したところ、Content-Length ヘッダーの取得が行えました。forbidden headers というのはあくまで HTTP リクエスト送信時のみで、HTTP レスポンスを受ける際には関係ありませんでした。

通信の進捗状況を得つつ、かつレスポンスの値を得る場合には

function open(blob) {
return new Promise(function(resolve, reject) {
var fileReader = new FileReader();
fileReader.addEventListener('load', function() {
resolve(this.result);
});
fileReader.addEventListener('error', function() {
reject(this.error);
});
fileReader.readAsText(blob);
});
}

fetch('/object.json').then(function(response) {
var reader = response.body.getReader();
var type = response.headers.get('content-type') || 'text/plain';
var total = +(response.headers.get('content-length') || 0);
var loaded = 0;
var body = new Uint8Array(total);
return (function pump() {
return reader.read().then(function(args) {
var newBody;
if (args.done) {
return new Blob([body], { type: type });
}
if (total < 0) {
body.set(args.value, loaded);
} else {
newBody = new Uint8Array(body.byteLength + args.value.byteLength);
newBody.set(body);
newBody.set(args.value, body.byteLength);
body = newBody;
}
loaded += args.value.byteLength;
console.log('loaded: ' + loaded + (total < 0 ? ' (' + Math.floor(loaded / total * 1000) / 10 + '%)' : ''));
return pump();
});
})();
}).then(function(blob) {
return open(blob).then(function(text) {
return JSON.parse(text);
});
}).then(function(object) {
console.log(object);
});

といった記述になります。

Streams の仕様についてまだ理解が浅いため、もう少しスマートな書きかたはあるかとは思いますが、わたしが目的としていたことは実現できました。

また XMLHttpRequest.prototype.abort に関しても、ReadableStream.prototype.cancel で実現できそうです。仕様としてはストリームのキャンセルとともに HTTP リクエスト自体も止めると規定されているため、XMLHttpRequest.prototype.abort 相当のことはできそうです。

ただし実際に Google Chrome 52 で試してみたところストリームの読み取り自体は止まっているものの、HTTP リクエスト自体は止まっていないのではないか? という疑念があるのでもう少し調査する必要がありそうです。

なお response.body.getReader() は現状 Google Chrome 以外では実装されていない (MS Edge では使えるかも。未確認) ため、実用に耐えるかと言われたら疑問が残るかもしれません。

しかし、Fetch API も Streams もどちらも今なお活発に仕様の策定が進んでいます。それにともなって実装も広く進められていくことでしょう。

世界の平和は近い。

また、この追記は Jxck さんのお力によるものが大きいです。仕様に対して曖昧な理解のままでいたわたしに対して、正しい情報の教示をしてくださいました。非常に助かりました。ありがとうございます!