Subterranean Flower

PromiseによるJavaScript非同期処理レシピ集

Author
古都こと
ふよんとえんよぷよぐやま!!!

Promiseの概念はずいぶん浸透してきました。Promiseは単なる「新機能」のひとつから、もはや非同期処理における基本となりました。有志のライブラリなどもPromiseを返すのが当たり前になってきていて、コールバックでの処理はオプションであることが多くなりました。

さて、そうなってくるとPromiseの概念がどうこうというよりも、実用的なケースに対するコードスニペットがほしくなってきます。そこで今回の記事では、よくあるケースに対しての具体的解決策をいくつか提示します。

この記事について

この記事では、JavaScript初心者に向けた、実用的な観点に焦点をあてて説明します。よっていつもの記事ほど正確性や厳密性はありません。

Promiseの「仕様」について詳しく知りたい場合は、MDNを読むなり、仕様書を読むなりしてください。

世界はPromiseに染まった

Promise!Promise!Promise!もうどこをみてもPromiseばかり!新しいAPIはPromiseで、気になるライブラリはPromiseで、誰もがPromiseを使っている!

そうなってくるとPromiseについて学習せねばなりません。そしてググってヒットします。当ブログの「Promiseとasync/awaitでJavaScriptの非同期処理をシンプルに記述する」とか。それで概念はわかった。でも違う!私たちが欲しいのは「今すぐ使えるコードパターン集」です。

この記事では、現実に発生するパターンに対しての解決策をいくつか紹介します。

Promiseの基礎基本

Promiseとasync/awaitでJavaScriptの非同期処理をシンプルに記述する」を読めば大抵わかるのですが、ここでも改めてさらっと説明だけしておきます。

Promiseは状態を持つオブジェクトで、「処理中(pending)」「解決(fulfilled)」と「リジェクト(rejected)」の3状態を持ちます。何らかの処理が終わると解決し、処理中にエラーが発生するなどしたらリジェクトされます。

解決したPromiseはthenメソッドで受け取ることができ、リジェクトされたPromiseはcatchメソッドで受け取ることができます。解決しようとリジェクトしようと強制的に実行するfinallyメソッドもあります。

const promise = someAsyncFunc();
promise
    .then((result) => console.log(result))
    .catch((e) => console.error(e))
    .finally(() => console.log('Done!'));

同期処理においてPromiseはあまり役に立ちませんが、非同期処理となると強い効果を発揮します。非同期処理は「いつ終わるかわからない」処理ともいえます。そういった処理の終了/エラーをトリガーに次の処理に移れるのは、非常に便利です。

また、async関数というものもあります。これは関数宣言の前にasyncとつけることで、戻り値を強制的にPromiseにラップできる関数です。また、関数内部でawaitという「Promiseの処理を待つ」処理ができるようになります。

async function myAsyncFunc() {
  // awaitがあるので、someAsyncFuncが返すPromiseが
  // 解決/リジェクトされるまでいったん処理が止まる
  const result = await someAsyncFunc();

  // stringを返しているが、
  // async関数なのでPromiseとしてラップされて返る
  return 'Hello';
}

これがPromiseとasync/awaitの基礎です。

Promiseを返す関数

Promiseを用いるライブラリでは、以下のようなサンプルがよく例示されます。

awesomeLib.fetchUserData()
    .then((user) => console.log(user));

これは新たな構文……ではなくJavaScriptの基礎基本だけで分解できる要素で構成されています。

この場合、ライブラリのfetchUserData()メソッドはPromiseを返します。ここでPromiseは単なるオブジェクトです。よって以下のように置き換えることもできます:

// いったん変数に入れて…
const userPromise = awesomeLib.fetchUserData();

// それから処理を書く!
userPromise.then((user) => console.log(user));

Promiseは単なるオブジェクトなので、変数に代入できます。そしてPromiseオブジェクトはthenメソッドなどを持つので、それを呼び出しているだけです。

変数を介してPromiseをどうこうするというのは今後よく出てくるので、覚えておきましょう。

アロー関数と戻り値

Promiseとは直接関係ありませんが、Promiseの例ではよくアロー関数が使われます。アロー関数にはいろいろな役割があるのですが、ここでは「1行の場合はreturnを省略できる」とだけ覚えておいてください。

// これと
const func = (a, b) => a + b;

// これはほぼ同じ
function func(a, b) {
  return a + b;
}

ですが、アロー関数でも複数行に渡る場合はreturnがいります。

const func = (a, b) => {
  console.log(a, b);
  return a + b;
};

この点は気をつけましょう。アロー関数はいつでも自動でreturnしてくれるわけではないのです。

Promiseレシピ集

それでは実際にPromiseを使ったレシピを見ていきましょう!

非同期処理を待つ

「非同期処理を待って何かを実行したい」ということはよくあります。これを素直に以下のように書いたとします:

someAsyncFunc(); // Promiseを返す
console.log('Done!');

