【ES2017】async/await の基礎(前編)
Promiseの理解はできたので async/await について基礎から学んでいきたいと思います。
async/await とは
async/await は、JavaScriptで非同期を扱うための新しい構文です。
Async Function
と Async Function内で使える await
式を合わせて async/await と呼ばれていると思われます。
正式名称としては Async Function
と呼ぶようなので、この記事でも基本的には Async Functionという言葉をベースとして話していきます。
参考:ECMAScript® 2019 Language Specification
Promiseを使えば従来のコールバック関数を使った記述よりも見やすく簡潔に非同期を扱えますが、あくまでPromiseはただのビルトインオブジェクトで、then
や catch
などを使ったメソッドチェーンで書かなければならないなどの制限があります。
こういった書き方の制限を解決するために ES2017 から正式に Async Functionが導入されました。
Async Functionを使えば、非同期処理をまるで同期処理のように記述することができます。
簡単なサンプル
まずは簡単な例として、fetchで取得したデータを表示するサンプルを、Promiseを使った方法と、Async Functionを使った書き方で比べてみます。
Promiseを使った例
fetch('http://httpbin.org/get') .then(res => res.json()) .then(console.log); // {args: {…}, headers: {…}, ...}
Async Functionを使った例
async function fetchData() { const res = await fetch('http://httpbin.org/get'); const json = await res.json(); console.log(json); // {args: {…}, headers: {…}, ...} }; fetchData();
Async Functionでは then
を使っておらず、同期処理の流れのように記述できているのが分かります。
代わりに、関数宣言の前に async
というキーワードと、Promiseオブジェクトを返す処理の前に await
というキーワードが付いていますが、詳細は後述していきます。
Async Functionの定義方法
Async Functionは下記のように、関数の前に async
を付けることで定義できます。
// 関数宣言 async function doAsync() { } // 関数式 const doAsync = async () => { }; // 仮引数を囲う () を省略した関数式 const doAsync = async message => { }; // メソッド const obj = { async doAsync() { } }; // クラスメソッド class Hoge { async doAsync() { } } // TypeScript でのクラスメソッド class Fuga { public async doAsync() { } } // 即時関数 (async () => { })();
Async Function の役割
Async Functionの役割は主に2つあります。
- 常にPromiseインスタンスを返す関数を定義する
- Async Function 内で
await
式が使える
順に詳しく見ていきます。
Async Functionは常にPromiseインスタンスを返す
実際にいくつかの値を渡して試してみます。
// 値を返した場合 async function doAsync1() { return 1; } doAsync1().then(value => { console.log(value) // 1 }); // Promiseオブジェクト(Fulfilled)を返した場合 async function doAsync2() { return Promise.resolve(2); } doAsync2().then(value => { console.log(value) // 2 }); // Promiseオブジェクト(Rejected)を返した場合 async function doAsync3() { return Promise.reject(new Error('エラーです!')); } doAsync3().catch(error => { console.log(error.message); // エラーです! }); // 例外が発生した場合 async function doAsync4() { throw new Error('例外発生!'); } doAsync4().catch(error => { console.log(error.message); // 例外発生! }); // 何も返さない場合 async function doAsync5() {} doAsync5().then(value => { console.log(value); // undefined });
このように Async Function の返り値は必ずPromiseインスタンスであることが保証されており、返す値は下記の3つになります。
- 値を return した場合は、その返り値をもつ
Fulfilled
状態のPromiseを返す - Promiseを return した場合は、その返り値のPromiseをそのまま返す
- 例外が発生した場合はそのエラーをもつ
Rejected
状態のPromiseを返す
これはPromiseの then()
と同じほぼ同じ挙動なのが分かります。
then()
は内部的な処理として、与えられたコールバック関数が return
した値を Promise.resolve()
に渡していますが、それと同じように、Async Functionは return
した値の代わりに Promise.resolve(返り値)
のように返り値をラップしたPromiseインスタンスを返します。
// 下記2つは同じ意味 // Async Function async function doAsync1() { return 1; } // 通常の関数 function doFn1() { return Promise.resolve(1); }
このように Async Function の返り値はPromiseインスタンスなので、それらを呼び出した戻り値はすべてPromiseという前提で実装をすることができます。
Promise.all()
で並列処理させたり、await で処理の完了を待つこともできます。
await 式について
await
は右辺のPromiseインスタンスが Fulfilled
または Rejected
になるまでAsync Function内での処理をその場で一時停止します。
そしてPromiseインスタンスの状態が変わるとその後の処理を再開します。
その時にPromiseの解決値を取り出して変数への代入を行うことができます。
簡単なサンプルで見てみます。
下記は、1秒後に Fulfilled
か Rejected
どちらかの状態になるPromiseを返す saveOrDie
関数を、Async Functionである doAsync
関数内で await
式で呼び出して、その解決値をコンソール上に出力するサンプルです。
function saveOrDie() { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.floor(Math.random() * 10) % 2 === 0) { resolve('saved!!!'); } else { reject(new Error('died...')); } }, 1000); }); } async function doAsync() { const result = await saveOrDie(); console.log(result); } doAsync();
確認すると、期待通りに1秒間は処理が停止して、saved!!!
または Uncaught (in promise) Error: died...
とコンソールに出力されました。
Async Functionでのエラーハンドリング
ここで1つ問題なのが、Rejected
になったときに Uncaught (in promise) Error: died...
とエラーのキャッチが出来ていません。
await 式の右辺のPromiseが Rejected
状態になるとその場でエラーを throw
します。
「Async Functionは常にPromiseインスタンスを返す」の節で見たように、エラーが throw
されると呼び出し元で catch
でそのエラーを補足できました。
つまりAsync Functionは Rejected
なPromiseを返しているということになります。
async function doAsync() { const result = await Promise.reject(new Error('エラー!')); console.log(1); // ここは呼ばれない } doAsync() .catch(error => console.error(error.message)); // エラー!
整理すると、await 式でPromiseが Rejected
になった場合、そのAsync Functionは Rejected
なPromiseを返すということになります。
話は戻って、先ほどの問題。
ここで1つ問題なのが、
Rejected
になったときにUncaught (in promise) Error: died...
とエラーのキャッチが出来ていません。
Async Function呼び出し元をcatch メソッドでつなぐ
上記のサンプルコードのように関数呼び出し元で catch
でつなげばエラーハンドリングが可能です。
function saveOrDie() { // 略 } async function doAsync() { const result = await saveOrDie(); console.log(result); } doAsync() .catch(error => console.error(error.message)); // died...
try-catch を使う
await 式がエラーを throw
するということはそのエラーは try-catch
構文でキャッチすることができるということです。
// 略 async function doAsync() { try { const result = await saveOrDie(); console.log(result); } catch(error) { console.error(error.message); } } doAsync();
このように try-catch
を使うと同期処理のように記述できる上に、catch
メソッドを使わなくても良いので、見た目もすっきりします。
エラー処理は呼び出し元に記述するべきか、呼び出し先に記述すべきか
上記のように、try-catch
を使えば呼び出し先のAsync Function 内で通常の同期処理のようにエラーハンドリングができます。
対して、try-catch
を使わなくても呼び出し先のAsync Functionで起きた例外は自動でキャッチされるので、呼び出し元で doAsync().catch(error => ...)
とすればエラーハンドリングが可能です。
どちらも同じことができるわけですが、どちらで行うのがベターなのか?
個人的に悩みました。
Promiseに慣れた手前では、後者の方がパッと分かりやすいですが、 コード同士が離れているのもなんだかよくない気がします。
いろいろ探してみると、下記の言葉を見つけ腹落ちしました。
基本的には、非同期関数は Promise で表現しつつ、使う側は async/await で解決する、という形になるでしょう。
TypeScript入門以前ガイド - mizchi's blog より
やはりasync/await の恩恵を得るためには、呼び出し先で try-catch
の形でエラーハンドリングするのがよさそうです。
awaitは常にPromiseを受け取る
await 式は右辺のPromiseインスタンスの状態変化が完了するまでその場で処理を一時停止して、解決値を返すと述べました。
では右辺にPromise以外の値をあたえたらどうなるのでしょうか?
実際に試してみると、特に問題なく動作しているようです。
async function doAsync() { const result = await 1; console.log(result); // 1 } doAsync();
これは下記コードのようにPromise.resolve()でラップしたのと同じ意味になります。
async function doAsync() { const result = await Promise.resolve(1); console.log(result); } doAsync();
その他にも Promiseではないが thenメソッドを持ったthenable
オブジェクトを渡した場合も、Promise.resolve()
でラップされることでPromiseオブジェクトされます。
このように、await 式は与えられた値を、PromiseでないものもすべてPromiseとして扱うため、常にPromiseを受け取るといえます。
awaitはAsync Functionの直下のみ使用可能
Async Function以外で await 式を使うとシンタックスエラーとなります。
Async Function外でも使えてしまうと、await 式で非同期処理の解決が時間がかかった場合に他の処理も止まってしまうためです。
注意点として、Async Function内のコールバック関数でも awaitは使えません。
たとえば下記のような場合です。
forEach
に渡しているコールバック関数で await 式を使っていますが、シンタックスエラーが起きています。
/** * msミリ秒後にresolveまたはrejectのどちらかになる関数 */ function saveOrDie(ms) { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.floor(Math.random() * 10) % 2 === 0) { resolve('saved!!!'); } else { reject(new Error('died...')); } }, ms); }); } async function doAsync() { const promises = [saveOrDie(1000), saveOrDie(2000)]; promises.forEach(promise => { try { const result = await promise; console.log(result); } catch (error) { console.error(error); } }); } doAsync(); // Uncaught SyntaxError: await is only valid in async function
解決するには、下記のようにコールバック関数も Async Functionにしなくてはいけません。
あくまでAsync Functionの直下であることが前提になります。
// 略 async function doAsync() { const promises = [saveOrDie(1000), saveOrDie(2000)]; promises.forEach(async promise => { try { const result = await promise; console.log(result); // saved!!! } catch (error) { console.error(error); // Error: died... } }); } // 略
Async Function 利用パターン
どのような時にAsync Functionを使うと便利なのか見ていきたいと思います。
非同期の逐次処理
1秒後に 1
、その2秒後に 2
、その3秒後に 3
を逐次配列に挿入して最終的にその配列を出力するサンプルです。
まずは通常のPromiseを使った非同期の逐次処理ですが、then()
をつないでいく形と、Array.prototype.reduce()
をつなぐ形があります。
1. then
をつなぐ
/** * 指定ミリ秒後にresolveする */ function timer(ms) { return new Promise(resolve => { setTimeout(() => { resolve(ms); }, ms); }); } function doAsync1() { const result = []; return timer(1000, () => { return 1; }) .then(value => { console.log(value); result.push(value); return timer(2000, () => { return 2; }); }) .then(value => { console.log(value); result.push(value); return timer(3000, () => { return 3; }); }) .then(value => { result.push(value); console.log(value); return result; }); } doAsync1() .then(result => { console.log('result :', result); // result : [1, 2, 3] });
慣れていれば分かりやすくは感じますが、縦に長くやや冗長に感じられます。
2. Array.prototype.reduce()
を使う
const tasks = { task1() { return timer(1000, () => 1); }, task2() { return timer(2000, () => 2); }, task3() { return timer(3000, () => 3); } }; // 逐次処理する function sequenceTasks(tasks) { const result = []; return tasks.reduce((promise, task) => { return promise .then(task) .then(value => { console.log(value); result.push(value); return result; }); }, Promise.resolve()); } function doAsync2() { return sequenceTasks(Object.values(tasks)); } doAsync2() .then(result => { console.log('result :', result); // result : [1000, 2000, 3000] });
then をつなぐ形よりもコンパクトになりますが、やや複雑になります。
3. Async Fuction を使う
Async Function、await 式を用いると、同期処理のようにシンプルに記述できます。
// 略 async function doAsync3() { const result = []; const result1 = await timer(1000, () => 1); console.log(result1); result.push(result1); const result2 = await timer(2000, () => 2); console.log(result2); result.push(result2); const result3 = await timer(3000, () => 3); console.log(result3); result.push(result3); return result; } doAsync3() .then(result => { console.log('result :', result); // result : [1000, 2000, 3000] });
処理の数が多くなる場合は、下記のように for
文 や for-of
文を使うと簡潔に書くことができます。
// 略 async function doAsync4() { const result = []; const tasksAry = Object.values(tasks); for (task of tasksAry) { const value = await task(); console.log(value); result.push(value); } return result; } doAsync4() .then(result => { console.log('result :', result); // result : [1000, 2000, 3000] });
非同期の並列処理
依存関係のない非同期処理を複数行いたい場合は、並列処理をさせる方が処理の時間が短く済みます。
Promiseでは並列処理を扱う Promise.all()
という静的メソッドがあるので、これをAsync Functionでも使えば簡単に並列処理が行えます。
// 略 const tasks = { task1() { return timer(1000, () => 1); }, task2() { return timer(2000, () => 2); }, task3() { return timer(3000, () => 3); } }; async function doAsync() { const result = await Promise.all(Object.values(tasks).map(task => task())); console.log('result :', result); // 3秒後に result : [1, 2, 3] } doAsync();
Promiseの結果を再利用したい場合
通常、Promiseを使った場合だと、ひとつ前の then
で得た結果の値を参照するには、スコープの外に変数を用意しておき、そこに入れて参照するという方法が一般的かと思います。
let result; Promise.resolve() .then(() => 1) .then(value => { result = value; return 2; }) .then(value => { console.log(value + result); // 3 });
これをAsync Functionを使うと同じスコープに並べることができるので簡単に参照できます。
async function doAsync() { const result1 = await timer(1000, () => 1); console.log(result1); // 1 const result2 = await timer(2000, () => 2); console.log(result1 + result2); // 3 const result3 = await timer(3000, () => 3); console.log(result1 + result2 + result3); // 6 } doAsync();
ここまでのまとめ
- Async Functionは必ずPromiseを返す
- Async Functionの直下では
await
式が使える await
式は常にPromiseを受け取るawait
式は右辺のPromiseの状態が変化するまでその場で処理を一時停止する- Async Functionの関数内で発生した例外は自動でキャッチされる
await
式に渡されたPromiseがRejected
になるとその場でthrow
される。つまりそのAsync FunctionはRejected
なPromiseを返す- エラーハンドリングはAsync Function内で
try-catch
でできる
後編では、Async Functionを使う上での注意点などをまとめていきます。