KDE BLOG

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

【JavaScript】Promiseを使った非同期処理

Promiseについてきちんと理解できていなかったので初歩から学ぶ。

Promiseとは

非同期処理を抽象的に扱えるオブジェクト。
ES2015から導入された。

これまで非同期処理を制御するためにはコールバック関数を使う方法が多く、非同期処理が続くとコールバック関数がどんどんとネスト化されていわゆる「コールバック地獄」におちいる課題があった。

しかしこのpromiseを使うことで並列的に記述することができコードの可読性がぐっと上がり、保守性や拡張性が増す。
特にNode.jsでは頻繁に非同期処理が行われるのでその恩恵は大きい。

また、Ajax処理を簡潔に行える Fetch や ES2017のasync/await もPromiseオブジェクトを扱っている模様。

使用できる環境

caniuse.comを確認すると、

となっており、ブラウザ上ではこのままでは使えそうにないため、es6-promise などのPolyfillを使うのが現実的。

基本の構文

new Promise((resolve, reject) => {
  /*
   * 非同期処理の記述
   */
 
  // 非同期処理が成功した時に呼び出す
  resolve(value);
  
  // 非同期処理にエラーが発生した場合に呼び出す
  reject(reason);
});

new 演算子でコンストラクタ関数を実行することでPromiseオブジェクトを作成できる。

コンストラクタ関数の引数には、resolvereject の2つの引数をもった関数を渡し、その関数内で非同期処理を記述する。

非同期処理が成功したときには前者のresolveを、エラーが発生した場合にはrejectを呼び出すことで、この関数での処理は終わる。

Promise.prototype.then()

先ほどの処理ではresolveまたはrejectを呼び出すところまで行った。 この後の処理を行うのが、Promiseオブジェクトのインスタンスメソッドであるthenメソッド。

thenメソッドの第一引数に関数を渡すと、resolve、つまり非同期成功時の処理を定義することができる。
そして第二引数にはreject、つまりエラー時の処理を定義することができる。

▼簡単なサンプル

// resolve(成功)の場合
new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Hello');
  }, 1000);
}).then(value => console.log('OK: ' + value)); // 1秒後に 「OK: Hello」

// reject(失敗)の場合
new Promise((resolve, reject) => {
  setTimeout(() => {
    reject(new Error('何らかのエラー'));
  }, 1000);
}).then(
  value => console.log('OK: ' + value),
  reason => console.log('NG: ' + reason)
); // 1秒後に 「NG: Error: 何らかのエラー」
  • resolve('Hello)reject(new Error('何らかのエラー'))のように、引数を与えることでthenメソッド内で値を引き継ぐことができる。
    • rejectの場合は、エラー特定のためにErrorオブジェクトを渡すことが多い。

しかし、このthenメソッドの第二引数でresolve時の処理を記述するのはあまりお勧めできず、後述のcatchメソッドを使う方が、可読性や機能性の面から推奨。詳しくは後述。

Promiseの状態変化

new Promiseインスタンス化したPromiseオブジェクトには3つの状態がある。

  • Pending : promiseオブジェクトが作成された初期状態等
  • Fulfilled : resolve(成功)したとき
  • Rejected : reject(失敗)したとき

この状態を確認してみる。

// pending -> resolvedの例
const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve();
  }, 3000);
});
console.log(p); // Promise {<pending>} [[PromiseStatus]] : "pending"

// 3秒後の実行
console.log(p); // Promise {<resolved>} [[PromiseStatus]] : "resolved"
// pending -> rejectedの例
const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve();
  }, 3000);
});
console.log(p); // Promise {<pending>} [[PromiseStatus]] : "pending"

// 3秒後の実行
console.log(p); // Promise {<rejected>} [[PromiseStatus]] : "rejected"

このようにPromiseオブジェクトの状態がpendingから変化したときに、その状態に応じて成功時または失敗時の処理が実行される。