このとき実際には先に’Done!’が表示されて、それからsomeAsyncFuncの処理が終わります。これはPromiseはあくまで「将来的に処理結果が出る」ことを約束して次に進むだけで、処理の終了についてはいつ終わるかわからないからです。

今回の場合、よっぽど妙な環境で実行しない限り以下のような順番で実行されるはずです:

  1. someAsyncFuncがPromiseを生成する
  2. 先ほど生成されたPromiseの中で処理が始まる
  3. console.log(‘Done!’)が実行される
  4. Promiseの処理が終わる

直感的な理解としては、非同期処理は通常のプログラムの流れからは外れた場所での動作をします。setTimeoutと同じような動きをする、と考えれば良いでしょう。

Promiseの完了を待つには、Promiseのthenメソッドを使います。以下のようにします:

someAsyncFunc()
    .then(() => console.log('Done!'));

より長い処理があっても同様です。thenの中に全て書きます:

someAsyncFunc()
    .then(() => {
      console.log('Hello!');
      console.log('Bye!');
      console.log('Done!');
    });

また、Promiseが結果を伴う解決をする場合、thenの中で引数を明示することで受け取ることができます:

someAsyncFunc()
    .then((records) => console.log(records));

もしPromiseを待ちたいのが関数内部であれば、async関数にしてしまって、awaitで停止させるという手もありです。

async function myFunc() {
  await someAsyncFuncA(); // 終わるまで待つ
  console.log('Done!'); // 終わってから実行される!
}

また、将来的にはtop-level awaitというのも導入される予定なので、関数の外でもawaitが使えるようになるかもしれません。

注意点として、async関数にするとawaitが使える代わりに必ずPromiseを返す関数になるというところです。関数内部では順番は保たれますが、関数使用側ではasync関数から返ってきたPromiseを制御するのを忘れないようにしましょう。

非同期処理を順番に実行する

よくあるのが「非同期処理Aが終わった後に非同期処理Bを実行したい」です。以下のように書いたとします:

someAsyncFuncA(); // Promiseを返す
someAsyncFuncB(); // Promiseを返す

これはうまく動きません。期待通りにsomeAsyncFuncAが先に終わることもありますが、someAsyncFuncBが先に終わることもあります。一般にPromiseは非同期処理であり、Promiseが生成されただけでは処理は完了していないからです。

生成されたPromiseはいつ解決/リジェクトされるかはわかりません。それを検知できるのはthenメソッドとcatchメソッド、finallyメソッドだけです。

よって順番に行いたい場合は以下のようにします:

someAsyncFuncA()
    .then(() => someAsyncFuncB());

someAsyncFuncAがPromiseを返すので、それが解決するのをthenで受けて、その中でsomeAsyncFuncBを呼び出します。

次はより処理が多い場合を考えましょう!someAsyncFuncCやDの登場です。A、B、C、Dの順番で実行したいとします。

このときひとつのテクニックを使います。thenの中で値を返すと、次のthenで受け取れるようになります。返した値がPromiseである場合、Promiseが解決するのを待ってから次のthenが実行されます。

つまり以下のように書くことで、順番にPromiseを実行できます:

someAsyncFuncA()
    .then(() => someAsyncFuncB())
    .then(() => someAsyncFuncC())
    .then(() => someAsyncFuncD());

これでA、B、C、Dの順番で実行されます!

注意点として、たとえPromiseを使用していようとthenの中でPromiseをreturnしないと待ってくれないという点があります。上の例ではアロー関数でPromiseをreturnしてしますが、thenの中が複数行になることでミスが起こりがちです。

someAsyncFuncA()
    .then(() => {
      console.log('Hello!');
      someAsyncFuncB(); // returnし忘れてる!
    }).then(() => someAsyncFuncC());

このときはsomeAsyncFuncBを待たずに次のthenへ行きます。思った順番通りに実行されないときは、Promiseをきちんとreturnしているか確認しましょう。

また、関数内部であれば、async関数にしてawaitを使うという手もあります。

async function myFunc() {
  await someAsyncFuncA(); // 終わるまで待つ
  await someAsyncFuncB(); // ↑が終わってから実行される
  await someAsyncFuncC(); // ↑が終わってから実行される
  await someAsyncFuncD(); // ↑が終わってから実行される
  console.log('Done!'); // 全てが終わってから実行される
}

ただしasync関数にすると必ずPromiseを返す関数になるので注意しましょう。

いくつあるかわからない非同期処理を順番に実行する

非同期処理がいくつあるかわからない場合があります。例えば以下のような場合:

for(const id of dataIds) {
  const promise = fetchDataById(id);
}

このコードですと、最初に一気にPromiseが生成されて、あとで個別に処理が走る、という結果になります。つまり全くバラバラに実行されます。

それでもよい場合はありますが、順番にひとつずつ実行したい場合もあります。そのときはthenでつないでいけばいいと先ほど言いましたが、今回のように動的に生成される場合はどうすればいいでしょう?

