uhyohyo.net

JavaScript初級者から中級者になろう

十三章第一回 XMLHTTPRequest

このページの最終更新日:

今回は、かなりよく使われる技術のひとつであるXMLHTTPRequest(略してXHR)を紹介します。

略してXHRと呼ばれることもあるこの技術は何かというと、JavaScriptでHTTP通信を行うためのAPIです。Ajaxという言葉はもはや死語かもしれませんが、その根幹をなす技術です。

HTTP通信がどういうものかご存知でしょうか。ブラウザでインターネットを閲覧する場合はHTTP通信により行われます。HTTP通信は、基本的にはページを見たい側(クライアント)がウェブサイト(サーバー)に対してリクエストを送り、それに対してサーバーからページの内容(レスポンス)が帰ってくるというものです。このようなHTTP通信をJavaScriptから行うことができるのです。

リクエストを送る

JavaScriptからHTTPリクエストを発行するには、XMLHttpRequestのインスタンスを作ります。


var xhr= new XMLHttpRequest();

そして、openメソッドでリクエストを開始します。このとき、パスメソッドを渡してやります。

メソッドというのは、GETとかPOSTとかです。ほかにDELETE,PUT,HEAD,OPTION,TRACE,CONNECTがありますが、XHRから使うのは、GET,POSTのほかにせいぜいがHEADくらいでしょう。また、CONNECTとTRACEはセキュリティの観点から使えないことになっています。

GETというのはサーバーからデータを送ってもらうということであり、POSTというのは逆にサーバーへデータを送ることであるとされています。例えば、POSTは掲示板に投稿したりとかそういう時に使うそうです。しかし現実的には、GETでもデータを送ることができますし(HTMLのform要素ではmethod属性でgetかpostか選択可能です)、POSTでもサーバーは結果としてデータを送り返してきます。まあ、状況に応じて使い分けて下さい。

openメソッドは具体的には次のように使います。


var xhr= new XMLHttpRequest();
xhr.open("GET","/index.html");

第1引数がメソッドであり、第2引数がパスです。このように「/」から始まるパスはルートからの絶対パスですね。このサイトの場合、「http://uhyohyo.net/index.html」に該当します。なお、基本的にはリクエストが送られる先のサーバーは現在のオリジンに該当するサーバー(たとえばこのサイト上のページでXHRを利用した場合http://uhyohyo.net)です。

ちなみに今回は省略していますが、第3引数は非同期フラグ(後述)で、また必要な場合、第4引数にユーザー名、第5引数にパスワードを渡すことが可能です。これらは認証に利用されます。

実はopen関数を呼ぶだけだとリクエストは送信されません。リクエストを実際に送信するのがsendメソッドです。

sendには引数が一つあり、リクエストの本文です。リクエストの本文というのは、例えばPOSTメソッドの場合、サーバーに送信するデータです。文字列など(後述)を送信することが可能です。GETやHEADの場合は本文は必要ありませんので、省略するかnullを渡します。


var xhr= new XMLHttpRequest();
xhr.open("GET","/index.html");
xhr.send();

これでリクエストが送信されました。

結果を受け取る

リクエストが送信されたのはいいですが、サーバーから戻ってくる結果はどうやって受け取るのでしょうか。基本的には、リクエストには時間がかかるので、非同期的に結果が帰ってきます。これは前の章でも出てきたのでもうおなじみだと思いますが、XHRにおいてもイベントを用います。ここで、リクエストが完了して結果が戻ってきたときのイベントはloadです。onloadプロパティを設定するか、addEventListenerでイベントハンドラを登録します。


xhr.addEventListener("load",function(ev){
});

loadイベントはリクエストの送信後レスポンスが返ってきたら発生します。この時点で、リクエストの結果はXHRオブジェクトのresponseプロパティに入っています。今回の場合、結果は文字列で得られます。

ここまでをまとめたサンプルを見てみましょう。ページを開くと、HTMLが表示されたと思います。これは/index.htmlのソースです。すなわち、あのページからHTTP通信によって/index.htmlの内容を取得し、その内容を表示したわけです。

結果の種類

