【JavaScript】Promiseとasync/await これからの非同期処理の書き方

 非同期処理を実装するにあたって避けては通れないPromiseと、ES2017からの非同期処理の新しい書き方であるasync/awaitについて解説します。

Promiseとは

 非同期処理をシンプルに記述するためのオブジェクトで、ES2015から実装されました。それまでは、jQueryのDeferredが比較的有名で、よく使われていました。

 現代のJavaScriptでは、jQueryを利用することは以前に比べて少なくなりました。jQueryで実装されていたDeferredやAjaxが、ES2015でPromise、fetchなどといったビルトイン関数として実装されたことも理由のひとつです。

 さて、この非同期処理について少し考えてみます。たとえば、5秒をカウントするようなプログラムを書いてみましょう。愚直に書くとこうなります。

const ONE_SEC = 1000;
setTimeout(()=>{
  console.log("1");
  setTimeout(()=>{
    console.log("2");
    setTimeout(()=>{
      console.log("3");
      setTimeout(()=>{
        console.log("4");
        setTimeout(()=>{
          console.log("5");
        }, ONE_SEC);
      }, ONE_SEC);
    }, ONE_SEC);
  }, ONE_SEC);
}, ONE_SEC);

 これが有名な「コールバック地獄」です。こういった複雑なロジックを回避するために考案されたのが、jQuery Deferredや、Promiseパターンです。

 それでは、ES2015のPromiseを利用して書いてみましょう。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      resolve();
    }, millisec);
  });
}

let sec = 0;

sleep(ONE_SEC).then(()=> console.log(++sec))
  .then(()=> sleep(ONE_SEC)).then(()=> console.log(++sec))
  .then(()=> sleep(ONE_SEC)).then(()=> console.log(++sec))
  .then(()=> sleep(ONE_SEC)).then(()=> console.log(++sec))
  .then(()=> sleep(ONE_SEC)).then(()=> console.log(++sec));

 new Promiseで渡した関数は即座に実行され、関数スコープ内のresolveまたはrejectが呼び出されたタイミングでthenに登録したメソッドが呼ばれます。

 先ほどに比べていくらか簡潔に書けましたね。Promiseは、非同期処理を出来るだけリーダブルに書くことを目的として搭載されました。

 エラーハンドリングには、.catch()関数か、.then()関数の第二引数にエラーハンドラを登録します。実際にやってみましょう。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      reject();  // reject()に変更することでPromiseが失敗したことを通知する
    }, millisec);
  });
}

sleep(ONE_SEC).then(()=> console.log("resolved."), ()=> console.log("error!")); // thenでエラーハンドリングを行う場合
sleep(ONE_SEC).then(()=> console.log("resolved.")).catch(()=> console.log("error!")); // catchでエラーハンドリングを行う場合

 上記を実行すると、resolved. ではなく、error! が表示されます。

 また、resolveやrejectには、引数を渡すことでthenで登録した関数で値を受け取ることができます。引数のオブジェクトは何でも構いません。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      resolve("Fullfilled!!");  // 引数として文字列を渡す
    }, millisec);
  });
}

sleep(ONE_SEC).then(value=> console.log(value));  // -> Fullfilled!!

 rejectも同様です。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      reject("Rejected!!")
    }, millisec);
  });
}

sleep(ONE_SEC).then(()=> console.log("resolved."), err=> console.log(err));  // -> Rejected!!

 このPromiseには、.all().race()などの便利な静的メソッドが搭載されています。

Promise.all()

 Promise.allは、渡されたIterableなPromisesが「すべて完了」するか、「いずれかが失敗」することを保証します。先ほどのSleep関数を使って以下に簡単な例を挙げてみましょう。

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      console.log(`Promise ${millisec} complete.`);
      resolve();
    }, millisec);
  });
}

let promises = [sleep(1000), sleep(2000), sleep(3000), sleep(4000), sleep(5000)];

Promise.all(promises).then(()=> console.log("All promises complete!")); 

 実行結果

Promise 1000 complete.
Promise 2000 complete.
Promise 3000 complete.
Promise 4000 complete.
Promise 5000 complete.
All promises complete!

 実行してみると、全てのPromiseがresolveされない限り、Promise.allのthen関数で登録した(console.log("All promises complete!"))は呼ばれないことがわかります。

 逆に、rejectした場合はどうなるでしょうか。

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      console.log(`Promise ${millisec} complete.`);
      reject();  // rejectに変更する
    }, millisec);
  });
}

let promises = [sleep(1000), sleep(2000), sleep(3000), sleep(4000), sleep(5000)];

Promise.all(promises).then(()=> console.log("All promises complete!")).catch(()=> console.log("error!!"));

 実行結果

Promise 1000 complete.
error!!
Promise 2000 complete.
Promise 3000 complete.
Promise 4000 complete.
Promise 5000 complete.

 この場合、一つ目がrejectされた時点でerrorが呼ばれていることがわかります。しかし、errorが返ってきたとしても、他の処理は継続してしまいます。

 いずれかの実行が失敗した時点で他の処理を止めたい場合は、少しの工夫とコーディングが必要です。

const timeoutIds = new Set();  // timeoutIdを管理するセットを作っておく
function sleep(millisec){
  return new Promise((resolve, reject) => {
    timeoutIds.add(setTimeout(()=>{
      console.log(`Promise ${millisec} complete.`);
      reject();  // rejectに変更する
    }, millisec));
  });
}

