JavaScript
dom
fetch

まだXMLHttpRequestを使ってるの? fetchのすすめ

JavaScriptでWeb的なプログラムを書いたことがある人は、XMLHttpRequestを使った経験もあるのではないかと思います。($.ajaxしか使ったことがないよという人はすみませんがサポート対象外です。)

XMLHttpRequest,略してXHRは、JavaScript(+DOM)でサーバーとHTTP通信をするための唯一の方法としての地位を長らく保ってきましたが1、ここ3〜4年でより新しいAPIであるfetch APIが登場しました。fetch APIが出たばかりの頃は何だこのおもちゃはと正直思いましたが、いつの間にか仕様が充実していい感じになっていました。

皆さんは、この新しいAPIであるfetchをちゃんと使っているでしょうか。それとも、古いXHRを今だに使っているのでしょうか。この記事では、今だにfetchを使っていない人を主な対象としてfetchの使い方を解説します。

fetchの基本:リクエストの発行と結果の取得

HTTP通信の一番の基本であるGETリクエストを発行してみましょう。XHRではこんな感じでしたね。

XHRでのGETリクエスト
var xhr = new XMLHttpRequest();
xhr.open("GET", "/");
xhr.send();
xhr.onload = ()=> {
  console.log(xhr.response);
};

まずXMLHttpRequestオブジェクトを作り、openメソッドでメソッドとURLを指定、そしてsendメソッドでリクエストを送信します。結果はloadイベントで受け取ります。loadイベントはリクエストが成功して結果が返ってきたときに発生するイベントです。エラーの処理は簡単のため省きました。

同じことをfetchでやるとこうなります。

fetchでのGETリクエスト
fetch("/", {
  method: "GET",
}).then(response => response.text())
.then(text => {
  console.log(text);
});

まず、リクエストはfetch関数を呼び出すことで発行します。第1引数がURLです。メソッドとか、あるいはPOSTの場合はリクエスト本文などは第2引数のオプションで指定します。メソッドのデフォルトはGETなので、今回は第2引数を全部省略することも可能です。

fetchの返り値はいまどきの非同期処理らしくPromiseです。Promiseの結果はResponseオブジェクトであり、後で詳しいことは説明しますが、このオブジェクトからリクエストの結果を文字列で得るためにはtextメソッドを用います。textメソッドは再びPromiseを返すので、再びthenメソッドをチェーンして文字列を得ることになります。

このように、XHRではメソッドを何回も呼び出したりイベントを監視したりする必要がありましたが、fetchはPromiseをベースとしたAPIになっており明快です。

Responseオブジェクト

fetchを使いこなす鍵は上でちらっと出てきたResponseオブジェクトにあります。そもそもResponseオブジェクトというのは何を表しているのでしょうか。

それを知るためには、まずResponseオブジェクトが得られるのはいつなのか、すなわちfetchの返すPromiseが解決されるのはいつなのかを知る必要があります。実は、fetchが返すPromiseが解決されるのはレスポンスヘッダが全部返ってきたときです2

HTTPのレスポンスというのはまずステータスコード、次にヘッダ、そして最後に本文(レスポンスボディ)という順番ですから、この時点ではまだ本文が到着していないことになります。これが、本文を文字列で取得するresponse.text()メソッドがPromiseを返す理由です。このPromiseは、さらにレスポンスの本文が全部到着するのを待ってから解決されることになります。

逆に言えば、ステータスコードやヘッダの情報は、Responseオブジェクトから直に(さらに待つことなく)取得できるということです。ステータスコードやヘッダはこのように取得します。

fetchでステータスコードとヘッダを取得
fetch("/")
.then(response => {
  console.log(response.status); // 多分200
  console.log(response.headers.get('Content-Encoding')); // "gzip"とか
});

ちなみに、Responseオブジェクトはちょっと便利なプロパティとしてokプロパティを持っています。これは真偽値で、通信が成功したかどうかを表します。ここでの成功というのは、ステータスコードが200番台だったかどうかです。

