Fetchについて調べるつもりが、脱線して伝統的な方に行ってしまった

[ Google Chrome 76.0.3809.100 (64bit) ]

  1. 調査方法
  2. ステータスコード200の場合
  3. ステータスコード400の場合
  4. サーバーが落ちてる場合
  5. タイムアウトした場合
  6. CORS制約でブロックされた場合
  7. xhr.abort() で中断した場合
  8. ステータスコード200の場合 (Transfer-encoding: chunked)
  9. タイムアウトした場合 (Transfer-encoding: chunked)

調査方法

↓のようなJavaScriptを用意しました。

JavaScript
const xhr = new XMLHttpRequest();
xhr.timeout = 5000;  // タイムアウト5000ミリ秒
xhr.addEventListener('readystatechange', (e) => {
	console.log('[readystatechange]', xhr.readyState, (() => {
		if (xhr.readyState === 0) return '(UNSENT)';
		if (xhr.readyState === 1) return '(OPENED)';
		if (xhr.readyState === 2) return '(HEADERS_RECEIVED)';
		if (xhr.readyState === 3) return '(LOADING)';
		if (xhr.readyState === 4) return '(DONE)';
	})());
});
xhr.addEventListener('loadstart', (e) => {
	console.log('[loadstart]');
});
xhr.addEventListener('progress', (e) => {
	console.log('[progress]', `${e.loaded} / ${e.total} bytes`);
});
xhr.addEventListener('abort', (e) => {
	console.log('[abort]');
});
xhr.addEventListener('error', (e) => {
	console.log('[error]');
});
xhr.addEventListener('load', (e) => {
	console.log('[load]', `xhr.status = ${xhr.status}, xhr.responseText = ${xhr.responseText}`);
});
xhr.addEventListener('timeout', (e) => {
	console.log('[timeout]');
});
xhr.addEventListener('loadend', (e) => {
	console.log('[loadend]');
});
xhr.open('GET', 'http://localhost/api.php');
xhr.send();

これは XMLHttpRequesthttp://localhost/api.php をGETし、XMLHttpRequest の全イベントをハンドリングしてコンソールにメッセージを出力するようになってます。タイムアウトは5000ミリ秒を設定してます。

上記のスクリプトを http://localhost/api.php とは別のサーバー (http://localhost:3000) 上のHTMLから実行して、結果を調べていきます。

なお、api.php の中身は↓のような雰囲気です。この記事では、これを書き換えることでレスポンスを操作していきます。

api.php (ステータス200)
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: text/plain');

echo 'レスポンス';

※JavaScriptを実行するページ http://localhost:3000http://localhost/api.php のドメインが異なる関係で、CORS制約回避のため、レスポンスヘッダに Access-Control-Allow-Origin を設定してます。

ステータスコード200の場合

api.php がステータスコード200で文字列「ABCDEFGHIJ」をレスポンスするようにして、上記のJSを実行します。

api.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: text/plain');

echo 'ABCDEFGHIJ';
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 2 (HEADERS_RECEIVED)
[readystatechange] 3 (LOADING)
[progress] 10 / 10 bytes
[readystatechange] 4 (DONE)
[load] xhr.status = 200, xhr.responseText = ABCDEFGHIJ
[loadend]

readystatechange (1)loadstartreadystatechange (2)readystatechange (3)progressreadystatechange (4)loadloadend の順にイベントが発火しました。普通は load のイベントハンドラでいろいろやるんじゃないでしょうか。

ステータスコード400の場合

レスポンスボディはそのままに、ステータスコードだけ400に変更します。

api.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: text/plain');

echo 'ABCDEFGHIJ';
http_response_code(400);
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 2 (HEADERS_RECEIVED)
[readystatechange] 3 (LOADING)
[progress] 10 / 10 bytes
[readystatechange] 4 (DONE)
[load] xhr.status = 400, xhr.responseText = ABCDEFGHIJ
[loadend]

イベントの発火順はステータスコード200の場合とまったく同じでした。

通信に成功した場合、ステータスコードに関わらず load が発火します。ステータスコードで処理を分ける場合は xhr.status を見て判断します。

