あらためてPromiseとasync/awaitを理解する
株式会社デパートでフロントエンドエンジニアしてます熊谷と申します!
今日はJavaScriptの中でも特に重要な概念の1つ、「Promise」について掘り下げていきましょう。
非同期処理には欠かせない存在ですが、とっつきにくく難しそうに感じてしまいます。
根はいいやつなんで、大丈夫です!一緒に使いこなしましょう!
Promiseって何?なんで必要なの?
Promiseは非同期処理を扱うためのオブジェクトです。簡単に言えば、「今はまだ結果がわからないけど、いつか(近い未来に)結果を返すよ」という約束(Promise)のオブジェクトなのです。
最近のウェブサイトでは、例えばAPIからデータを取得したり、大きな画像をロードしたり、複雑な計算を行ったりする時に使います。
これらの処理は時間がかかるので、その間もユーザーインターフェースを固まらせずに、他の処理を続けられるようにするんです。
昔はどうしてたの?
昔は主に2つの方法がありました。コールバック関数とイベント駆動です。
コールバック関数
処理に時間のかかる関数にはコールバックという手法を用いて、処理を次の処理をする関数に引き継いでいます。
具体的には、引数で受け取った関数を処理の一番最後に実行するというものです。
コールバック関数を使うと、こんな感じになってしまいます。
loadUserData(function(userData) {
// ユーザデータを取得した後に実行する
loadUserPosts(userData.id, function(posts) {
// 取得したユーザデータをもとにそのユーザの記事を取得する
loadPostComments(posts[0].id, function(comments) {
// そのユーザの最新記事のコメントを取得する
updateUI(userData, posts, comments, function() {
// 今まで取得したデータをもとに画面表示を更新する
showNotification('データのロード完了!', function() {
// 全部完了したら画面に通知を表示する
// ...そしてもっと深くネストされていく...
});
});
});
});
});
見てください、この恐ろしいネストの深さ!これを「コールバック地獄」と呼びます。読みにくいし、デバッグも大変ですよね。
イベント駆動
もう一つの方法はイベント駆動です。
イベントならネストしないし、読みやすくなると思うのですが、、、
実際に使用してみた例はこちら
const dataLoader = {
loadUser: function() {
// ユーザーデータをロード
this.trigger('userLoaded', userData);
},
loadPosts: function(userId) {
// 投稿をロード
this.trigger('postsLoaded', posts);
},
// ... 他のメソッド ...
};
dataLoader.on('userLoaded', function(userData) {
// ユーザーデータを処理
});
dataLoader.on('postsLoaded', function(posts) {
// 投稿を処理
});
dataLoader.loadUser();
これはこれで便利なんですが、複雑な非同期処理を扱うときはちょっと大変です。
イベントに名前を付けるというタスクが発生し、ちゃんとルールを設定しないと管理が煩雑になります。
jQueryが先駆者だった!
上記のことを解決するため、PromiseがES2015(ES6)に登場する前に、jQueryが先駆けて実装していました。
「Deferred」というオブジェクトを使って、こんな感じで書けました。
// AJAX要求の例
$.ajax({
url: 'https://api.example.com/user'
})
.done(function(userData) {
console.log('ユーザーデータ取得成功:', userData);
})
.fail(function(error) {
console.error('エラー:', error);
});
// カスタムDeferredオブジェクトの例
function asyncOperation() {
var deferred = $.Deferred();
setTimeout(function() {
if (Math.random() > 0.5) {
deferred.resolve('操作成功!');
} else {
deferred.reject('操作失敗...');
}
}, 1000);
return deferred.promise();
}
asyncOperation()
.done(function(result) {
console.log(result);
})
.fail(function(error) {
console.error(error);
});
これはかなりスッキリしていますね。jQueryのDeferredは、AJAX要求だけでなく、カスタムの非同期操作にも使えるものでした。
でも、これはJavaScriptの標準機能ではありませんでした。
Promiseの使い方
満を持してES2015(ES6)にて標準化とされたPromiseの使い方を見ていきましょう!
よくある例として、APIからデータを取得する場合を考えてみます。ここでは現代的な非同期通信APIの`fetch`関数を使います。
function fetchUserData(userId) {
return fetch(`https://api.example.com/users/${userId}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
});
}
fetchUserData(123)
.then(userData => {
console.log('ユーザーデータ取得成功:', userData);
})
.catch(error => {
console.error('エラー発生:', error);
});
これで非同期処理がかなりスッキリ書けるようになりました!`fetch`関数自体がPromiseを返すので、とても使いやすいですね。
then地獄問題
でも、複雑な処理を書いていくと、またこんな感じになってしまうのです…
fetchUserData(123)
.then(userData => {
return fetchUserPosts(userData.id);
})
.then(posts => {
return fetchPostComments(posts[0].id);
})
.then(comments => {
return updateUI(userData, posts, comments);
})
.then(() => {
return showNotification('データのロード完了!');
})
.catch(error => {
console.error('エラー:', error);
});
これを「then地獄」と呼びます(勝手に呼んでいます)。
コールバック地獄よりはマシですが、まだ読みにくいですね。
async/awaitを使おう!
そこで登場するのが、async/await構文です。これはPromiseをさらに直感的に扱うための仕組みで、ES2017で導入されました。then地獄を次のように書き換えられます。
async function loadUserDataChain() {
try {
const userData = await fetchUserData(123);
const posts = await fetchUserPosts(userData.id);
const comments = await fetchPostComments(posts[0].id);
await updateUI(userData, posts, comments);
await showNotification('データのロード完了!');
} catch (error) {
console.error('エラー:', error);
}
}
loadUserDataChain();
すっきり!非同期処理なのに、同期処理のように読めますね。
async/awaitの仕組み
asyncキーワードは関数の前に付けて使います。この関数は自動的にPromiseを返すようになります。
awaitキーワードはPromiseが解決されるまで処理を一時停止し、解決されたら結果を返します。
awaitはasync関数の中でのみ使用できます。
async/awaitの利点
読みやすさ:コードが同期処理のように書けるので、理解しやすくなります。
エラーハンドリング:try-catch文を使って、同期処理と同じようにエラーを扱えます。
デバッグのしやすさ:コードの流れが直線的なので、デバッグが容易になります。
注意点
トップレベルでの使用:一部の環境を除き、トップレベルでawaitは使用できません。常にasync関数内で使用しましょう。
並列処理:awaitを使うと処理が逐次的になるため、複数の非同期処理を並列で行いたい場合は注意が必要です。
// 逐次処理(遅い)
const result1 = await asyncFunc1();
const result2 = await asyncFunc2();
// 並列処理(速い)
const [result1, result2] = await Promise.all([asyncFunc1(), asyncFunc2()]);
エラーハンドリング:async関数内でthrowされた例外は、返されるPromiseのrejectionとなります。適切に.catch()またはtry-catchを使ってハンドリングしましょう。
ブラウザサポート:IE11など古いブラウザではサポートされていないので、必要に応じてBabelなどのトランスパイラを使用しましょう。
this束縛:アロー関数を使う場合、thisの束縛に注意が必要です。クラスのメソッドなどでは通常の関数宣言を使うことをお勧めします。
class MyClass {
async myMethod() { // OK
this.property = await someAsyncOperation();
}
badMethod: async () => { // thisが期待通りに動作しない可能性がある
this.property = await someAsyncOperation();
}
}
async/awaitを使いこなすことで、Promiseベースの非同期処理をより簡潔に、そして理解しやすく書くことができます。ただし、その仕組みや注意点をしっかり理解した上で使用することが大切です。
まとめ
Webアプリケーションが複雑化し、非同期通信が当たり前になったこの時代、Promiseは本当に役立つ機能です。APIリクエスト、ファイルの読み込み、アニメーション処理など、様々な場面で活躍します。複雑な非同期処理も読みやすく、管理しやすくなります。
最新のブラウザではfetch関数が標準でサポートされており、Promiseベースの非同期処理を簡単に書けるようになっています。さらに、async/await構文を使えば、非同期コードをさらに直感的に書くことができます。
Promiseとasync/awaitをマスターすれば、より効率的で堅牢なクライアントサイドコードが書けるようになりますよ。がんばって使いこなしていきましょう!