今回結果は文字列として得られましたが、実はどのような形で結果が欲しいのか指定することができます。今回はHTMLページを取得したので文字列が適していましたが、例えば画像などを取得したい場合はより適した形があるでしょう。

結果をどんな風に受け取りたいかはXHRオブジェクトのresponseTypeプロパティに文字列を代入してやることで決定します。デフォルトは空で、その場合基本的には"text"と同様の扱いになります(後述)。いずれの場合でも、結果はresponseプロパティに入っていることになります。

responseTypeに設定できる文字列とその意味は以下の通りです。

text
結果をテキストで受け取ります。このとき、結果はresponseプロパティのほかresponseTextプロパティでも取得できます。
json
結果はJSONで送られてきて、それをパースした結果のオブジェクトを受け取ります。結果にJSON.parseを適用したものと同じです(JSONについて詳しくはまた今度)。
arraybuffer
結果をArrayBufferで受け取ります。この方法ならばバイナリデータも受け取れます。
blob
結果をBlobで受け取ります。arraybufferとの違いは、Blobはtypeプロパティ(そのデータのMIMEタイプ)を持っておりこれが使用できるという点です。
document
HTMLまたはXMLのファイルを受け取ります。結果はパースされてHTMLDocument(送られてきたHTMLファイルに対応するdocument)またはXMLDocument(XML文書の場合)で受け取ります。HTMLになるかXMLになるかは、サーバーから送られてきたデータ(のMIMEタイプ)によって自動的に判定されます。HTMLかXMLかに関わらず、この場合はresponseXMLプロパティでも結果を受け取れます。

上の説明でresponseTextとresponseXMLというプロパティが何気なく出現しましたが、これは昔のXHRの名残です。responseプロパティがあれば全て受け取れるので使うことはあまりないでしょうが、昔に書かれたコードに出現することがあります。

リクエストヘッダ

HTTPでは、リクエストやレスポンスの付帯情報としてヘッダを送ることができます。例えば、

Accept-Language: ja;q=0.8, en;q=0.6

みたいなやつです。これは明らかにヘッダ名: という形をしています。ヘッダはさまざまなものがありますので、どのようなヘッダがあるのかは各自で調べてください。

リクエストヘッダを設定するには、setRequestHeaderメソッドを使います。ただし、タイミングはopenメソッドのあと、sendする前でなければいけません。

引数は、第一引数にヘッダ名、第二引数に値です。上の例だとこのようになります。


xhr.setRequestHeader("Accept-Language","ja;q=0.8, en;q=0.6");

ただし、XHRで利用できるヘッダは制限されており、完全に自由にヘッダを操作できるわけではありません。

レスポンスヘッダ

一方、レスポンスヘッダを取得するのがgetResponseHeaderです。引数はヘッダ名だけで、返り値は文字列またはnull(該当ヘッダが存在しない場合)です。レスポンスヘッダはレスポンスにくっついてくるので、当然このメソッドが利用できるのはレスポンスが返ってきたあとです。


var length = xhr.getResponseHeader("Content-Length");

こういう感じで使います。

また、全てのヘッダを一度に得るgetAllResponseHeadersメソッド(引数無し)もありますが、次のような文字列で結果が得られるためやや使いにくいです。。


Content-Type: text/plain;charset=UTF-8
Content-Length: 5

レスポンス

ほかにレスポンスに関連するメソッドとして、あまり使う機会はないと思いますがoverrideMimeTypeというメソッドがあります。これは引数を1つとり、結果のMIMEタイプを書き換えます。

というのも、HTTPのレスポンスは必ずMIMEタイプを伴います。例えばブラウザでウェブサイトを開くとHTMLが正しく描画されるのは、サーバーからのレスポンスが"text/html"のMIMEタイプを伴って返ってくるからです。レスポンスのMIMEタイプはContent-Typeというレスポンスヘッダから取得できます。

overrideMimeTypeを使ってレスポンスのMIMEタイプを書き換えることでXHRの処理を変えることができる場合があります。例えば、サーバーがHTML文書を表す文字列を返すのにMIMEタイプが"text/plain"の場合、XHRのresponseTypeを"document"にしてもresponseとしてHTMLDocumentを取得できません。そのような場合に xhr.overrideMimeType("text/html"); とすればデータがHTML文書として認識されるでしょう。