※なお、この状態([[PromiseStatus]])を触るAPIは存在しないため基本的にはそこまで気にしなくてもよい。

もう1点、Promiseの状態について重要なことは、thenメソッドで登録した関数が呼ばれるのは1回限りで、FulfilledRejectedのどちらかの状態になったら不変であること。

こちらも確認してみる。

const p = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Hello');
    resolve('How are you?');  // ①
    console.log('hogehoge');
  }, 3000);
  
  setTimeout(() => {
    reject(new Error('何らかのエラー'));
  }, 4000);
}).then(
  value => 'OK: ' + value,
  reason => 'NG: ' + reason
);

console.log(p); 
setTimeout(() => console.log(p), 3000);
setTimeout(() => console.log(p), 4000); 

/* 実行結果
Promise {<pending>}

(3秒後)
hogehoge
Promise {<resolved>: "OK: Hello"}

(4秒後)
Promise {<resolved>: "OK: Hello"}
*/

①の箇所ではresolveを2回呼び出しているが、thenメソッドが実行されたのは1回だけ。
しかし、直後のconsole.log('hogehoge');が実行されているので、エラーになったり、処理が止まっているわけではない。

再度言うと、Promiseオブジェクトの状態が変化したときに、一度だけ呼ばれる関数を登録するのがthenなどのメソッドとなる。

Promise.prototype.catch()

これまでの例では、非同期処理中にエラーが起きたとき(rejectが呼び出されたとき)はthenメソッドの第二引数にエラー時の処理を記述していた。

※エラー時の処理を書きたいだけの場合は下記のように、第一引数にundefinednullを指定する。

new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('何らかのエラー')), 1000);
}).then(null, reason => console.log(reason)); // 1秒後に表示: Error: 何らかのエラー

このPromise.then(undefined, onRejected)エイリアスとなるのが、catchメソッド。

catchメソッドのメリット

1. コードの見通しがよくなる

上記のサンプルを書き換えるとこうなる。

new Promise((resolve, reject) => {
  setTimeout(() => reject(new Error('何らかのエラー')), 1000);
}).catch(reason => console.log(reason));

これだけだと、あまりメリットを感じないが、thenメソッドと組み合わせると、成功時とエラー時の流れが分かりやすくなる。

new Promise((resolve, reject) => {
  setTimeout(() => {
    try {
      /*
       * 何らかの非同期処理
       */
      resolve('Hello')
    } catch(e) {
      reject(e);
    }
  }, 1000);
})
  .then(value => { 
    // 成功時
    console.log(value);
  })
  .catch(reason => { 
    // エラー時
    console.log(reason);
  });

thenメソッド内に2つの処理を入れるよりもだいぶ見やすくなっているのがわかる。

2. onResolved時にエラーが起きた場合でも拾うことができる

下記はthenメソッドの第二引数にonRejectedを定義したサンプル。

new Promise((resolve, reject) => {
  // 略
})
  .then(value => {
    throw new Error('何らかのエラー');
    console.log(value);
  },reason => {
    console.log(reason);
  });

// Uncaught (in promise) Error: 何らかのエラー

第一引数のonResolved内でエラーを投げているが、コンソール表示の Uncaught (in promise) Error: 何らかのエラー のようにエラーをキャッチできていない。

では、このcatchを使わずにエラーを捕まえるにはどうしたらよいか。
詳しくは後述するが、thenメソッドをつなげばOK。

new Promise((resolve, reject) => {
  // 略
})
  .then(value => {
    throw new Error('何らかのエラー');
    console.log(value);
  },reason => {
    console.log(reason);
  })
  .then(null, reason => {
    console.log(reason);  // Error: 何らかのエラー
  });

ただ、見ての通り直感的には分かりにくいコードになってきている。

さらに見てみると、最初のコンストラクタ関数実行時にエラーが発生した場合はどうなるか。

