Promiseを活用する
ここまでで、XHRを使ってAjax通信を行い、サーバーから取得したデータを表示できました。 最後に、Promiseを使ってソースコードを整理することで、エラーハンドリングをしっかり行います。
関数の分割
まずは、大きくなりすぎたgetUserInfo
関数を整理しましょう。
この関数では、XHRを使ったデータの取得・HTML文字列の組み立て・組み立てたHTMLの表示をしています。
そこで、HTML文字列を組み立てるcreateView
関数とHTMLを表示するdisplayView
関数を作り、処理を分割します。
また、後述するエラーハンドリングを行いやすくするため、アプリケーションにエントリポイントを設けます。
index.jsに新しくmain
関数を作り、その中でgetUserInfo
関数を呼び出すようにします。
function main() {
getUserInfo("js-primer-example");
}
function getUserInfo(userId) {
const request = new XMLHttpRequest();
request.open("GET", `https://api.github.com/users/${userId}`);
request.addEventListener("load", (event) => {
if (event.target.status !== 200) {
console.error(`${event.target.status}: ${event.target.statusText}`);
return;
}
const userInfo = JSON.parse(event.target.responseText);
const view = createView(userInfo);
displayView(view);
});
request.addEventListener("error", () => {
console.error("Network Error");
});
request.send();
}
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>
XHRをPromiseでラップする
次に、getUserInfo
関数で行っているXHRの処理を整理します。
これまではXHRのコールバック関数の中で処理していましたが、これをPromiseを使った処理に書き換えます。
コールバック関数を使うと、ソースコードのネストが深くなったり、例外処理が複雑になったりします。
Promiseを用いることで、可読性を保ちながらエラーハンドリングを簡単に行えます。
コールバック関数を使う形式のAPIをPromiseに置き換えるのは、次のコードのようにnew Promise()
を用いるのが一般的です。
Promiseのコンストラクタには、resolve
とreject
の2つの関数オブジェクトを引数とする関数を渡します。
ひとつめの引数は非同期処理が成功したときに呼び出す関数で、ふたつめは失敗した時に呼び出す関数です。
new Promise((resolve, reject) => {
// ここで非同期処理を行う
});
Promiseのコンストラクタに渡す関数で、XHRの処理を行います。
作成されたPromiseは成功か失敗のどちらかで完了させなければなりません。
非同期処理が成功したら第1引数のresolve
関数を、失敗なら第2引数のreject
関数を呼び出します。
作成したPromiseのオブジェクトをreturn
することで、getUserInfo
関数はPromiseを返す関数になりました。
getUserInfo
関数がPromiseを返すことで、それを呼び出すmain
関数の方で非同期処理の結果を扱えるようになります。
function getUserInfo(userId) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", `https://api.github.com/users/${userId}`);
request.addEventListener("load", (event) => {
if (event.target.status !== 200) {
console.error(`${event.target.status}: ${event.target.statusText}`);
reject(); // ステータスコードが200じゃないので失敗
}
const userInfo = JSON.parse(event.target.responseText);
const view = createView(userInfo);
displayView(view);
resolve(); // 完了
});
request.addEventListener("error", () => {
console.error("Network Error");
reject(); // 通信エラーが発生したので失敗
});
request.send();
});
}
エラーハンドリング
このままではPromiseに置き換えた意味がないので、Promiseを使ったエラーハンドリングを行いましょう。
Promiseのコンテキスト内で発生したエラーは、Promise#catch
メソッドを使って一箇所で受け取れます。
次のコードでは、getUserInfo
関数から返されたPromiseオブジェクトを使い、エラーが起きた時にログを出力します。
reject
関数に渡したエラーはcatch
のコールバック関数で第1引数として受け取れます。
function main() {
getUserInfo("js-primer-example")
.catch((error) => {
console.error(`エラーが発生しました (${error})`);
});
}
function getUserInfo(userId) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", `https://api.github.com/users/${userId}`);
request.addEventListener("load", (event) => {
if (event.target.status !== 200) {
reject(new Error(`${event.target.status}: ${event.target.statusText}`));
}
const userInfo = JSON.parse(event.target.responseText);
const view = createView(userInfo);
displayView(view);
resolve();
});
request.addEventListener("error", () => {
reject(new Error("ネットワークエラー"));
});
request.send();
});
}
Promiseチェーンへの置き換え
PromiseはPromise#then
メソッドを使うことで、複数の処理の連鎖を表現できます。
複数の処理をthen
で分割し、連鎖させたものを、ここではPromiseチェーンと呼びます。
基本的に、then
はコールバック関数の戻り値をそのまま次のthen
へ渡します。
ただし、コールバック関数の戻り値がPromiseである場合はその完了を待ち、Promiseの結果の値を次のthen
に渡します。
つまり、then
のコールバック関数が同期処理から非同期処理に変わったとしても、次のthen
が受け取る値の型は変わらないということです。
Promiseチェーンを使って処理を分割する利点は、同期処理と非同期処理を区別せずに連鎖できることです。
一般に、同期的に書かれた処理を後から非同期処理へと変更することは、全体を書き換える必要があるため難しいです。
そのため、最初から処理を分けておき、処理をthen
を使って繋ぐことで、変更に強いコードを書くことができます。
どのように処理を区切るかは、それぞれの関数が受け取る値の型と、返す値の型に注目するのがよいでしょう。
Promiseチェーンで処理を分けることで、それぞれの処理が簡潔になりコードの見通しがよくなります。
さて、今のgetUserInfo
関数ではloadイベントのコールバック関数でHTMLの組み立てと表示も行っています。
これをPromiseチェーンを使うように書き換えると、次のようにできます。
getUserInfo
関数では、resolve
関数にuserInfo
を渡し、次のthen
でコールバック関数の引数として受け取っています。
同じように、userInfo
を受け取った関数はcreateView
関数を呼び出し、その戻り値を次のthen
に渡しています。
function main() {
getUserInfo("js-primer-example")
.then((userInfo) => createView(userInfo))
.then((view) => displayView(view))
.catch((error) => {
console.error(`エラーが発生しました (${error})`);
});
}
function getUserInfo(userId) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", `https://api.github.com/users/${userId}`);
request.addEventListener("load", (event) => {
if (event.target.status !== 200) {
reject(new Error(`${event.target.status}: ${event.target.statusText}`));
}
const userInfo = JSON.parse(event.target.responseText);
resolve(userInfo);
});
request.addEventListener("error", () => {
reject(new Error("ネットワークエラー"));
});
request.send();
});
}
[コラム] Fetch API
Fetch APIとは、ページの外部からリソースを取得するためのインターフェースを定義した、Webブラウザの標準APIです。
Fetch APIはfetch
関数など、リソースを取得するためのAPIを定義しています。
fetch
関数はPromiseを返すのが特徴です。
たとえば、本章で扱ったXHRによるgetUserInfo
関数は、fetch
関数を使うと次のようになります。
function getUserInfo(userId) {
// 暗黙にGETリクエストとなる
// Responseオブジェクトがthenに渡される
return fetch(`https://api.github.com/users/${userId}`)
.then(response => {
if (!response.status !== 200) {
throw new Error(`${response.status}: ${response.statusText}`);
}
// jsonメソッドは、レスポンスボディをJSONとしてパースしたPromiseオブジェクトを返す
return response.json();
}, error => {
throw new Error("ネットワークエラー");
});
}
今回のユースケースではFetchへの置き換えが可能ですが、コールバック関数をPromiseでラップする手法を学ぶために、あえてXHRを利用しています。 また、プログレスイベントやリクエストの中断などXHRでしか使えない機能もあるため、常にFetchで置き換えられるわけではありません。
Fetchの詳しい使い方についてはFetchに関するドキュメントを参照してください。
ユーザー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>
タグから値を受け取るための処理を追加すると、最終的に次のようになります。
function main() {
const userId = getUserId();
getUserInfo(userId)
.then((userInfo) => createView(userInfo))
.then((view) => displayView(view))
.catch((error) => {
console.error(`エラーが発生しました (${error})`);
});
}
function getUserInfo(userId) {
return new Promise((resolve, reject) => {
const request = new XMLHttpRequest();
request.open("GET", `https://api.github.com/users/${userId}`);
request.addEventListener("load", (event) => {
if (event.target.status !== 200) {
reject(new Error(`${event.target.status}: ${event.target.statusText}`));
}
const userInfo = JSON.parse(event.target.responseText);
resolve(userInfo);
});
request.addEventListener("error", () => {
reject(new Error("ネットワークエラー"));
});
request.send();
});
}
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function escapeHTML(strings, ...values) {
return strings.reduce((result, string, i) => {
const value = values[i - 1];
if (typeof value === "string") {
return result + escapeSpecialChars(value) + string;
} else {
return result + String(value) + string;
}
});
}
アプリケーションを実行すると、次のようになります。 要件を満たすことができたので、このアプリケーションはこれで完成です。