もっと実用的な例は思いつきませんので、このメソッドを使う機会はあまりないでしょう。

次に、statusプロパティを紹介します。こちらは比較的よく使います。これは、帰ってきた結果のHTTPステータスコードを数値で得ます。例えば。正常にページが帰ってきたならば大抵は200です。ほかに、403とか404が有名です。注意すべきことは、400番台や500番台はページが見つからないなどの問題があったことを示すステータスコードですが、XHR的にはサーバーから何かしら結果が帰ってきたならば「通信成功」とみなされることです。失敗時の処理については後述しますが、XHRの失敗というのはサーバーが応答しないとか何も帰ってこないとかいう場合を指します。

また、statusTextプロパティは、ステータスコードに対応するテキストでの説明です。これは各ステータスコードに対して定義されている文字列で、例えば200なら"OK"とか、404なら"Not Found"です。

readyStateとその他のイベント

さて、上では受信に成功したときに発生するloadイベントを紹介しました。ほかには次のようなイベントがあります。

loadstart
リクエストを開始した瞬間。
progress
データを受信しているとき(後述)。
abort
リクエストが中断して失敗しとき。
error
リクエストに失敗したとき。
timeout
タイムアウト(後述)によりリクエストが失敗したとき。
loadend
失敗か成功かにかかわらずリクエストが終了したとき。
readystatechange
後述

まとめると、処理完了時点で成功した場合にはloadイベントが発生し、失敗した場合は原因によって、abort(中断)またはtimeout(タイムアウト)またはerror(その他)イベントが発生します。loadendイベントは、成功・失敗のいずれの場合も発生するイベントです。loadendイベントはloadなどのイベントと同時(厳密にはloadなどの直後)に発生します。

ちなみに、abortイベントが発生する原因の一つとしては、abortメソッド(引数無し)を呼び出すことで自分からリクエストを中断させることができます。

成功しても失敗しても通信終了時に何らかの動作をしたい場合には、loadendイベントを利用するのが楽でしょう。loadendイベントにおいて成功か失敗か見分ける方法としてはstatusプロパティを使う方法があります。通信成功した場合は前述の通り何らかのステータスコードが入っていますが、失敗した場合はstatusの値は0になっています。statusはまだリクエストしていない場合も0なので注意しましょう。

他の指標として、通信に失敗した場合にはresponseプロパティがnullになります。ただし、通信自体は成功したもののresponseがnullになる場合も多少あります(変なJSONが送られてきたとか、サーバーがステータスコード204を返した場合など)。

ちなみに、loadstartイベントが発生するのはsendメソッドが呼ばれたときです。

そして、readystatechangeイベントについてですが、これはXHRオブジェクトのreadyStateというプロパティと関連しています。これは「現在の状態」を表す数値で、以下に列挙します。

0 (UNSENT)
まだopenメソッドが呼ばれていない状態。
1 (OPENED)
openメソッドが呼ばれたがsendメソッドは呼ばれていない状態。または、sendメソッドが呼ばれたあとまだレスポンスを受信していない状態。
2 (HEADER_RECEIVED)
sendが呼ばれた後、HTTPヘッダを全て受信し終えた状態。この状態からstatusプロパティやgetResponseHeaderメソッドなどが利用可能。まだデータ本体は受信していない。
3 (LOADING)
本文のデータを受信中である状態。
4 (DONE)
受信完了したか、リクエストに失敗して終了した状態。

見て分かるように、readyStateが4になるのはloadendイベントと同時といっていいでしょう。

さて、readystatechangeイベントは、その名の通りreadystateが変化したときに発生するイベントです。

実は昔はこのreadystatechangeイベントしか無かったため、例えばロード完了はloadイベントではなくreadystatechangeイベントを監視してreadyStateが4になったらロード完了だと判断していました。

今はあえてこのreadystatechangeイベントを使う機会は少ないかもしれませんが、最速でstatusを得たい(readyStateが2になったタイミング)とかそういう場合は使い道があるかもしれません。

また、実は、readyStateが変わっていないのにreadystatechangeイベントが発生する場合があります。

