KDE BLOG

Webデザインやコーディングについて書いています

【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はただのビルトインオブジェクトで、thencatch などを使ったメソッドチェーンで書かなければならないなどの制限があります。

こういった書き方の制限を解決するために 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つあります。

  1. 常にPromiseインスタンスを返す関数を定義する
  2. 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つになります。

  1. 値を return した場合は、その返り値をもつ Fulfilled 状態のPromiseを返す
  2. Promiseを return した場合は、その返り値のPromiseをそのまま返す
  3. 例外が発生した場合はそのエラーをもつ 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秒後に FulfilledRejected どちらかの状態になる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を使う上での注意点などをまとめていきます。

参考