XHRでも、一応ヘッダーだけが返ってきて本文はこれからというタイミングに処理を挟むことはできました。readystatechangeイベントを監視してreadyStateが2になったタイミングで処理をすればよいのです。しかし、readystatechangeイベントを使うよりはPromiseベースのAPIのほうが直感的でよいのではないかと思います。

これはXHRにも言えることですが、ここで注意すべきは、何を以って通信が成功したかと言うのかということです。そもそも、Promiseというのは成功する場合と失敗する場合があります。例えばfetchの返り値のPromiseが成功するのはいつで失敗するのはいつでしょうか。

答えは、大雑把に言えば「サーバーからレスポンスが(少なくともヘッダーまで)返ってきたら成功」です。これは、たとえステータスコードが404とかだったとしても成功であるということを意味します。逆に失敗するのは、サーバーに接続できなかったとか、CORSに引っかかったとか、そういう場合です。もし404を失敗にしたいのであれば、下のように自分でいい感じに処理する必要があります。

正常でないステータスコードを失敗にする例
fetch("/non-existent-page")
.then(async response => {
  if (response.ok) {
    return response.text();
  } else {
    throw new Error(`Request failed: ${response.status}`);
  }
})
.catch(err => console.error(err)); // Error: Request failed: 404

ここまで見たように、Responseオブジェクトというのは、ヘッダーまでのレスポンスで得られた情報を表すオブジェクトであるというのが1つの側面です。statusheadersなどを通してこれらの情報を得ることができます。

そして、もうひとつの側面が、これから取得されるであろうレスポンス本文を表すオブジェクトという側面です(仕様書的にはこれをBody mixinと読んでいます)。最初の例で使用したresponse.text()メソッドはこれに属するメソッドです。

レスポンス本文の取得方法

ここまではResponseオブジェクトのtext()メソッドを使ってレスポンスを文字列として取得してきました。XHRで言うところのresponseType = "text"に相当する動作です。しかしこれでは不便な場合がありますから、文字列以外にもいくつかのデータ形式を選べるようになっています。XHRで選べたのは以下の形式でした。

responseType 意味
"text" 文字列
"arraybuffer" ArrayBuffer
"blob" Blob
"json" レスポンス文字列をJSONとしてパースした結果のオブジェクト
"document" レスポンス文字列をHTMLまたはXMLとしてパースした結果のDocumentオブジェクト

バイナリデータを扱いたいときは用途に応じて"arraybuffer""blob"を選びます。"json"は普通に少し便利なやつですね。そして、"document"が特徴的です。XHRはXMLを名に冠しているくらいですからXMLをパースできたのです。

一方、fetchではResponseオブジェクトに次のようなメソッドが用意されています。もちろん、返り値は全てPromiseです。

メソッド 結果
text() 文字列
arraybuffer() ArrayBuffer
blob() Blob
json() JSON
formData() FormData

見て分かる通り、"document"が消えた代わりにFormDataが増えました。FormDataというのは、multipart/form-data MIMEタイプで送信されたデータをいい感じに表すやつです。3

"document"が無いとXMLとかをパースできなくて困るという方がいるかもしれませんが、ご安心ください。文字列で取得したあとにDOMParserでパースすればいいのです。

というわけで、おおよそXHRと同じことができることが分かりましたね。

ストリーム

次に、XHRには無いfetchならではの点を紹介します。これはいくつかあるでしょうが、一つ代表的なのはレスポンス本文をストリームとして扱えるという点です。ストリームの話をするとfetch APIではなくStreams APIという別のAPIの話に片足を突っ込むことにはなるのですが、折角なので少し紹介します。

ストリームというのは、データが徐々にやってくるやつです。HTTP通信というのはインターネットを介してデータを送ってもらうわけですから、データは一瞬で全部がやってくるのではなくパケットに分割されたりして(あまり詳しくないので詳細な話は避けますが)順々にやってくるわけです。

となると、データを前から順に見ていくような処理を行いたい場合には、データが全部やってくるのを待つ必要はありません。既に到着したデータから順に見ていくことが可能なはずです。さらに、既に見たデータはもう不要という場合は、レスポンスの全部を律儀に覚えておくのではなく見たそばからデータを捨てることができます。このような処理は、上で紹介したメソッドでは実現できません。これらは全てデータの全体が届くまで待ってまとめて取得するものであからです。