たとえば、send()が呼ばれた時点、つまりloadstartと同時にreadystatechangeも起こっています。これは「歴史的理由」によるそうです。

さて、タイムアウトとは制限時間のようなものです。サーバーに接続できたものの一定時間のうちにレスポンスが帰ってこなかったような場合はサーバーが応答しないと判断され、timeoutイベント(やloadendイベントなど)が発生して終了となります。

タイムアウト時間を設定するにはtimeoutプロパティを使います。これはミリ秒の数値であり、例えば

xhr.timeout=1000;

としたならば1秒以内に処理を完了できないとタイムアウトで失敗するということです。デフォルトの値は0で、これは無制限(タイムアウトしない)という意味になります。

イベントオブジェクト

ところで、イベントですから、イベントオブジェクトが存在します。readystatechangeは古いイベントなので特筆すべきことはありませんが、その他のイベントはProgressEventという種類のイベントオブジェクトを有しており、以下に紹介する便利なプロパティを持っています。

ちなみに、イベントオブジェクトですから例によってtargetプロパティがありますね。この場合、targetはそのXMLHttpRequestオブジェクトになります。

ProgressEventに特有なプロパティは主に3つです。一つ目はloadedです。これは数値で、すでに受信した本文のバイト数です。

次はtotalです。これは、受信すべきデータサイズの全体です。例えば、1024バイトのファイルのうち100バイトをすでに受信したとしたら、loadedは100でtotalは1024ということになります。

しかし、totalのはデータサイズの全体ですが、まだ全部受信していないのにどうしてデータサイズが分かるのでしょう。実はCotent-Lengthというレスポンスヘッダによりレスポンスのデータが判明します。レスポンスヘッダはレスポンスの本文より先に送られてきますので、データを全部受け取る前にデータサイズが分かるのです。ただし、これは裏を返せばレスポンスヘッダが来ないとデータサイズが分からないということなので、readyStateが2以上でないとtotalは使えません。

また、totalがすでに判明しているかどうかを表すプロパティとしてlengthComputableがあります。これがtrueならばtotalは利用可能で、falseならばtotalはまだ利用できません(このときtotalは0になっています)。これがreadyStateとは別にある理由は、レスポンスヘッダにContent-Lengthが含まれないためにヘッダが来てもデータサイズが分からないという事態も考えられるからです。

ProgressEventに特有なプロパティは以上の3つです。

これらが活躍するのは、主に上のprogressイベントでしょう。progressイベントは、最短で0.05秒に1回発生することになっています(例外もたまにあります)。

このprogressイベントを用いてダウンロードの進捗を把握すれば、よくある「0%」からはじまって「100%」までいくやつが可能です。具体的にはこんな感じです。


var xhr=new XMLHttpRequest();
xhr.open("GET","/index.html");
xhr.addEventListener("progress",function(ev){
  console.log(ev.loaded,ev.total);
});
xhr.send();

こんな感じでconsole.logでloadedとtotalを表示してやれば、loadedがだんだん増えていく様子がわかるでしょう。ただ、あまりに小さいファイルだと一瞬で終わってしまって分からないかもしれません。実際には、progress要素などに表示するとよいでしょう。

XMLHttpRequestUpload

さて、イベントと関連して紹介するのがXMLHttpRequestUploadです。これはXHRオブジェクトとセットになっていて、uploadプロパティに入っています。

このXMLHttpRequestUploadの機能は、イベントを発生することです。発生するイベントの名前はXMLHttpRequestと同じです。

さて、実は、さっきのprogressイベントは、サーバーからこちらへ送られてくるデータを観測するものでした。では、POSTでこちらから巨大なデータを送る場合(後述のFormDataでファイルを送る場合とか)は進み具合を知ることはできないのでしょうか。

それを可能にするのがこのXMLHttpRequestUploadであり、クライアントからサーバーにデータを送る部分に関するイベントを発生してくれます。ただし、sendの引数がないなど送るデータが全くない場合はイベントは一つも発生しないので、注意しましょう。

loadstart, abort, error, timeoutについては、クライアントからサーバーへ送る部分とサーバーからクライアントへデータが送られてくるが細分化されていたりはしないので、通常のXMLHttpRequestのイベントと同じです。つまり、わざわざXMLHttpRequestUploadのほうのイベントを監視することもないでしょう。

