Fetch APIとPromiseの覚え書き

Javascript May 07, 2020

この記事では、ES2015で導入されたFetch APIとPromiseの使い方を説明します。

Ajaxとは

JavaScriptが広く使われるようになったきっかけとしてAjax(Asynchronous JavaScript + XML)が挙げられます。asynchronousが「非同期」という意味です。
では、「Javascriptにおける『同期』とは何か?」というと、「リンクのクリックなどでページ遷移が起こること」です。この場合、WebブラウザからWebサーバにページ取得リクエストが送られ、サーバからページが返されるまで待つことになります。待っている間は他に何もできません。

一方「Webブラウザでの非同期通信」は、サーバにリクエストを送った後で結果が返ってくるのを待ちません。もちろん結果が返されないと処理は行えませんがその間に別のことを行えます。これが非同期通信です。

特に重要なこととして、あるページを表示中に非同期通信を行うことで、ページを遷移せずに(ページを読み込むために画面を更新する必要無く)ページ内容の書き換えができます。

JavascriptでAjaxを行うには以下の3つの方法があります。

  • XMLHttpRequest
  • jQuery.ajax()
  • Fetch API

Ajaxを使用するとき、ES2015より前には、XMLHttpRequestを使用するか、jQuery.ajax()を使用していました。

ES2015以降はPromise(非同期処理)をベースに設計されたFetch APIが導入され、徐々にこちらが主流になりつつあります。

Fetch APIを使ってみる

Fetch APIを使うためにはWebサーバを準備する必要があります。
なぜWebサーバが必要かというと、外部サイトに対してFetchを実行してもクロスオリジン(Cross-Origin) 制約によってリクエストが失敗するからです。セキュリティ保護の観点からこのような仕組みになっています。

簡易的なWebサーバを準備するためにNode.jsを使用するのでインストールしましょう。
Node.jsのバージョンはいくつであっても構いませんが、今回はv8.11.3を使用しています。

Node.js

今回は簡易Webサーバとしてhttp-serverを使用します。
ターミナル(もしくはコマンドプロンプト)から以下のコマンドでインストールします。

npm install -g http-server

続いて、適当なフォルダを作成し、フォルダの中に次の3つのファイルを作成してください。

fetch.json

{ "name": "chappie", "age": 6 }

fetch.js

const url = "fetch.json";

fetch(url).then((response) => {
  if(response.ok) {
    /* コンテンツを抜き出すために以下のメソッドが定義されています
     * arrayBuffer() - バイト配列
     * blob() - ファイル
     * json() - JSON
     * text() - テキスト
     * formData() - フォームデータ
     */
    return response.json();
  }
  throw new Error('Network response was not ok.');
}).then((data) => {
  console.log(data);
  console.log("Success!");
}).catch((error) => {
  console.log(error);
});

fetch.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script src="fetch.js"></script>
    </body>
</html>

フォルダの中に入り、以下のコマンドを実行しましょう。

http-server

ブラウザからhttp://127.0.0.1:8080/fetch.htmlにアクセスし、デベロッパツール(Google Chromeの場合、右クリック→検証ツール)からConsoleを開いて、{ "name": "chappie", "age": 6 }と「Success!」が表示されることを確認してください。

※Failed to load resource: the server responded with a status of 404 (Not Found)が表示されることがありますが、Faviconが無いことによるエラーなので気にしないでください。

このコードは、fetch()を実行すると、解決時にResponseオブジェクトを取得できるPromiseオブジェクトが返されます。Promiseはresolve()(解決)とreject()(拒否)という命令を持っており、解決されたときには.then(function(data) {...})、拒否されたときには.catch(function(error) {...})が実行されます。

このようにメソッドを繋げて処理を書くことをメソッドチェーンといいます。

非同期処理とコールバック

Fetchは「Promise」という「非同期処理」を扱うための機能をベースにして作られています。
ここでの「非同期処理」はAjaxのような「ページを遷移しない」という意味ではありません。JavaScriptの多くの動作は非同期です。

これを理解するために次の2つのファイルを作成してください。

new_script_1.js

function newFunc1() {
    console.log("Loaded!");
}

