Promiseを活用する

ここまでのセクションで、Fetch APIを使ってAjax通信を行い、サーバーから取得したデータを表示できました。 最後に、Fetch APIの返り値でもあるPromiseを活用してソースコードを整理することで、エラーハンドリングをしっかり行います。

関数の分割

まずは、大きくなりすぎたfetchUserInfo関数を整理しましょう。 この関数では、Fetch APIを使ったデータの取得・HTML文字列の組み立て・組み立てたHTMLの表示をしています。 そこで、HTML文字列を組み立てるcreateView関数とHTMLを表示するdisplayView関数を作り、処理を分割します。

また、後述するエラーハンドリングを行いやすくするため、アプリケーションにエントリーポイントを設けます。 index.jsに新しくmain関数を作り、main関数からfetchUserInfo関数を呼び出すようにします。

function main() {
    fetchUserInfo("js-primer-example");
}

function fetchUserInfo(userId) {
    fetch(`https://api.github.com/users/${encodeURIComponent(userId)}`)
        .then(response => {
            if (!response.ok) {
                console.error("エラーレスポンス", response);
            } else {
                return response.json().then(userInfo => {
                    // HTMLの組み立て
                    const view = createView(userInfo);
                    // HTMLの挿入
                    displayView(view);
                });
            }
        }).catch(error => {
            console.error(error);
        });
}

function createView(userInfo) {
    return escapeHTML`
    <h4>${userInfo.name} (@${userInfo.login})</h4>
    <img src="${userInfo.avatar_url}" alt="${userInfo.login}" height="100">
    <dl>
        <dt>Location</dt>
        <dd>${userInfo.location}</dd>
        <dt>Repositories</dt>
        <dd>${userInfo.public_repos}</dd>
    </dl>
    `;
}

function displayView(view) {
    const result = document.getElementById("result");
    result.innerHTML = view;
}

ボタンのclickイベントで呼び出す関数もこれまでのfetchUserInfo関数からmain関数に変更します。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>Ajax Example</title>
  </head>
  <body>
    <h2>GitHub User Info</h2>

    <input id="userId" type="text" value="js-primer-example" />
    <button onclick="main();">Get user info</button>

    <div id="result"></div>

    <script src="index.js"></script>
  </body>
</html>

Promiseのエラーハンドリング

次にfetchUserInfo関数を変更し、Fetch APIの返り値でもあるPromiseオブジェクトをreturnします。 この変更によって、fetchUserInfo関数を呼び出すmain関数のほうで非同期処理の結果を扱えるようになります。 Promiseチェーンの中で投げられたエラーは、Promiseのcatchメソッドを使って一箇所で受け取れます。

次のコードでは、fetchUserInfo関数から返されたPromiseオブジェクトを、main関数でエラーハンドリングしてログを出力します。 fetchUserInfo関数のcatchメソッドでハンドリングしていたエラーは、main関数のcatchメソッドでハンドリングされます。 一方、Responseのokプロパティで判定していた400や500などのエラーレスポンスがそのままではmain関数でハンドリングできません。 そこで、Promise.rejectメソッドを使ってRejectedなPromiseを返し、Promiseチェーンをエラーの状態にします。 Promiseチェーンがエラーとなるため、main関数のcatchでハンドリングできます。

function main() {
    fetchUserInfo("js-primer-example")
        .catch((error) => {
            // Promiseチェーンの中で発生したエラーを受け取る
            console.error(`エラーが発生しました (${error})`);
        });
}

function fetchUserInfo(userId) {
    // fetchの返り値のPromiseをreturnする
    return fetch(`https://api.github.com/users/${encodeURIComponent(userId)}`)
        .then(response => {
            if (!response.ok) {
                // エラーレスポンスからRejectedなPromiseを作成して返す
                return Promise.reject(new Error(`${response.status}: ${response.statusText}`));
            } else {
                return response.json().then(userInfo => {
                    // HTMLの組み立て
                    const view = createView(userInfo);
                    // HTMLの挿入
                    displayView(view);
                });
            }
        })
        .catch(err => {
            return Promise.reject(new Error(`Failed fetch user(id: ${userId}) info`, { cause: err }));
        });
}

Promiseチェーンのリファクタリング

現在のfetchUserInfo関数はデータの取得に加えて、HTMLの組み立て(createView)と表示(displayView)も行っています。 fetchUserInfo関数に処理が集中して見通しが悪いため、fetchUserInfo関数はデータの取得だけを行うように変更します。 併せてmain関数で、データの取得(fetchUserInfo)、HTMLの組み立て(createView)と表示(displayView)という一連の流れをPromiseチェーンで行うように変更していきます。

PromiseのthenメソッドでつながるPromiseチェーンは、thenに渡されたコールバック関数の返り値をそのまま次のthenへ渡します。 ただし、コールバック関数の返り値がPromiseである場合は、そのPromiseで解決された値を次のthenに渡します。 つまり、thenのコールバック関数が同期処理から非同期処理に変わったとしても、次のthenが受け取る値の型は変わらないということです。

Promiseチェーンを使って処理を分割する利点は、同期処理と非同期処理を区別せずに連鎖できることです。 一般に、同期的に書かれた処理を後から非同期処理へと変更するのは、全体を書き換える必要があるため難しいです。 そのため、最初から処理を分けておき、処理をthenを使ってつなぐことで、変更に強いコードを書けます。 どのように処理を区切るかは、それぞれの関数が受け取る値の型と、返す値の型に注目するのがよいでしょう。 Promiseチェーンで処理を分けることで、それぞれの処理が簡潔になりコードの見通しがよくなります。