実はPromiseは空の解決済みPromiseを作ることができるので、それを活用します。

// 空の解決済みPromiseを生成
let promise = Promise.resolve();

for(const id of dataIds) {
  // promiseにthenで繋ぎ再代入
  // これでどんどんthenをチェーンしていける
  promise = promise.then(() => fetchDataById(id));
}

再代入可能な変数としてPromise.resolve()で解決済みPromiseを生成し、そこにthenでつないで再代入していくことで動的なthenチェーンを作ることができています。最終的に変数promiseの中身は例えば以下のようになります:

Promise.resolve()
    .then(() => fetchDataById(0));
    .then(() => fetchDataById(1));
    .then(() => fetchDataById(2));
    .then(() => fetchDataById(3));
    .then(() => fetchDataById(4));

1つめが終わってから2つめ……ということが実現できています。

複数の非同期処理が全部終わるのを待つ

複数の非同期処理を並行して走らせて、全部終わるのを待ちたい時があります。順番に実行するのではなく、バラバラに実行して、全部待つ、というやつです。

これはPromise.allというものを使うことで簡単に実現できます。Promise.allは、「受け取ったPromiseの配列が全て解決するまで待つPromise」を生成します:

const a = someAsyncFuncA();
const b = someAsyncFuncB();
const c = someAsyncFuncC();

// Promise.allにPromiseの配列を渡すと
// 全てのPromiseが解決するのを待ってくれる
const promiseArray = [a, b, c];
Promise.all(promiseArray)
    .then((resultArray) => {
      console.log('A', resultArray[0]);
      console.log('B', resultArray[1]);
      console.log('C', resultArray[2]);
    });

Promise.allの結果は配列として解決されます。配列の中身はそれぞれのPromiseの解決結果です。渡した配列と同じ順番で返ってきます。

これだけです。あとは普通のPromiseのように扱ってください。

動的にPromiseの数が変わる場合でも有効です。

const promiseArray = [];

for(const id of userIds) {
  const promise = awesomeLib.fetchUserData(id);
  promiseArray.push(promise);
}

Promise.all(promiseArray)
    .then((results) => console.log(results));

ただしひとつ罠があって、Promise.allは配列の中のPromiseがひとつでもリジェクトされるとリジェクト扱いになります。つまりcatchで受けることになります。

しかし「解決したかリジェクトしたかに関わらずとりあえずthenにつないで欲しい」という場面があります。それを解決するためにPromise.allSettledというものが導入されようとしています。2019年11月現在、まだChromeでしか使えないので実用はできませんが、動向をチェックしておくと良いでしょう。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise/allSettled

複数の非同期処理のどれかが終わるのを待つ

Promise.allと似たようなメソッドに、Promise.raceというものがあります。これを使うと「渡したPromise配列のどれかが解決するのを待つ」ということが実現できます。

const a = someAsyncFuncA();
const b = someAsyncFuncB();
const c = someAsyncFuncC();

const promiseArray = [a, b, c];
Promise.race(promiseArray)
    .then((result) => {
      console.log('Fastest Promise Result', result);
    });

Promise.raceは渡されたPromise配列の中で「最初に解決したPromiseの値として解決するPromise」を生成します。これを使えば「どれかのPromiseが解決した」ことを検知できます。

コールバック関数方式の非同期処理をPromiseにする

全ての非同期処理がPromiseなわけではありません。たとえばsetTimeoutはコールバック方式で、Promiseではありません。

例えば一定時間待つwaitメソッドを作りたいとします。このときsetTimeoutを使わざるを得ませんが、setTimeoutはPromiseに対応していません。

ですがPromiseは自作することもできます。new Promise(callback)としてやることで、自分でPromiseを生成できます。callbackはresolve関数, reject関数を引数に受け取り、それぞれを実行することでPromiseを解決したりリジェクトしたりできます。

このとき以下のようにします:

async function wait(ms) {
  // Promiseをnewし、returnしてやる
  // 第一引数にresolve関数、第二引数にreject関数を受け取る
  return new Promise((resolve, reject) => {
    // 終わったらresolve関数を呼んでやる
    setTimeout(() => resolve(), ms);
  });
}

// このように使える
wait(1000)
    .then(() => console.log('Done!'))

Promiseは強力ですが、まだ全ての場所に普及したわけではありません。このようなラップ関数も必要になることでしょう。

最後に

Promiseは難しい概念ではありませんが、その実用となるととても複雑になります。各種ドキュメントで説明されている範囲では解決できないことも多いでしょう。しかしここで紹介したレシピがあれば、ある程度のパターンについては対応できるようになります。

また、より本格的に学びたいのであれば、無料で公開されているazuさんの「JavaScript Promiseの本」を読むと良いでしょう。Promiseについて丁寧に正確に記述されています。

非同期処理の道は長く厳しいものですが、一緒に頑張っていきましょう。