【ES2017】async/await の基礎(後編)
前回に続き、Async Function(async/await)の使い方について学びます。
今回はおもにAsync Functionを使う上での注意点にフォーカスを当てていきたいと思います。
Async Functionとコールバック関数の問題
await
式による非同期処理の待機は、Async Functionの中でコールバック関数を定義するケースで思い通りの動作にならないケースがあります。
逐次処理でそのケースを見てみます。
Promiseだけ使ったケース
まず、問題なく動作する通常のPromiseを使った逐次処理です。
main
関数の引数に与えられたパスを逐次フェッチして結果をコンソール出力することを想定したサンプルです。
function dummyFetch(path) { return new Promise((resolve, reject) => { setTimeout(() => { // pathが「/success」始まりならresolve if (path.startsWith('/success')) { resolve({ body: `success to ${path}` }); } else { reject(new Error('Not found.')); } }, 500); }); } function main(paths) { paths.reduce((promise, path) => { return promise .then(() => dummyFetch(path)) .then(value => { console.log(value.body); }) .catch(console.error); }, Promise.resolve()); } main(['/success/foo', '/success/bar', '/success/buz']); /* 実行結果(500ms毎に出力される) -------------- success to /success/foo success to /success/bar success to /success/buz -------------- */
Async Functionとコールバック関数を使ったケース
ではこれを await
式とコールバック関数(forEach
)を使って実装してみます。(main
関数の内容を変更しています)
function dummyFetch(path) { // 略 } async function main(paths) { paths.forEach(async path => { const result = await dummyFetch(path); console.log(result); }); } main(['/success/foo', '/success/bar', '/success/buz']); /* 実行結果(500ms後に全て出力される) -------------- success to /success/foo success to /success/bar success to /success/buz -------------- */
コールバック関数に async
をつけているので大丈夫かと思うかもしれませんが、実際に実行してみると、500ms後に、3つすべての結果が出力されてしまいます。
不具合の理由
なぜかというと、非同期処理の完了を待つのはコールバック関数内のawait 式の箇所で行われていますが、外側では dummyFetch
関数の完了を待つことなく進んでいるためです。
下記で確認してみます。
async function main(paths) { console.log('1. main関数 start'); paths.forEach(async path => { console.log('2. dummyFetch関数 start'); const result = await dummyFetch(path); console.log(result.body); console.log('4. dummyFetch関数 end'); }); console.log('3. main関数 end'); } main(['/success/foo', '/success/bar', '/success/buz']); /* 実行結果 -------------- 1. main関数 start 2. dummyFetch関数 start -> 3回連続出力される 3. main関数 end (500ms毎に出力される) success to /success/foo 4. dummyFetch関数 end success to /success/bar 4. dummyFetch関数 end success to /success/buz 4. dummyFetch関数 end -------------- */
コールバック関数内の console.log('2. dummyFetch関数 start ');
が一気に3回連続で実行されているのが分かります。
本来は1つ目のコールバック関数内の非同期処理が終わったら、2つ目のコールバック関数を実行するというふうになってもらいたいのが、非同期処理が終わることを待つことなく次のコールバック関数が実行されてしまっているというわけです。
解決方法
コールバック関数を使わずに、for
文または for-of
文を使うことで解決できます。
// for文を使った場合 async function main(paths) { for (let i = 0; i < paths.length; i++) { const result = await dummyFetch(paths[i]); console.log(result.body); } } // for-of文を使った場合 async function main(paths) { for (let i = 0; i < paths.length; i++) { const result = await dummyFetch(paths[i]); console.log(result.body); } }
非同期のコールバック関数内のエラーをキャッチできない
await
の右辺に関数が渡された場合、呼び出し先の関数内(Promise内部を含む)で起こる同期的な例外、またはreject
されたPromiseが返った場合は呼び出し元で、try-catch
によるエラーハンドリングが可能です。
下記は、main
関数の引数に渡す配列に、/success
から始まるパスではないものを渡しています。
/success
から始まらない場合は reject
となるので、それが正しくキャッチされているのが分かります。
function dummyFetch(path) { // 略 } async function main(paths) { for (const path of paths) { try { const result = await dummyFetch(path); console.log(result.body); } catch (error) { console.log(error); } } } main(['/success/foo', '/fail/bar', '/fail/buz']); // 2回目、3回目はrejectするパスを渡している /* 実行結果(500ms毎に出力される) -------------- success to /success/foo Not found. Not found. -------------- */
しかし、次のような非同期のコールバック関数内でのエラーはキャッチできず、Uncaught Error: 予期せぬエラー
とコンソール上に出力され、処理も途中で止まってしまいます。
(dummyFetch
関数の setTimeout
内でエラーを throw
しています)
function dummyFetch(path) { return new Promise((resolve, reject) => { setTimeout(() => { // 非同期コールバック関数内でエラーを発生させる throw new Error('予期せぬエラー'); // pathが「/success」始まりならresolve if (path.startsWith('/success')) { resolve({ body: `success to ${path}` }); } else { reject(new Error('Not found.')); } }, 1000); }); } async function main(paths) { for (const path of paths) { try { const result = await dummyFetch(path); console.log(result.body); } catch (error) { console.log(error.message); } } } main(['/success/foo', '/success/bar', '/success/buz']); /* 実行結果 -------------- Uncaught Error: 予期せぬエラー -------------- */
理由
そもそも例外は関数の呼び出し履歴(コールスタック)をさかのぼって伝播していきます。
(参考:JavaScriptと非同期のエラー処理 - Yahoo! JAPAN Tech Blog)
非同期のコールバック関数内のコンテキストでは呼び出し元がグローバルとなるため、このdummyFetch
関数の例外とならないため、キャッチができないと思われます。
グローバルでエラーとなったものは、window.onerror
等で拾うことができます。
試しに下記コードを追加してみると、コンソールでエラーメッセージを出力されるのが確認できました。
window.addEventListener('error', event => { console.log(event.message); // Uncaught Error: 予期せぬエラー });
解決方法
非同期コールバック関数内の例外は throw
ではなく reject()
で伝えるようにすればエラーハンドリングができます。
今回は下記のように setTimeout
のコールバック関数を try-catch
でラップし、キャッチした error
を reject
で伝えています。
こうすることで、途中で処理が止まることなくエラーハンドリングが可能になります。
function dummyFetch(path) { return new Promise((resolve, reject) => { setTimeout(() => { try { throw new Error('予期せぬエラー'); // pathが「/success」始まりならresolve if (path.startsWith('/success')) { resolve({ body: `success to ${path}` }); } else { reject(new Error('Not found.')); } } catch(error) { reject(error); } }, 1000); }); } async function main(paths) { for (const path of paths) { try { const result = await dummyFetch(path); console.log(result.body); } catch (error) { console.log(error.message); } } } main(['/success/foo', '/success/bar', '/success/buz']); /* 実行結果(500ms毎に出力される) -------------- 予期せぬエラー 予期せぬエラー 予期せぬエラー -------------- */
まとめ
Async Functionではこのようにコールバック関数で思わぬ不具合が生じる可能性があります。
このことから、そもそもAsync Functionが使える環境ではPromiseと await
式を使ってコードを書くようにして、非同期のコールバックはなるべく使わないようにする方が望ましいでしょう。