サーバーが落ちてる場合

サーバーを落として http://localhost/api.php にアクセスできないようにして、JSを実行してみます。

JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 4 (DONE)
[error]
[loadend]

URLにアクセスできなくなって初めて error が発火しました。loadend はエラーの有無に関わらず発火します。

Chromeのバージョンとかによるかもですが、XMLHttpRequest はサーバーが落ちてると4秒くらいで諦めるようです。

タイムアウトした場合

レスポンスに時間がかかるようにして、XMLHttpRequest のタイムアウト (ここでは5000ミリ秒 = 5秒) を破ります。

api.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: text/plain');

echo 'ABCDEFGHIJ';
http_response_code(200);
sleep(6);
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 4 (DONE)
[timeout]
[loadend]

timeout イベントが発火しました。error は発火しません。loadend はタイムアウトでも発火してます。

CORS制約でブロックされた場合

せっかくだから、CORS制約でブラウザにレスポンスをブロックされるケースもやってみます。APIが Access-Control-Allow-Origin ヘッダを返さないように変更します。

api.php
<?php
header('Content-Type: text/plain');

echo 'ABCDEFGHIJ';
http_response_code(200);
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
Access to XMLHttpRequest at 'http://localhost/api.php' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
[readystatechange] 4 (DONE)
[error]
[loadend]

Chromeでやってるので、コンソールに Access to XMLHttpRequest at 'http://localhost/api.php' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. エラーが出力されました。

イベントの発火順は、サーバーが落ちてる場合と同じでした。

xhr.abort() で中断した場合

ちょっと特殊ケースです。api.php は置いといて、JavaScriptの末尾に xhr.abort() を追加して実行します。

JavaScript (末尾)
// (略)
xhr.open('GET', 'http://localhost/api.php');
xhr.send();
xhr.abort();  // 追加
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 4 (DONE)
[abort]
[loadend]

abort イベントが発火しました。この場合も error は発火せず、loadend は発火します。

ステータスコード200の場合 (Transfer-encoding: chunked)

progress イベントが複数回発生するところが見たかったので、Transfer-Encoding: chunked を使って、5000バイトずつ2回に分けてレスポンスするよう api.php を変更します。

api.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: text/plain');
header('Transfer-encoding: chunked');

$chunk1 = str_repeat('!', 5000);
echo dechex(strlen($chunk1))."\r\n".$chunk1."\r\n";

flush();
sleep(1);

$chunk2 = str_repeat('?', 5000);
echo dechex(strlen($chunk2))."\r\n".$chunk2."\r\n";

flush();
sleep(1);

echo '0'."\r\n\r\n";
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 2 (HEADERS_RECEIVED)
[readystatechange] 3 (LOADING)
[progress] 5000 / 0 bytes
[readystatechange] 3 (LOADING)
[progress] 10000 / 0 bytes
[readystatechange] 4 (DONE)
[load] xhr.status = 200, xhr.responseText = !!!!!!!!!!!!!!!!!!!!!!!!!!!!(※注:長いので略)
[loadend]

progress だけでなく readystatechange (3) も2回発火しました。

なお、ここでは Content-Length ヘッダをレスポンスしてないので、progress イベントの e.total がゼロになってしまってます。

タイムアウトした場合 (Transfer-encoding: chunked)

Transfer-Encoding: chunked の途中でタイムアウトさせてみます。トレイラーを返さなければできます。

api.php
<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: text/plain');
header('Transfer-encoding: chunked');

$chunk1 = str_repeat('!', 5000);
echo dechex(strlen($chunk1))."\r\n".$chunk1."\r\n";

flush();
JS実行結果
[readystatechange] 1 (OPENED)
[loadstart]
[readystatechange] 2 (HEADERS_RECEIVED)
[readystatechange] 3 (LOADING)
[progress] 5000 / 0 bytes
[readystatechange] 4 (DONE)
[timeout]
[loadend]

progress が発火した後レスポンスを待ってましたが、タイムアウトした段階で readystatechange (4)timeout の流れになりました。普通のタイムアウトと同じですね。

以上

他に何か思いついたら追加で調べるかもしれません。