XHRではレスポンスをストリームとして扱うことはできませんでした4。progressイベントを用いて現在何バイト受信したかを知ることだけはできましたが。

ストリームの例1

あまり意味のない例ですが、レスポンス本文をストリームとして扱い、レスポンス中の'<'の文字を数える例を出しておきます。余談ですが、ReadableStreamのreadメソッドはAsyncIteratorとAPIが似ていますね。ECMAScriptとDOMで微妙に食い違ったり歩調が揃わなかったりするのはそこそこよくあるという印象ですが。

fetchとストリームの例
fetch("/")
.then(async response => {
  // 結果を数えるための変数
  let count = 0;
  // response.body にレスポンス本文のストリーム(ReadableStream)が入っている
  // ストリームのReaderを作成
  const reader = response.body.getReader();
  while (true) {
    // ストリームからデータを読む
    const {done, value} = await reader.read();
    if (done) {
      // doneがtrueならストリームのデータを全部読み終わった
      break;
    }
    // 読んだデータはバイナリデータ(Uint8Array)で与えられる
    for (const char of value) {
      // 文字'<' (0x3c) を見つけたらカウント
      if (char === 0x3c) {
        count++;
      }
    }
  }
  return count;
}).then(count => console.log(count)); // Qiitaのトップページで試してみたら262でした

ストリームの例2

XHRのprogressイベントで現在受信したバイト数を知ることができると先ほど述べました。せっかくなので、同じことをストリームを使ってやってみましょう。まずはXHR版を作ります。

XHRで受信したバイト数を得る例
var xhr = new XMLHttpRequest();
xhr.open("GET", "/");
xhr.send();
xhr.onprogress = e => {
  console.log(`${e.loaded} / ${e.total}`);
};

実際にこれを使ってUIとかを作ろうとするともう少し複雑になりそうですが、これだけなら簡単ですね。fetchでは以下のように行なえます。

fetchで受信したバイト数を得る例
fetch("/")
.then(async response => {
  // 全バイト数を先に取得
  const total = Number.parseInt(response.headers.get("Content-Length"));

  // 受信したバイト数
  let loaded = 0;

  const reader = response.body.getReader();
  while (true) {
    const {done, value} = await reader.read();
    if (done) {
      break;
    }
    // 読んだデータはバイナリデータ(Uint8Array)で与えられる
    loaded += value.length;
    console.log(`${loaded} / ${total}`);
  }
});

これらの例はContent-Lengthヘッダに依存しているのでContent-Lengthが送られてこない場合などは正しい表示になりませんが、まあ仕方ありませんね。

fetch API(とStreams API)を使った例のほうがなんだか長い気がしますが、まあ生のAPIを叩いていればこんなものでしょう。いい感じのライブラリなどもあるのかもしれません(ちゃんと調べていませんが)。

リクエストのキャンセル

XHRでは、発行したリクエストをキャンセルすることができました。リクエストを発行した直後にキャンセルする意味不明な例は以下のとおりです。

XHRでのリクエストのキャンセル
var xhr = new XMLHttpRequest();
xhr.open("GET", "/");
xhr.send();
// 一瞬待ったあとにキャンセル
setTimeout(()=> xhr.abort(), 0);
// キャンセルされたイベントを検知
xhr.onabort = e => console.error(e);

では、fetchの場合はどうするのでしょうか。fetchの返り値はPromiseですが、Promiseは成功と失敗があるのみで、処理をキャンセルする機能は備わっていません。非同期処理を全部Promiseで扱うとなればそのような「キャンセル可能なPromise」にも当然需要が発生するはずですが、現在のところ議論はあるようですが標準化は遠い状況です。

そこで、fetch(というかDOM)では独自のアプローチをとっています。それがAbortControllerです。AbortControllerオブジェクトを生成すると対応するsignalが得られるので、それをfetchに渡します。その後、AbortControllerオブジェクトのabort()を呼び出すことでリクエストをキャンセルできます。

