Promiseを活用する

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

関数の分割

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

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

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

function getUserInfo(userId) {
    fetch(`https://api.github.com/users/${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イベントで呼び出す関数もこれまでのgetUserInfo関数からmain関数に変更します。

<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <title>Ajax Example</title>
  </head>
  <body>
    <h2>GitHub User Info</h2>
    <button onclick="main();">Get user info</button>
    <div id="result"></div>
    <script src="index.js"></script>
  </body>
</html>

Promiseのエラーハンドリング

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

次のコードでは、getUserInfo関数から返されたPromiseオブジェクトを、main関数でエラーハンドリングしてログを出力します。 getUserInfo関数ではネットワークエラーとサーバーエラーを投げています。 投げられたエラーはcatchのコールバック関数で第1引数として受け取れます。

function main() {
    getUserInfo("js-primer-example")
        .catch((error) => {
            // Promiseのコンテキスト内で発生したエラーを受け取る
            console.error(`エラーが発生しました (${error})`);
        });
}

function getUserInfo(userId) {
    // fetchの戻り値のPromiseをreturnする
    return fetch(`https://api.github.com/users/${userId}`)
        .then(response => {
            if (!response.ok) {
                // サーバーエラーを投げる
                throw new Error(`${event.target.status}: ${event.target.statusText}`);
            } else {
                return response.json().then(userInfo => {
                    // HTMLの組み立て
                    const view = createView(userInfo);
                    // HTMLの挿入
                    displayView(view);
                });
            }
        }).catch(error => {
            // ネットワークエラーを投げる
            throw new Error("ネットワークエラー");
        });
}

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

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

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

さて、今のgetUserInfo関数ではFetch APIが返したPromiseのthenでHTMLの組み立てと表示も行っています。 このPromiseチェーンを次のように書き換えてみましょう。 getUserInfo関数では、Fetch APIが返すPromiseのthenメソッドで、Reponse#jsonメソッドの戻り値を返しています。 Reponse#jsonメソッドの戻り値はJSONオブジェクトで解決されるPromiseなので、次のthenではユーザー情報のJSONオブジェクトが渡されます。 同じように、userInfoを受け取った関数はcreateView関数を呼び出し、その戻り値を次のthenに渡しています。

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

function getUserInfo(userId) {
    return fetch(`https://api.github.com/users/${userId}`)
        .then(response => {
            if (!response.ok) {
                throw new Error(`${event.target.status}: ${event.target.statusText}`);
            } else {
                // JSONオブジェクトで解決されるPromiseを返す
                return response.json();
            }
        }).catch(error => {
            throw new Error("ネットワークエラー");
        });
}

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を付与しておきます。

<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>タグから値を受け取るための処理を追加すると、最終的に次のようになります。

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

function getUserInfo(userId) {
    return fetch(`https://api.github.com/users/${userId}`)
        .then(response => {
            if (!response.ok) {
                throw new Error(`${event.target.status}: ${event.target.statusText}`);
            } else {
                return response.json();
            }
        }).catch(error => {
            throw new Error("ネットワークエラー");
        });
}

function getUserId() {
    const value = document.getElementById("userId").value;
    return encodeURIComponent(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関数を宣言し、getUserInfo関数が返すPromiseのエラーハンドリングをおこなった
  • Promiseチェーンを使ってgetUserInfo関数をリファクタリングした
  • Async Function を使ってmain関数をリファクタリングした
  • index.html<input>タグを追加し、getUserId関数でユーザーIDを取得した