new Promise((resolve, reject) => {
  setTimeout(() => {
    try {
      throw new Error('最初のエラー'); // ①
      resolve('Hello');
    } catch(e) {
      reject(e);
    }
  }, 1000);
})
  .then(value => {
    throw new Error('2番目のエラー');  // ②
    console.log(value);
  },reason => {
    console.log('1: ' + reason); // A
  })
  .then(null, reason => {
    console.log('2: ' + reason);  // B
  });

①が発生した場合、「A」が実行されて、1: Error: 最初のエラー と出力される。
②が発生した場合、「B」が実行されて、2: Error: 2番目のエラーと出力される。

ますます複雑化してきてしまっている。

ではcatchメソッドの場合はどうなるか。

new Promise((resolve, reject) => {
  setTimeout(() => {
    try {
      throw new Error('最初のエラー'); // ①
      resolve('Hello');
    } catch(e) {
      reject(e);
    }
  }, 1000);
})
  .then(value => {
    throw new Error('2番目のエラー');  // ②
    console.log(value);
  })
  .catch(reason => {
    console.log(reason); // A
  });

①が発生した場合、「A」が実行されて、1: Error: 最初のエラー と出力される。
②が発生した場合、「A」が実行されて、2: Error: 2番目のエラーと出力される。

つまり、catch が①も②も両方のエラーを拾ってくれるのが分かる。

このようなことから、エラー処理の記述が1箇所で済むため、基本的には onRejectedcatchメソッドで記述するのが良い。

インデント

少し話がそれるが、上記のサンプルでは、thenメソッドとcatchメソッドをnew Promise()から1レベルインデントを下げている。
これはnew Promise() つまり非同期処理が完了してから実行されるメソッドなので、その手続きを視覚的にわかりやすくするための工夫である。
オープンソースのコードを見るとよく見かける。

Promise.resolve()

Promiseオブジェクトの静的メソッドであるresolve()メソッドを使うと、newインスタンス化せずとも、Promiseオブジェクトを作成できる。

Promise.resolve()は下記コードのシンタックスシュガーである。

new Promise(resolve => {
  resolve();
});

また、与えられた引数によって、返されるPromiseオブジェクトの状態が異なる。

Promiseオブジェクトが渡された場合

受け取ったPromiseオブジェクトをそのまま返す。

const p = new Promise((resolve, reject) => {
  resolve('Hello');
});
console.log(Promise.resolve(p) === p); // true

これだけだと「だから?」という感じだが、この特性があることで後述するメソッドチェーンが実行できるメリットがある。

Thenableなオブジェクトが渡された場合

Thenableとは、 Promiseライク なオブジェクトのことで、.thenというメソッドを持っているものを指す。
argumentsNodeList などを ArrayLike と呼ぶのと同じ感じ。

今はあまり使わないかもしれないが、Thenableなものとしては jQuery.ajax() の戻り値がそうである。

console.log(
  Promise.resolve($.ajax('http://httpbin.org/get'))
); 
// Promise {<pending>} 
// [[PromiseStatus]]: "resolved"
// [[PromiseValue]]: Object

確認すると確かにPromiseオブジェクトに変換されて返されている分かる。

それ以外の値が渡された場合

その値をもってFullfilledの状態になったPromiseオブジェクトを返す。

const p = Promise.resolve('Hello');
p.then(value => console.log(value)); // Hello

以上のことをまとめると下記のようになる。

与えらた引数 返されるPromiseオブジェクトの状態
Promiseオブジェクト 受け取ったPromiseオブジェクトをそのまま返す
Thenableなオブジェクト Promiseオブジェクトに変換して返す
それ以外の値 その値をもってFullfilledな状態になったPromiseオブジェクトを返す
引数なし 結果となる値を持たないFullfilledな状態になったPromiseオブジェクトを返す

またPromiseの多くの処理は内部処理にPromise.resolveアルゴリズムを使って、値をPromiseオブジェクトに変換している。
これもまたPromiseのメソッドチェーンを理解するうえで重要なポイントとなるが、詳しくは後述。

Promise.reject()