また、readystatechangeはありません。残ったload,loadend,progressが、XMLHttpRequestUploadで違う部分です。要するに、XMLHttpRequestUploadの用途は主にprogressイベントを監視したり、loadイベントでアップロードの終了を検知することになります。

それでは、XMLHttpRequestUploadにおけるload,loadend,progressの意味を見ていきましょう。とはいっても、これは単純です。

progressイベントのloaded・totalは、受信済みデータ量・受信する総データ量の代わりに、送信済みデータ量・送信する総データ量を示します。XMLHttpRequestUplioadの場合、load・loadendの2つはタイミングが同じであり、こちら側から送るデータを全て送った段階で発生します。大きいデータを送信して時間がかかりそうな場合は、これを利用してインターフェースを作ると面白いでしょう。これについても、サンプルを示しておきます。


//変数dataには巨大なデータが入っていると仮定
var xhr=new XMLHttpRequest();
xhr.open("POST","/index.html");
xhr.upload.addEventListener("progress",function(ev){
  console.log(ev.loaded,ev.total);
});
xhr.send(data);

クロスオリジンリクエスト

次に、クロスオリジンのリクエストについて解説します。クロスオリジンとは、オリジンをまたぐということです。オリジンは七章台一回に少しでてきました。オリジンはホスト名・ポート番号・プロトコルを合わせた概念であり、これが1つでも違う2つのサイトは違うオリジンであると見なされます。一般にオリジンが同じWebページは所有者が同じであるとみなされます。

ブラウザで動作するJavaScriptはセキュリティ上の制限を受けているため、原則として他のオリジンに存在するデータを見ることができません。例えば全く関係のないページを開いたらJavaScriptが裏でこっそりとAmazonにアクセスしてアカウント情報を盗まれるというなことが起こると困るからですね。このように、同じオリジンのデータしかアクセスできないという制限を同一オリジンポリシーSame-origin policy)といいます。

しかし、XHRにおいては特定の条件を満たした場合のみ、あるオリジンのページから違うオリジンのページへHTTPリクエストを送ることができます。このような同一オリジンポリシーを超越したリクエストがクロスオリジンリクエストです。

具体的にどうすればクロスオリジンのリクエストができるかというと、リクエストを受ける側がHTTPレスポンスヘッダによりクロスオリジンリクエストの受け入れを表明する必要があります。例えば、


xhr.open("GET","http://example.com/");

という場合には、example.com側が変更を行わないといけないということです。このようにデータを見られる側がJavaScriptからのアクセスを制御することで、セキュリティを保ちつつクロスオリジンリクエストができるようになります。

具体的には、Access-Control-Allow-Originというヘッダをレスポンスに追加します。これで、XHRを許可するドメインを列挙します。例えば、


Access-Control-Allow-Origin: http://example.com http://uhyohyo.net

のようにします。この例は、http://example.comとhttp://uhyohyo.netにあるWebページからのXHRは許可するという意味です。また、ドメインを列挙する代わりに「*」とすることもできます。これは、「全てのオリジンからのXHRを許可」ということです。

実はXHRオブジェクトにはwithCredentialsというプロパティがあります。これはクロスオリジンのときに関係するプロパティで、falseまたはtrue、デフォルトはfalseです。

trueになると何が起こるかというと、CookieとかSSL証明書などの情報も一緒に送られるようになります。これらが必要な場合はwithCredentialsをtrueにしましょう。

ただし、注意するのは、このwithCredentialsがtrueの状態で、さらにAccess-Control-Allow-Originが"*"でない場合(ちゃんと列挙してある場合)には、さらに追加のHTTPヘッダが必要になります。


Access-Control-Allow-Credientials: true

このヘッダがないと失敗してしまいます。

同期リクエスト

XHRオブジェクトのopenメソッドの説明のときに、第3引数が「非同期フラグ」であると述べました。また、今回の説明ではXHRの結果はイベントを通じて得ることができる、すなわち非同期ですね。実はopenメソッドの第3引数をfalseにすると同期的なリクエストが行われます。逆にtrueだと今まで説明した通りの非同期的なリクエストになります。つまり、この引数は省略するとtrue扱いになるという珍しい引数です。

