JavaScript : Promise の挙動をおさらいする

普段は決まった書式で非同期処理を書いているのだが、Promise の仕様を押さえるため、思い付きで変な書式を試してみたりした。

今回紹介するサンプルコードは、全て promise-test.js というファイル名で保存し、Node.js で実行した結果を確認している。

目次

同期的な処理は直接値を return すれば良い。

then() 内の処理が同期的な処理であれば、きちんと待って次の then() に移行してくれるので、

new Promise()
  .then(() => {
    return new Promise((resolve) => {
      resolve(HOGE);
    });
  })
  .then(() => {
    return new Promise((resolve) => {
      resolve(FUGA);
    });
  })

というように、全ての then() 内で new Promise() を生成して return しなくて良い。

new Promise()
  .then(() => {
    return HOGE;
  })
  .then(() => {
    return HUGA;
  });

同期的な処理であれば、コレで良いのだ。

以下により詳しいサンプルコードと実行結果を置く。

console.log('Start');
new Promise((resolve) => {
  setTimeout(() => {
    let val = 5;
    resolve(val);
    // resolve() 以降のコードも実行はされる
    val += 20;
    console.log('Promise 1 : ', val);
  }, 1000);
})
  .then((val) => {
    console.log('Promise 2 : ', val);
    
    // 同期的だが時間がかかる処理を用意する
    let str = `${val} : `;
    for(let i = 0; i < 10000000; i++) {
      str += `${i}`;
    }
    
    return str.slice(-10);
  })
  .then((val) => {
    console.log('Promise 3 : ', val);
  });
$ node promise-test.js
Start
Promise 1 :  25
Promise 2 :  5
Promise 3 :  9989999999

$ node promise-test.js
# ↓ コマンド実行後すぐ出力される
Start

# ↓ setTimeout 内に書いてあるので1秒後に出力される
Promise 1 :  25
# resolve している値は 5 だが、console.log の値は += 20 した値

# ↓ 「Promise 1」の直後に出力される
Promise 2 :  5
# resolve している値は 5 なので、resolve 後に += 20 されていても無関係
# 時間のかかる for ループの処理が行われる
# then 内の関数が直接 return しているが、この値は Promise でラップされるので、次の then の仮引数で受け取れる

# ↓ 「Promise 2」の出力後、数秒開いて出力される
Promise 3 :  9989999999
# 時間のかかる for 文の処理を待って出力される
# 「Promise 2」の then 内の関数が return した str.slice(-10) が、仮引数 val で受け取れている

失敗例:非同期処理を Promise でラップできていない

then() の中に非同期処理が含まれているのに、Promise でラップしていない場合は、後続の then() に値を渡せない。

以下サンプル。

new Promise((resolve) => {
  console.log('Promise 1');
  resolve('Resolve!');
})
  .then((val) => {
    console.log('Promise 2 : ', val);
    
    setTimeout(() => {
      console.log('Promise 2 SetTimeout');
      return 'Resolve?';
    }, 1000);
  })
  .then((val) => {
    console.log('Promise 3 : ', val);
  });
$ node promise-test.js
Promise 1
Promise 2 :  Resolve!
Promise 3 :  undefined
Promise 2 SetTimeout

$ node promise-test.js
# ↓ コマンド実行直後に出力される
Promise 1

# ↓ 「Promise 1」の直後に出力される
Promise 2 :  Resolve!
# 「Promise 1」は resolve で値を渡しているので「Resolve!」が引き継げている

# ↓ 「Promise 2」の直後、1秒待たずに出力される
Promise 3 :  undefined
# setTimeout の処理を待てていないので、「Resolve?」の値が引き継げていない

# ↓ 「Promise 3」の約1秒後に出力される
Promise 2 SetTimeout
# 宙に浮いた非同期処理になっているので、Promise のチェーン全体が終わった後に出力されてしまっている

「Promise 3」が結果を受け取れず、変数 valundefined になってしまっているのが分かる。

コレを修正するには、「Promise 2」部分で new Promise()return するようにする。

new Promise((resolve) => {
  console.log('Promise 1');
  resolve('Resolve!');
})
  .then((val) => {
    // 「Promise オブジェクトを生成する関数」として作る
    // then() の第1引数に直接 Promise オブジェクトを渡す、「then( new Promise() )」このような書き方は動かないので注意
    // then() の第1引数を関数として実行してくるので、then(() => new Promise()) でないといけない
    return new Promise((resolve) => {
      console.log('Promise 2 : ', val);
      
      setTimeout(() => {
        console.log('Promise 2 SetTimeout');
        resolve('Resolve?');  // return ではなく resolve で値を渡す
      }, 1000);
    });
  })
  .then((val) => {
    console.log('Promise 3 : ', val);
  });
$ node promise-test.js
Promise 1
Promise 2 :  Resolve!   # ← ココまではコマンド実行直後に出力される
Promise 2 SetTimeout    # ← その1秒後に出力される
Promise 3 :  Resolve?   # ← SetTimeout の直後に出力され、「Resolve?」の文字列が取得できている

ある非同期処理が失敗しても後続の then() に繋げるには

サンプルとして以下のようにランダムに Reject する関数を用意。

// 1秒後にランダムに Resolve か Reject する関数
function randomPromise(val) {
  // resolve・reject の仮引数は好きな名前に変えても動く
  return new Promise((res, rej) => {
    setTimeout(() => {
      if(Math.random() < 0.5) {
        res(val || 'Default Resolve');
      }
      else {
        rej(val || 'Default Reject');
      }
    }, 1000);
  });
}