こちらもPromise.resolve() と同じくPromiseオブジェクトの静的メソッドで、 渡された値をもってRejectedな状態のPromiseオブジェクト を返す。

Promise.reject('何らかのエラー');は下記コードのシンタックスシュガー。
Promise.resolveに比べてそこまで出番はない気がする。

new Promise((resolve, reject) => {
  reject(new Error('何らかのエラー'));
});

Promiseをつなげて使う

Promiseは thencatch をメソッドチェーンとしてつなげて書くことができる。

よくあるajaxでのGETを例にしてみる。
下記は郵便番号検索APIを使ってレスポンスをメソッドチェーンで加工しながら渡していき最終的にひとつの住所を出力している(ちなみにこれは日本一長い住所らしい)。

const get = (url) => {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', url, true);
    xhr.onload = e => {
      if (xhr.status === 200) {
        resolve(xhr.responseText);
      } else {
        reject(new Error(xhr.statusText));
      }
    };
    xhr.onerror = e => {
      reject(new Error(xhr.statusText));
    };
    xhr.send();
  });
}

get('http://zipcloud.ibsnet.co.jp/api/search?zipcode=6028368')
  .then(res => JSON.parse(res))
  .then(json => json.results[0])
  .then(value => value.address1 + value.address2 + value.address3)
  .then(address => console.log(address))
  .catch(reason => console.log(reason));

/* 実行結果
 * '京都府京都市上京区北町上の下立売通天神道西入上る'
 */

なぜこのようなことができるのかというと、thenメソッドが新しいPromiseオブジェクトを返しているから。

ではどのようなPromiseオブジェクトを作成しているかというと、then に渡された関数の戻り値を Promise.resolve に渡して作成したものとなっている(これが先に少し触れた内部的にPromise.resolveのアルゴリズムが使われているということの一つ)。

Promise.resolve は渡された値によって、返すPromiseオブジェクトの状態が異なるが、今回の場合は 文字列 または オブジェクト(配列) であるから、その値をもったfullfilledなPromiseオブジェクトが常に返されている。

fullfilledなPromiseオブジェクトはthenで繋ぐと第一引数の関数(onFullfilled)が実行される。 それが続いていくという流れ。

もっとシンプルな例で整理すると下記のようになる。

Promise.resolve()
  .then(() => { // ①
    return 'Hello';
  })
  .then(value1 => console.log(value1))  // ② Hello
  .then(value2 => console.log(value2)); // ③ undefined

①のthen に渡された関数の戻り値は'Hello' なので、内部的にPromise.resolve('Hello')が実行されて、②のthen で引数を参照している。②の関数の戻り値はundefined なので③ではそのままundefine となるという流れ。

もうひとつ例として、then 内の関数の戻り値がPromiseオブジェクトの場合どうなるかというと、先にあげたように Promise.resolve にPromiseオブジェクトが渡された場合はそのままPromiseオブジェクトが返されるので、そのPromiseオブジェクトの状態によって以降の処理が変わる。

下記は、非同期でリクエスト用の郵便番号を作成してからリクエストを投げるサンプル。

/**
 * 非同期でリクエスト用の郵便番号を作成
 */
const asyncMakeZipCode = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('6028368');
    }, 1000);
  });
}

asyncMakeZipCode()
  .then(zipCode => {
    return get(`http://zipcloud.ibsnet.co.jp/api/search?zipcode=${zipCode}`); // ①
  })
  .then(res => JSON.parse(res))
  .then(json => json.results[0])
  .then(value => value.address1 + value.address2 + value.address3)
  .then(address => console.log(address))
  .catch(reason => console.log(reason));

①でPromiseオブジェクトを返しているが、Fullfilledな状態なので以降の処理が続く。
当然、ajaxが失敗してRejectedになれば catch まで飛ぶ。

thenは常に非同期であることに注意

下記は、すぐに resolve() を実行をして一見非同期処理を書いていないように感じるが、実際には then に渡された関数が実行されるときから非同期となる。

