こうこく
作 ▸
改 ▸

ケース別 XMLHttpRequestの全イベント発火タイミング

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

Google Chrome 76.0.3809.100 (64bit)
もくじ

調査方法

↓のような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 echo '<?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 echo '<?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 echo '<?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 echo '<?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 echo '<?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 echo '<?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 がゼロになっています。RFC2068では Transfer-Encoding: chunked のとき Content-Length は返してはいけないそうです。本来は全体長がわからないものを返すために使うからだと思います。

Transfer-Encoding: chunked について - 理系学生日記

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

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

api.php
<?php echo '<?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 の流れになりました。普通のタイムアウトと同じですね。

以上

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

この記事に何かあればこちらまで (非公開)