unsync_1.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            function loadScript(src) {
                let script = document.createElement('script');
                script.src = src;
                document.head.append(script);
            }

            loadScript('new_script_1.js');
            newFunc1(); //Uncaught ReferenceError: newFunc is not defined
        </script>
    </body>
</html>

loadScript関数の目的は新しいスクリプトを読み込むことです。ドキュメントに<script src="…">を追加したとき、ブラウザはそれを読み込み、実行します。

その動作(スクリプトの読み込み)は、今ではなく後で終わるため、関数は「非同期」と呼ばれます。

loadScriptの呼び出しにより、スクリプトの読み込みを開始し、その後も実行を続けます。スクリプトが読み込まれている間、それ以降のコードは実行が終わり、もし読み込みに時間がかかる場合は、他のスクリプトも実行される可能性があります(loadScript の下のコードはスクリプトの読み込みが終わるのを待ちません)。

こうした状況の中で、新しいスクリプトがロードされたときにそれを使いたいとします。恐らく新しい関数を宣言しているので、それらを実行したいとします。

しかし、loadScript(...)の呼び出しの直後にnewFunc関数を呼び出しても上手く動作せず、「newFunc関数が未定義のエラー」となります。

これはブラウザにスクリプトを読み込む時間がなかったことを意味します。今のところ、loadScript関数は読み込みの完了を追跡する方法を提供していません。そのスクリプトの新しい関数や変数を使用するために、いつ読み込みが完了するのかを知らせる必要があります。

これを解決するために、loadScript関数の2つ目の引数として、スクリプトが読み込まれたときに実行するコールバック関数を追加しましょう。

次のファイルを作成してください。

unsync_2.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            function loadScript(src, callback) {
                let script = document.createElement('script');
                script.src = src;

                //スクリプトの読み込みが完了したときに渡された関数を実行します
                script.onload = () => callback(script);
                document.head.append(script);
            }

            loadScript('new_script_1.js', function() {
                // コールバックはスクリプトのロード後に実行されるので、これは動作します
                newFunc1();
            });
        </script>
    </body>
</html>

これは「コールバックベース」と呼ばれる非同期プログラミングのスタイルです。非同期処理を行う関数にスクリプトの読み込みが完了した後に実行するためのコールバック関数を渡します。

コールバック地獄

先ほどのloadScript関数で2つ以上のスクリプトを読み込む場合を考えてみましょう。
次の3つのファイルを作成してください。

new_script_2.js

function newFunc2() {
    console.log("Loaded!");
}

new_script_3.js

function newFunc3() {
    console.log("Loaded!");
}

unsync_3.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            function loadScript(src, callback) {
                let script = document.createElement('script');
                script.src = src;
                script.onload = () => callback(script);
                document.head.append(script);
            }

            loadScript('new_script_1.js', function(script) {
                newFunc1();

                loadScript('new_script_2.js', function(script) {
                    newFunc2();

                    loadScript('new_script_3.js', function(script) {
                        newFunc3();
                        // ...すべてのスクリプトが読み込まれるまで続きます
                    });
                });
            });
        </script>
    </body>
</html>

コールバックによってネストが深くなっていきます。ここにエラーを考慮した処理やその他の処理を書いていくと、管理することが難しくなっていき、ほどなくして制御不能に陥ります。

これは「コールバック地獄」や「破滅のピラミッド(pyramid of doom)」と呼ばれる場合があります。

... ここまでがPromiseが考案された背景になります。Promiseではメソッドチェーンを使用することができるので、コールバック地獄に足を踏み入れることはありません。

Promise

Javascriptでは多くの処理が非同期のため、その処理が終わったことを知らせるためにコールバックが有効です。しかし、「コールバック地獄」を避けるためにはPromiseを学ぶ必要があります。

次のコードは一番簡単なPromiseのサンプルです。
ブラウザのConsoleを開き、貼り付けて実行してみてください。

sample()
.then(res => {
    console.log(res);  // "Resolve!!"
})
.catch(res => {
    console.log(res); // "Reject!!"
});

function sample(){
	return new Promise((resolve,reject)=>{
		const num = Math.floor(Math.random()*2); //0 or 1

		if(num === 0) {
			resolve("Resolve!!");
		} else if(num === 1) {
			reject("Reject!!");
		}
	});
}