console.log('outer Promise1');

new Promise((resolve, reject) => {
  console.log('A');
  resolve();
})
  .then(() => {
    console.log('B');
    return 'Hello';
  })
  .then(value => {
    console.log('C');
  });

console.log('outer Promise2');

実行結果は

outer Promise1
A
B
C
outer Promise2

とはならずに

outer Promise1
A
outer Promise2
B
C

となる。

なぜこのように非同期になるのかというと、Promiseの仕様で Promise.then で関数を登録する段階でPromiseオブジェクトの状態が決まっていても、そこで登録したコールバック関数は非同期で呼び出させるようになっているため。

そのような仕様になっている理由としては、同期と非同期処理の混在の問題が起きないようにするため。

詳しくは 2.3.1. 同期と非同期の混在の問題 - JavaScript Promiseの本を参照。

エラーハンドリング

Promise.prototype.catch() の箇所で説明したように、非同期処理でエラーが発生し、PromiseオブジェクトがRejectedな状態になったときの処理は、 catchを使って実行することができる。

では下記のようなコードの場合、処理はどう流れるか?

const double = num=> num * 2;
const onRejected = err => console.log(err);

new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve(10);
  }, 1000);
})
  .then(double)
  .catch(onRejected)
  .then(double)
  .then(value => console.log(value)); // 40

結果的には、catch を飛び越えて処理が一番最後まで進み、40 という数値が出力される。

もし、catch よりも前の箇所で reject が呼ばれると、すぐに catch に渡された onRejected が実行される。
onRejected の処理が終わるか、onRejectedがPromiseを返した場合そのPromiseがfullfilledな状態になったとき、次の then に渡している関数が実行される。

上記の実行の途中でエラーを発生させてみると、catch のあとの then が実行されているのが確認できる。

new Promise((resolve, reject) => {
  // 略
})
  .then(double)
  .then(() => {
    throw new Error('何らかのエラー'); // エラー発生
  })
  .catch(onRejected)
  .then(double)
  .then(value => console.log(value));

/* 実行結果
 * Error: 何らかのエラー
 * NaN
 */

これまでの話を整理すると、下記のようなコードは次の図のように説明できる。

async1()
  .then(async2)
  .catch(onRejected)
  .then(aync3);

async1 async2 async3 はそれぞれPromiseを返す関数とする。

f:id:jinseirestart:20180409022825p:plain

Promise.all()

Promise.all を使うと、複数の非同期処理を並列に実行して、そのすべてが成功した時の処理を書くことができる。

引数にPromiseを返す処理を配列で渡し、そのすべてがfullfilledになったときに、then に渡した関数が実行される。
その then に渡された関数の引数には、各Promise内で resolve に渡された値が配列として渡る。

サンプルは下記。

const asyncFunc = value => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(value);
    }, Math.floor(Math.random() * 2000));
  });
}

Promise.all([
  asyncFunc(10),
  asyncFunc(100),
  asyncFunc(1000)
])
  .then(value => console.log(value)); 
  
  /* 実行結果
   * [10, 100, 1000]
   */

Promise.all に渡された配列内の関数はそれぞれ終了タイミングが0~2秒のランダムになっているが、実行結果は必ず [10, 100, 1000] となっている。

これは、引数に渡される配列内の値の順番は、Promise.all へ渡した処理の順番と同じということが保証されているため。

エラー発生時

Promise.all に渡したProimseのうち、ひとつでもrejected(失敗)になったものが発生した場合、即 catch に登録した処理へ移行する。
他のPromiseがfullfilledになっていても then に登録した処理は飛ばされる。

下記で確認してみる。

const asyncFunc = value => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log(value); // エラー発生時に実行されるか確認
      resolve(value);
    }, Math.floor(Math.random() * 2000));
  });
}

const errorFunc = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('何らかのエラー'));
    }, Math.floor(Math.random() * 2000));
  });
}

