KDE BLOG

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

【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 でラップし、キャッチした errorreject で伝えています。
こうすることで、途中で処理が止まることなくエラーハンドリングが可能になります。

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 式を使ってコードを書くようにして、非同期のコールバックはなるべく使わないようにする方が望ましいでしょう。

参考