ただし、現在の仕様では普通のJavaScript実行環境ではXHRを同期リクエストで利用することはできません。つまり、第3引数にfalseを指定するとエラーになります。

こんな意味のない仕様がなぜ存在するかというと、ひとつは歴史的経緯、そしてもうひとつはWorkerプロセス内で利用するためです。Workerプロセスについてはまた今度紹介しますが、Workerプロセス内ではXHRを同期的に利用することができます。

とにかく、同期リクエストは普通は利用できませんが、利用する機会があったときのために簡単に説明しておきます。同期リクエストでは、send()が呼び出された時点でブロックされ、リクエストが完了すると戻ってきます。つまり、send()の直後にresponseプロパティを参照して結果を得ることができます。通信失敗した場合はerrorイベント等の代わりに例外が発生します。

sendで送るデータ

最後にsendメソッドで送るデータについて解説します。HTTPでは、POSTの場合などにリクエストに伴ってデータ(本文)を送ることができました。これは次のように文字列で指定できます。


var xhr=new XMLHttpRequest();
xhr.open("POST","sample.cgi");
xhr.send("foo=bar&hoge=piyo");

バイナリデータを送りたい場合、sendの引数にArrayBufferやBlobを渡すことができます。Blobの場合はMIMEタイプの情報を持っていますので、本文のデータのMIMEタイプもしっかり設定して送れます。

他に、HTMLDocumentやXMLのDocumentを渡すこともできます。そうなると、そのソースを送ってくれます。

そして、sendには今回初登場のFormDataオブジェクトを渡すこともできるのです。

FormDataをsendに渡すと、HTMLでフォームを送信した場合と同様の本文を生成してくれます。というのも、HTMLのフォームのデータをPOSTで送る際は、特にファイル送信などが混ざってくると、multipart/form-dataという形式で送る必要があります。これは色々と複雑で、手で処理するのは面倒です。

そこで、送りたいデータを設定すればmultipart/form-dataの形式を作ってくれるのがFormDataです。FormDataを使うには、まずそのインスタンスを作ります。


var data = new FormData();

また、FormDataの第一引数にHTMLFormElementを渡すとその内容を持ったFormDataが得られます。つまり、そのFormDataをそのまま送るとそのフォームを送信したのと同じデータがサーバーに送られます。引数がない場合は、データは空の状態で作られます。

こうして作ったFormDataのインスタンスには、さらにappendメソッドでデータを追加することができます。FormDataのデータは基本的にname-value形式です。例えば、 <input name="foo" value="bar"> というinput要素で表されるデータは、名前(name)が"foo"で値(value)が"bar"というわけです。これを追加するには、appendメソッドの第1引数に名前、第2引数に値を渡します。つまり上の例の場合こうなります。


data.append("foo","bar");

また、input要素はファイルを送信する機能がありましたね。appendメソッドではFormDataにファイルをデータとして追加することもできます。このとき、第1引数は名前で同じですが、第2引数はBlobになります(もちろん、Blobを継承しているFileでもいいということに注意しましょう)。そして、第3引数にファイル名を与えることも可能です。すなわちこんな感じです。


//変数blobには何かのBlobが入っている
data.append("foo",blob,"file.txt");

このようにして作ったFormDataオブジェクトをsendに渡すと、multipart/form-dataの形式にして送ってくれます。つまり、HTMLのフォーム(enctype属性が"multipart/form-data")を送信したときと同じ形式のデータを送ってくれます。

以上にわたってXMLHttpRequestを解説してきましたが、要するに使い道は、HTMLファイルとは別のデータをロードして使いたいとか、そういうときでしょう。自分のページ内のデータだけでなく、必要に応じてサーバーからデータを取得するような機能は昨今のウェブアプリケーションでは日常茶飯事です。結構使い道はあるので、活用しましょう。

ちなみに、ずいぶん長い間XMLHttpRequestがHTTP通信を発行する唯一の方法でしたが(Server-sent Eventsを除けば)、最近は新世代のfetchというAPIが登場して取って代わられようとしています。これについてはまた今度紹介します。