Promise.all([
  asyncFunc(10),
  errorFunc(), // エラー発生させる
  asyncFunc(100),
  asyncFunc(1000)
])
  .then(value => console.log(value))
  .catch(err => console.log(err));
  
  /* 実行結果
   * 1000
   * 10
   * Error: 何らかのエラー
   * 100
   */

エラーを発生させる errorFuncPromise.all に渡したところ、 正しく、then に渡された関数が実行されることなく catch に移行している。

この場合、errorFunc のあとの残りの関数( asyncFunc) は実行されるのか確認するために asyncFunc 内で console.log(value) を実行させてみた結果、すべて実行されているのが確認できた。

まとめると、Promise.all渡された配列内のPromiseがすべてfullfilledになったら自身もfullfilledになり、ひとつでもrejectedになったら自身もrejectedに移行するPromiseオブジェクトを返す

Promise.race()

Promise.race は、記述の仕方は Promise.all と同じで、配列として渡したPromiseのうち、どれかひとつでもfullfilled または rejected になったら次の処理に移行する。
その名の通り、レース(競争)させるイメージ。

下記はPromise.all の説明で使った最初のサンプルを all から raceに変えただけのもの。

const asyncFunc = value => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(value);
    }, Math.floor(Math.random() * 2000));
  });
}

Promise.race([
  asyncFunc(10),
  asyncFunc(100),
  asyncFunc(1000)
])
  .then(value => console.log(value)); 
  
/* 実行結果
 * 10 または 100 または 1000
 */

実行結果はどれかひとつだけなので、then に渡される関数の引数には all とは異なり配列ではなく、ひとつの値である。

また残りのPromiseについては、キャンセルされることなく並行して実行される。
下記はall で使用したサンプルを少し変更したもの。

const asyncFunc = value => {
  return new Promise(resolve => {
    setTimeout(() => {
      resolve(value);
      console.log(value); // ※2番目以降でもキャンセルされないか確認用
    }, Math.floor(Math.random() * 2000));
  });
}

const errorFunc = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('何らかのエラー'));
      console.log('エラー');  // ※2番目以降でもキャンセルされないか確認用
    }, Math.floor(Math.random() * 2000));
  });
}

Promise.race([
  asyncFunc(10),
  errorFunc(),
  asyncFunc(100),
  asyncFunc(1000)
])
  .then(value => console.log('sucess: ' + value))
  .catch(err => console.log('fail: ' + err));
  
/* 実行結果1: asyncFunc(1000)が最初に完了した場合
 * 1000
 * sucess: 1000
 * 10
 * 100
 * エラー
 */

/* 実行結果2: errorFunc()が最初に完了した場合
 * エラー
 * fail: Error: 何らかのエラー
 * 1000
 * 10
 * 100
 */

下記は2.9. Promise.race - JavaScript Promiseの本からの引用。

ES6 Promisesの仕様には、キャンセルという概念はありません。 必ず、resolve or rejectによる状態の解決が起こることが前提となっています。 つまり、状態が固定されてしまうかもしれない処理には不向きであるといえます。 ライブラリによってはキャンセルを行う仕組みが用意されている場合があります。

まとめ

自分用の備忘録としてだらだらと書いてしまったが、これまでのことをざっくりとまとめてみる。

  • Promiseは非同期処理を抽象的に扱うことができるオブジェクト
  • Promiseは3つの状態を持つ
    • Pending (初期状態)
    • Fullfilled (成功時)
    • Rejected (失敗時)
  • Fullfilled になったとき then に渡された関数が実行される
  • Rejected になったとき catch に渡された関数が実行される
  • Promise.resolve は渡す引数によって返すPromiseオブジェクトの状態が異なる
  • Promiseは thencatch をメソッドチェーンでつないで使うことができる
    • then は常に新しいPromiseオブジェクトを返す
      • then に渡された関数の戻り値を Promise.resolve に渡して実行している
    • thenは常に非同期
  • Promiseにキャンセルの概念はない

参考