// この関数を3回呼び出してみる
console.log('Promise 1 : Start');
randomPromise('Promise 1 Result')
  .then((val) => {
    console.log('Promise 2 : ', val);
    return randomPromise('Promise 2 Result');
  })
  .then((val) => {
    console.log('Promise 3 : ', val);
    return randomPromise('Promise 3 Result');
  })
  .catch((e) => {
    console.error('Error! : ', e);
  });
# 実行するたびに、どのタイミングで Reject されるかは変わる
$ node promise-test.js
Promise 1 : Start
Promise 2 :  Promise 1 Result
Error! :  Promise 2 Result

今回は、この randomPromise() で仮に Reject されても、必ず後続処理に繋げる仕組みを作ってみる。

function randomPromise(val) {
  return new Promise((res, rej) => {
    setTimeout(() => {
      if(Math.random() < 0.5) {
        res(val || 'Default Resolve');
      }
      else {
        rej(val || 'Default Reject');
      }
    }, 1000);
  })
    .then(  // ← この then だけ追加、あとのコードは変えていない
      (val) => {
        return `${val} (Resolved)`;
      },
      (error) => {  // then(onFulfilled, onRejected) と書けるのでこのように書く
        console.log('Reject されたが続行 : ', error);
        return `${error} (Rejected)`;
      }
    );
}
# 実行するたびに動作は変わるが、「Error!」は実行されない
$ node promise-test.js
Promise 1 : Start

# ↓ 1秒後に実行される
Promise 2 :  Promise 1 Result (Resolved)
# 最初の randomPromise('Promise 1 Result') は Resovled した

# ↓ さらに1秒後に出力される
Reject されたが続行 :  Promise 2 Result
# 「Promise 2」の randomPromise('Promise 2 Result') が Rejected したが、そのエラーをキャッチしている

# ↓ 直後に出力される
Promise 3 :  Promise 2 Result (Rejected)
# return `${val} (Rejected)`; としたとおり、onRejected が return した値を受け取っている

このように、エラーを握り潰したい Promise オブジェクトの後ろに、空の onRejected なチェーンを作っておけばよいので、以下のいずれかの構成が基本となるだろう。

// then(onFulfilled, onRejected) で書く場合
return new Promise((resolve, reject) => {
  // 条件によって Resolve したり Reject したりする処理…
  if( /* 条件 */ ) {
    resolve();
  }
  else {
    reject();
  }
})
  .then(
    (value) => { return value; },
    (error) => { return error; },
  );

// もしくは、then().catch() と書く場合。結果は同じ
return new Promise((resolve, reject) => {
  // 条件によって Resolve したり Reject したりする処理…
})
  .then((value) => { return value; })
  .catch((error) => { return error; });

それぞれの then()catch() 内でログだけ切り替えても良いし、return する値はエラー時のみ差し替えるなどしても良い。

配列を直列で順に実行する Promise チェーンを作る

Promise.all() は配列で渡した非同期処理たちを並列で実行するが、直列で、順次実行したい場合は、Promise チェーンを作る。

// 適当な配列データ
const data = [
  { id: 1, name: '111' },
  { id: 2, name: '222' },
  { id: 3, name: '333' },
  { id: 4, name: '444' }
];

// Promise チェーンを開始する最初の Promise オブジェクトは、空の Promise.resolve() で作る
let promiseChain = Promise.resolve();
// 配列 data を順にループする
data.forEach((item) => {
  // 変数 promiseChain に、promiseChain.then() と定義して代入を繰り返していく
  promiseChain = promiseChain.then(() => new Promise((resolve, reject) => {
    console.log(new Date(), item, 'Start');
    setTimeout(resolve, item.id * 1000);
  }));
});
// 最終的に promiseChain は、Promise.resolve().then(id: 1).then(id: 2).then(id: 3).then(id: 4) な Promise チェーンになっている

// 後から promiseChain に then() を繋げると、そこだけ後で実行される
setTimeout(() => {
  promiseChain.then(() => {
    console.log(new Date(), 'Finished');
  });
}, 2000);

let promiseChain で変数を用意し、data.forEach() 内で promiseChain = promiseChain.then(); と繋げていくのは、直感的で読解しやすいコードにはなるが、let が登場するところがイマイチか。

そこで、Array.prototype.reduce を使って同様の Promise チェーンを構築する方法も紹介する。コレが一番エレガントだと思います。

const promiseChain = data.reduce((prevPromise, item) => {
  return prevPromise
    .then(() => new Promise((resolve, reject) => {
      console.log(new Date(), item, 'Start');
      setTimeout(resolve, item.id * 1000);
    }));
}, Promise.resolve());

setTimeout(() => {
  promiseChain.then(() => {
    console.log(new Date(), 'Finished');
  });
}, 2000);

reduce() は、第1引数の関数が return した値を順に渡していく。また、第2引数に最初の値を指定できるので、まずは第2引数で Promise.resolve() を用意する。

第1引数の関数にて、前回の関数が return した値と今回のループで取り出した要素を取得できるので、return prevPromise.then(); とすることで Promise チェーンを実現する。

let promiseChain で代入を繰り返した場合も、reduce() で一気に構築した場合も、いずれも以下のような出力結果になる。

$ node promise-test.js
2018-08-15T07:35:40.345Z { id: 1, name: '111' } 'Start'
2018-08-15T07:35:41.354Z { id: 2, name: '222' } 'Start'
2018-08-15T07:35:43.360Z { id: 3, name: '333' } 'Start'
2018-08-15T07:35:46.366Z { id: 4, name: '444' } 'Start'
2018-08-15T07:35:50.372Z 'Finished'

以上

かなり Promise が身体に馴染んできた。そろそろ async・await も覚えねば…。