let promises = [sleep(1000), sleep(2000), sleep(3000), sleep(4000), sleep(5000)];

Promise.all(promises).then(()=> console.log("All promises complete!")).catch(()=>{
  timeoutIds.forEach(id => clearTimeout(id));  // 失敗した場合、現在実行されているtimeoutを全て止める
  timeoutIds.clear();  // 不要になった、TimeoutIDが入ったSetをクリアする
  console.log("error!!");
});

 実行結果

Promise 1000 complete.
error!!

 タイムアウトが正しく中断していることがわかります。fetch関数等を利用する場合は、AbortContollerを利用するなどの工夫が必要になります。わかりやすい記事をいくつかご紹介しておきましょう。

Promise.race()

 Promise.race()は、allとは逆に、一番最初にresolveまたはrejectされたものがあった瞬間にthenで登録した関数が呼ばれます。

 このとき、Promise.all()を使ったときと同様に、resolveまたはrejectを返した関数以外が停止することを保証するわけではない、ということに注意が必要です。

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      console.log(`Promise ${millisec} complete.`);
      resolve();
    }, millisec);
  });
}

let promises = [sleep(1000), sleep(2000), sleep(3000), sleep(4000), sleep(5000)];

Promise.race(promises).then(()=> console.log("Any promise complete!")).catch(()=> console.log("error!!"));

 実行結果

Promise 1000 complete.
Any promise complete!
Promise 2000 complete.
Promise 3000 complete.
Promise 4000 complete.
Promise 5000 complete.

 ミラーサーバーを選択するような用途であれば、あえてキャンセル機構を入れる必要は無さそうですが、場合によっては必要かもしれません。

async / await

 async / awaitは、前述したようなPromise系の処理を同期的な書き方で書ける画期的な構文です。ES2017で提唱された比較的新しい構文なため、フロントエンドで利用するにはBabelやTypeScriptなどのトランスパイラを使用したほうが無難です。

 最大の利点は、「for文やwhile文で利用できる」ことではないかと思います。従来の書き方では、繰り返し処理の非同期化のために再帰処理を施したりと、とても複雑なプログラムになりがちだったからです。

 それでは、先ほどのコードを、async / awaitを利用して書いてみましょう。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      resolve();
    }, millisec);
  });
}

async function run(){
  for(let sec = 1; sec <= 5; sec++){
    await sleep(ONE_SEC);
    console.log(sec);
  }
}

run();

 注意点としては、処理を必ずasync関数(Promiseを返します)でラップすることと、async関数は非同期で実行される、ということです。

 たとえば、上記のコードのrun()以降にコードを書いてみましょう。run()の処理が全て終わった後に後続の処理が行われることを期待したいところですが……。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      resolve();
    }, millisec);
  });
}

async function run(){
  for(let sec = 1; sec <= 5; sec++){
    await sleep(ONE_SEC);
    console.log(sec);
  }
}

run();
console.log("All count complete!");  // 追加

 実行結果

All count complete!
1
2
3
4
5

 このように、なんと最初に実行されてしまいます。async関数は必ず非同期になる、ということは心に留めておいてください。

 もし、run()が終了することを待ちたい場合は、async関数で更にwrapし、awaitを付与するのが良い方法です。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      resolve();
    }, millisec);
  });
}

async function run(){
  for(let sec = 1; sec <= 5; sec++){
    await sleep(ONE_SEC);
    console.log(sec);
  }
}

(async ()=>{
  await run();
  console.log("All count complete!");
})();

 実行結果

1
2
3
4
5
All count complete!

 期待通りの結果になりました。

rejectを検知する

 さて、エラーなどの何らかの異常が起きた場合、reject()を呼び出すのは、Promiseの項でご説明したとおりです。async / awaitを使った記法でも、もちろんreject()を検知することができます。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      reject();  // rejectに変更する
    }, millisec);
  });
}

async function run(){
  for(let sec = 1; sec <= 5; sec++){
    await sleep(ONE_SEC);
    console.log(sec);
  }
}

(async ()=>{
  // try ~ catch節を追加する
  try{
    await run();
    console.log("All count complete!");
  }catch(e){
    console.log("An error ocurred!");
  }
})();

 実行結果

An error ocurred!

 async / awaitでは、なじみ深いtry~catch節を使用することができます。ご推察のとおり、finally句も使用できます。やってみましょう。

const ONE_SEC = 1000;

function sleep(millisec){
  return new Promise((resolve, reject) => {
    setTimeout(()=>{
      reject();  // rejectに変更する
    }, millisec);
  });
}

async function run(){
  for(let sec = 1; sec <= 5; sec++){
    await sleep(ONE_SEC);
    console.log(sec);
  }
}

(async ()=>{
  // try ~ catch節を追加する
  try{
    await run();
    console.log("All count complete!");
  }catch(e){
    console.log("An error ocurred!");
  }finally{
    console.log("finally!");  // finally句を追加
  }
})();

 実行結果

An error ocurred!
finally!

 普通に例外処理を実装するような感覚で使うことができます。

まとめ

 JavaScriptは昔に比べ、豊富なシンタックスシュガーやビルトインオブジェクトにより使いやすくなりました。

 async / awaitは、最初は少しとっつきづらいかもしれませんが、一度理解すれば、非常に簡潔な構文で非同期処理を記述できるようになります。

 この機会に、ぜひ使ってみてください!

コメント

  1. […] Promise と async/await これからの非同期処理の書き方 | Deep Rain […]