はじめに
こちらの記事でPromiseについてまとめましたが、まだ非同期処理についての理解が足りていないなと感じています。
今回は非同期処理を書くうえで避けては通れないasync/awaitについて深堀りします。
async/awaitとは何か
async/awaitは、JavaScriptで非同期処理を扱うための構文です。
Promiseベースの非同期処理をより簡潔に、同期的な見た目で書くことができます。
それぞれ以下の役割があります。
-
async
:非同期関数を定義するために使用する -
await
:Promiseの結果が返されるまで処理を一時停止する
async/awaitの特徴
async/awaitには、以下のような特徴があります。
-
読みやすさの向上
Promiseの.then()
チェーンよりも、直感的に読めるコードになります -
エラーハンドリングの簡素化
try-catch文を使って、同期処理と同じようにエラーをキャッチできます -
デバッグのしやすさ
通常の関数と同じようにステップ実行できるので、デバッグが容易になります -
Promiseとの互換性
async関数は常にPromiseを返すので、既存のPromiseベースのコードと組み合わせやすいです
async/awaitの基礎的な使い方
const fetchUserData = async (userId) => {
try {
console.log(`Fetching data for user ${userId}...`);
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const userData = await response.json();
console.log('User data:', userData);
return userData;
} catch (error) {
console.error('Failed to fetch user data:', error);
throw error;
}
};
const main = async () => {
try {
const user = await fetchUserData(123);
console.log(`Hello, ${user.name}!`);
} catch (error) {
console.error('Error in main process:', error);
}
};
main();
このコードでは、async
キーワードを使って非同期関数を定義し、await
を使ってPromiseの解決を待っています。エラーハンドリングはtry-catch文で行っています。
async/awaitとPromiseの比較
同じ処理を両方の方法で書いてみます。
// Promiseを使った場合
// Promiseを使った場合
const fetchDataPromise = () => {
return fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => {
console.log('データを取得しました:', data);
return data;
})
.catch(error => {
console.error('エラーが発生しました:', error);
throw error;
});
};
// async/awaitを使った場合
const fetchDataAsync = async () => {
try {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log('データを取得しました:', data);
return data;
} catch (error) {
console.error('エラーが発生しました:', error);
throw error;
}
};
async/awaitを使った方が、コードの流れが直感的で読みやすくなっていることが分かります。
特に、複雑な非同期処理を扱う場合に、その差が顕著になります。
async/awaitの注意点
トップレベルでの使用制限
awaitキーワードは、async関数内かモジュールのトップレベルでのみ使用可能です。
通常のJavaScriptファイル(非モジュール環境)
// エラー(async関数外のため)
const data = await fetch('https://api.example.com/data');
// OK
const fetchData = async () => {
const data = await fetch('https://api.example.com/data');
return data;
};
// エラー(async関数でないため)
const normalFunction = () => {
const data = await fetch('https://api.example.com/data');
}
このファイルを<script src="script.js"></script>
として読み込む場合、このファイルはモジュールではないため、トップレベル(関数の外)でのawait使用はエラーとなります。
JavaScriptモジュール環境
// OK
const response = await fetch('https://api.example.com/data');
const data = await response.json();
console.log(data);
// OK
const fetchData = async () => {
const data = await fetch('https://api.example.com/data');
return data;
};
このファイルを以下のいずれかの方法で使用する場合、トップレベルでのawaiの使用が許可されます。
-
<script type="module" src="module.js"></script>
として読み込む -
.mjs
拡張子を持つファイルとして使用する -
package.json
で"type": "module"
が指定された環境で使用する
モジュールのトップレベルでawaitが許可されるのはES2022以降からになります。
パフォーマンスへの影響
awaitキーワードは、Promiseが解決されるまで実行を一時停止します。
これは便利ですが、並列で実行可能な処理を逐次実行してしまう可能性があります。
// 悪い例(逐次実行)
const fetchDataSequential = async () => {
const data1 = await fetchData1(); // 完了を待ってから次へ
const data2 = await fetchData2(); // data1の取得後に開始
return [data1, data2];
};
// 良い例(並列実行)
const fetchDataParallel = async () => {
const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
return [data1, data2];
};
fetchDataSequential
の場合、fetchData1
の完了を待ってからfetchData2
が開始されます。
一方、fetchDataParallel
では両方の関数が同時に開始され、どちらも完了するまで待機します。
パフォーマンスの差を実感するために、実際の時間を計測してみましょう。
const measureTime = async (fn) => {
const start = Date.now();
await fn();
console.log(`Execution time: ${Date.now() - start}ms`);
};
// それぞれ2秒かかる非同期関数を定義
const fetchData1 = () => new Promise(resolve => setTimeout(() => resolve('data1'), 2000));
const fetchData2 = () => new Promise(resolve => setTimeout(() => resolve('data2'), 2000));
measureTime(fetchDataSequential); // 約4000ms
measureTime(fetchDataParallel); // 約2000ms
この例では、並列実行が逐次実行の約半分の時間で完了することがわかります。
実際のアプリケーションでは、このような最適化が重要なパフォーマンスの向上をもたらす可能性があります。
エラーハンドリングの範囲
async/await を使用する際、エラーハンドリングの範囲に注意が必要です。
try-catch ブロックは同期的なエラーと非同期的なエラーの両方をキャッチできますが、その範囲は慎重に設定する必要があります。
const riskyOperation = async () => {
throw new Error('Oops!');
};
const main = async () => {
try {
await riskyOperation();
} catch (error) {
console.error('エラーをキャッチしました:', error);
}
};
main();
この例では、riskyOperation内で発生したエラーはmain関数内の try-catch ブロックでキャッチされます。
これはawaitキーワードによって、非同期的なエラーが同期的なエラーとして扱われるためです。
しかし、次のような場合には注意が必要です。
const delayedRiskyOperation = () => {
setTimeout(() => {
throw new Error('Delayed Oops!');
}, 1000);
};
const main = async () => {
try {
delayedRiskyOperation(); // awaitしていない
console.log('この行は実行されます');
} catch (error) {
console.error('このcatchブロックは実行されません:', error);
}
};
main().catch(error => console.error('ここでもキャッチされません:', error));
process.on('uncaughtException', (error) => {
console.error('未キャッチの例外:', error);
});
この例では、delayedRiskyOperation
がPromiseを返さず、非同期的にエラーをスローしているため、try-catch ブロックではキャッチできません。
また、main関数の戻り値である Promiseも、このエラーをキャッチしません。
このような状況を適切に処理するには、非同期操作を常にPromiseでラップし、それをawaitするか、または適切なエラーイベントリスナー(例:Node.js の uncaughtException)を使用する必要があります。
const safeDelayedRiskyOperation = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error('Safe Delayed Oops!'));
}, 1000);
});
};
const main = async () => {
try {
await safeDelayedRiskyOperation();
} catch (error) {
console.error('エラーを適切にキャッチしました:', error);
}
};
main();
この改善版では、非同期操作がPromiseでラップされ、awaitされているため、エラーを適切にキャッチできます。
これらの注意点を理解し、適切に対処することで、async/await を使用した堅牢で効率的な非同期コードを書くことができます。
まとめ
今回は、async/awaitについて詳しく解説しました。
なんとなくで使っていましたが、Promiseをより見やすくするための書き方なのだということがわかりました。
非同期処理を書くときはいつも深い理解ができず、雰囲気で実装してしまっていることが多かったのですが、Promise、async/awaitについての理解を深めることができたので、これからはより実践的なコードが書けるような気がしています。