index.jsfetchUserInfo関数とmain関数を次のように書き換えます。 まず、fetchUserInfo関数がResponseのjsonメソッドの返り値をそのまま返すように変更します。 Responseのjsonメソッドの返り値はJSONオブジェクトで解決されるPromiseなので、次のthenではユーザー情報のJSONオブジェクトが渡されます。 次に、main関数がfetchUserInfo関数のPromiseチェーンで、HTMLの組み立て(createView)と表示(displayView)を行うように変更します。

function main() {
    fetchUserInfo("js-primer-example")
        // ここではJSONオブジェクトで解決されるPromise
        .then((userInfo) => createView(userInfo))
        // ここではHTML文字列で解決されるPromise
        .then((view) => displayView(view))
        // Promiseチェーンでエラーがあった場合はキャッチされる
        .catch((error) => {
            console.error(`エラーが発生しました (${error})`);
        });
}

function fetchUserInfo(userId) {
    return fetch(`https://api.github.com/users/${encodeURIComponent(userId)}`)
        .then(response => {
            if (!response.ok) {
                return Promise.reject(new Error(`${response.status}: ${response.statusText}`));
            } else {
                // JSONオブジェクトで解決されるPromiseを返す
                return response.json();
            }
        })
        .catch(err => {
            return Promise.reject(new Error(`Failed fetch user(id: ${userId}) info`, { cause: err }));
        });
}

Async Functionへの置き換え

Promiseチェーンによって、Promiseの非同期処理と同じ見た目で同期処理を記述できるようになりました。 さらにAsync Functionを使うと、同期処理と同じ見た目でPromiseの非同期処理を記述できるようになります。 Promiseのthenメソッドによるコールバック関数の入れ子がなくなり、手続き的で可読性が高いコードになります。 また、エラーハンドリングも同期処理と同じくtry...catch構文を使うことができます。

main関数を次のように書き換えましょう。まず関数宣言の前にasyncをつけてAsync Functionにしています。 次にfetchUserInfo関数の呼び出しにawaitをつけます。 これによりPromiseに解決されたJSONオブジェクトをuserInfo変数に代入できます。

もしfetchUserInfo関数の中で例外が投げられた場合は、try...catch構文でエラーハンドリングできます。 このように、あらかじめ非同期処理の関数がPromiseを返すようにしておくと、Async Functionにリファクタリングしやすくなります。

async function main() {
    try {
        const userInfo = await fetchUserInfo("js-primer-example");
        const view = createView(userInfo);
        displayView(view);
    } catch (error) {
        console.error(`エラーが発生しました (${error})`);
    }
}

ユーザーIDを変更できるようにする

仕上げとして、今までjs-primer-exampleで固定としていたユーザーIDを変更できるようにしましょう。 index.htmlに<input>タグを追加し、JavaScriptから値を取得するためにuserIdというIDを付与しておきます。

index.html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>Ajax Example</title>
  </head>
  <body>
    <h2>GitHub User Info</h2>

    <input id="userId" type="text" value="js-primer-example" />
    <button onclick="main();">Get user info</button>

    <div id="result"></div>

    <script src="index.js"></script>
  </body>
</html>

index.jsにも<input>タグから値を受け取るための処理を追加すると、最終的に次のようになります。

index.js

async function main() {
    try {
        const userId = getUserId();
        const userInfo = await fetchUserInfo(userId);
        const view = createView(userInfo);
        displayView(view);
    } catch (error) {
        console.error(`エラーが発生しました (${error})`);
    }
}

function fetchUserInfo(userId) {
    return fetch(`https://api.github.com/users/${encodeURIComponent(userId)}`)
        .then(response => {
            if (!response.ok) {
                return Promise.reject(new Error(`${response.status}: ${response.statusText}`));
            } else {
                return response.json();
            }
        });
}

function getUserId() {
    return document.getElementById("userId").value;
}

function createView(userInfo) {
    return escapeHTML`
    <h4>${userInfo.name} (@${userInfo.login})</h4>
    <img src="${userInfo.avatar_url}" alt="${userInfo.login}" height="100">
    <dl>
        <dt>Location</dt>
        <dd>${userInfo.location}</dd>
        <dt>Repositories</dt>
        <dd>${userInfo.public_repos}</dd>
    </dl>
    `;
}

function displayView(view) {
    const result = document.getElementById("result");
    result.innerHTML = view;
}

function escapeSpecialChars(str) {
    return str
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");
}

function escapeHTML(strings, ...values) {
    return strings.reduce((result, str, i) => {
        const value = values[i - 1];
        if (typeof value === "string") {
            return result + escapeSpecialChars(value) + str;
        } else {
            return result + String(value) + str;
        }
    });
}

アプリケーションを実行すると、次のようになります。 要件を満たすことができたので、このアプリケーションはこれで完成です。

完成したアプリケーション

このセクションのチェックリスト

  • HTMLの組み立てと表示の処理をcreateView関数とdisplayView関数に分離した
  • main関数を宣言し、fetchUserInfo関数が返すPromiseのエラーハンドリングを行った
  • Promiseチェーンを使ってfetchUserInfo関数をリファクタリングした
  • Async Function を使ってmain関数をリファクタリングした
  • index.html<input>タグを追加し、getUserId関数でユーザーIDを取得した

この章で作成したアプリは次のURLで確認できます。