以上をコードにすると以下のようになります。実行すると、リクエストが失敗することが分かります。

fetchのキャンセル
// 新しいAbortControllerを生成
var controller = new AbortController();
fetch("/", {
  // controllerが持つsignalをfetchに渡す
  signal: controller.signal,
})
.then(response => console.log('成功', response.status))
.catch(err => console.log('失敗', err));

// 一瞬待った後にキャンセル
setTimeout(()=> controller.abort(), 0);

fetchでしかできないこと

XHRはできないがfetchではできることとして先ほどストリームの扱いを紹介しましたが、以前は他にもありました。というのも、一時期Credential Management APIではパスワード情報を保持したPasswordCredentialオブジェクトをfetchのオプションの一つcredentialに渡すことでサーバーにパスワード情報を送るようになっていました。このPasswordCredentialオブジェクトはfetchでサーバーに送信することでパスワードがサーバーに送信できましたが、クライアントのスクリプトはこのオブジェクトからパスワード情報を取り出すことができませんでした。こうすることで、パスワード情報に触れるスクリプトを減らしてセキュリティリスクを下げる意味があったのでしょう。

これはfetchのみの機能でXHRには対応していませんでした。もっとも、この機能はのちに廃止され、今は普通にPasswordCredentialオブジェクトからパスワードを取り出せるようになったのでfetchに特化した仕様ではなくなりました。

とはいえ、このようにfetchのみの仕様が今度も追加されるかもしれません。それに備えてXHRからfetchに乗りかえておくのがよいでしょう。

ServiceWorkerとの関係

ServiceWorkerは今をときめくDOMのすてき機能で、いわゆるPWAにおいて重要な役割を果たす機能です。ServiceWorkerの目玉機能のひとつが、ページからの通信に割り込んでキャッシュを働かせたりすることができる機能です。実は、そのような通信を扱うのにあたってこの記事で紹介したResponseオブジェクト、そしてその対となるRequestオブジェクトなどが使われるのです。fetchの使い方を知っておくことで将来的にServiceWorkerを扱いたくなったときに役に立つかもしれません。

まとめ

このQiitaにもfetchを紹介する記事はいくつかあるわけですが、何かどれも中途半端な気がしたので自分の勉強も兼ねてまとめ直してみました。

この記事ではfetchの使い方をXMLHttpRequestと比較しながら紹介し、XHRと同様なHTTP通信がよりモダンなやり方でできることを示しました。CORS周りの機能など、この記事では紹介していないfetchの機能も色々ありますので、よかったら調べてみてください。

ES2015でPromiseといういい感じの非同期処理のやり方が追加されて以降(キャンセルをどうするのという問題は残っていますが)、非同期処理はPromiseベースのAPIでやるのが普通になりました。その流れに乗ったfetchですから、XHRを使うという面倒なことをせずにどんどんfetchを使っていきたいものです。

Q&A

  • Q. どうせIEじゃ使えないんでしょ?
  • A. [ Polyfill とは ] [ 検索 ]5
  • Q. Polyfillとかそんな変なライブラリ勝手に使えないんですけど!
  • A. 転職したら?

  1. 唯一とはいいつつも、JSONPとかServer-Sent Eventsとか、あるいはFlash等を用いてサーバーと通信する方法も一応ありました。ただ、前2つは汎用性に乏しく、FlashはJavaScriptではありませんから、JavaScriptから使えるAPIとしてはXHRがやはり実質唯一といえます。 

  2. 仕様を完全に読み込んだわけではありませんが、fetchの仕様のHTTP-network fetchの項目の4-3に“Wait until all the headers are transmitted.”とあります。 

  3. サーバがmutlipart/form-dataでデータを送ってくることはあまり無いでしょう。どちらかと言うとこれはServiceWorker内でユーザーからのリクエストボディを読むときに使うやつです。 

  4. 一応、responseTypeが"text"のときは受信の途中でもデータにアクセスすることはできたようです(MDN)。 

  5. ストリーム周りは実装されていないPolyfillが多いみたいですが。