はじめにsample()を呼び出し、resolve("Resolve!!")が呼び出された場合には.then(res => {...})が実行され、reject("Reject!!")が呼び出された場合には.catch(res => {...})が実行されます(※正常系の処理がresolveとthen、異常系の処理がrejectとcatchという風に使い分けます)。

Promiseチェーン

Promiseを使用すれば、コールバックの呼び出しでネストが深くなることはありません。Promiseではメソッドを次々に「.」で繋いで呼び出す、「メソッドチェーン」という仕組みを利用しているからです。

次のコードをブラウザのConsoleを開き、貼り付けて実行してみてください。
Promiseでは.then(res => {...})をいくつも繋いで書くことができます。これを「Promiseチェーン」といいます。

new Promise(function(resolve, reject) {
    setTimeout(() => resolve(1), 1000);
}).then((res) => {
    alert(result); // 1
    return result * 2;
}).then((res) => {
    alert(result); // 2
    return result * 2;
}).then((res) => {
    alert(result); // 4
    return result * 2;
});

返却した結果が次の.then(res => {...})へと引き継がれます。

Promiseを返す

さて、ここで先ほどの「コールバック地獄」で出てきたコードをPromiseで書いてみます。

次のファイルを作成してください。

unsync_4.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            function loadScript(src) {
                return new Promise((resolve, reject) => {
                    let script = document.createElement('script');
                    script.src = src;

                    //スクリプトの読み込みが完了したときに渡された関数を実行します
                    script.onload = () => resolve(src); // 読み込み完了したとき
                    script.onerror = () => reject(src); // 読み込みエラーのとき
                    document.head.append(script);
                });
            }

            loadScript('new_script_1.js')
            .then((res) => {
                console.log("Loaded:" + res);
                newFunc1();
                return loadScript('new_script_2.js');
            }).then((res) => {
                console.log("Loaded:" + res);
                newFunc2();
                return loadScript('new_script_3.js');
            }).then((res) => {
                console.log("Loaded:" + res);
                newFunc3();
            }).catch(res => {
                console.log("Load Error:" + res);
            });
        </script>
    </body>
</html>

.then(res => {...})の中でPromiseオブジェクトを返却することで、そのオブジェクトのresolve()、またはreject()が実行されるまでは以降の実行は中断されます。

コールバック地獄のコードと比べると、コードは右にではなく、下に成長していきます。

Promise.all()

Promise.all()は、全てのコードを並行に実行し、全てのPromiseオブジェクトからresolve()が返却されるまで処理を中断します。

前項のloadScriptでは、一つずつスクリプトを読み込んでいました。この方法では仮に一つのスクリプトの読み込みに1秒かかった場合、30個のスクリプトを読み込むのに30秒かかってしまいます。

Promise.all()では、並行して実行することが可能なので、全てのスクリプトを並行して読み込むことで、ずっと早く処理が完了するはずです。

次のファイルを作成してください。

unsync_5.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
    </head>
    <body>
        <script>
            function loadScript(src) {
                return new Promise((resolve, reject) => {
                    let script = document.createElement('script');
                    script.src = src;

                    //スクリプトの読み込みが完了したときに渡された関数を実行します
                    script.onload = () => resolve(src); // 読み込み完了したとき
                    script.onerror = () => reject(src); // 読み込みエラーのとき
                    document.head.append(script);
                });
            }

            Promise.all([
                loadScript('new_script_1.js'),
                loadScript('new_script_2.js'),
                loadScript('new_script_3.js')
            ]).then((resAry) => {
                console.log(resAry[0]); // "new_script_1.js"
                console.log(resAry[1]); // "new_script_2.js"
                console.log(resAry[2]); // "new_script_3.js"
            }).catch((res) => {
                console.log("Load Error:" + res); // Load Error:読み込めなかったファイル名
            });
        </script>
    </body>
</html>

全てのPromiseがresolve(src)を実行すると、.then(resAry => {...})に処理が移行し、srcを配列にまとめたものがresAryに渡されます。

参考
Fetch を使う - Web API | MDN
GlobalFetch.fetch() - Web API | MDN
Fetch: クロスオリジン(Cross-Origin) リクエスト
[Sy] npm でインストールできる簡易的な Webサーバ「http-server」が手軽で便利 | Syntax Error.
導入: callbacks
Promises チェーン

SmokyDog

I love dog and web.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.