KDE BLOG

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

【JavaScript】ちゃんと理解しておきたいPromiseの勘所など

以前下記の記事でPromiseについて調べたことをまとめましたが、どうもすっきりと腹落ちしていませんでした。

kde.hateblo.jp

ようやく最近になってふと腹落ちして理解できたと思うので、改めて自分の頭の整理のためにまとめておきたいと思います。

Promiseとは

PromiseはES2015から導入された非同期処理を扱いやすくするオブジェクトです。
Promiseが登場するまでの非同期の扱いは、エラーファーストコールバックなどのルールを決めて少しでもコードの見通しがよくなるように実装者の間で努力が重ねられてきました。
しかしますます非同期処理が複雑化する中で、きちんと仕様として非同期の扱いを決めようという流れのもと作成されたのがPromiseオブジェクトになります。

Promiseの勘所

個人的にこれを抑えておけば最低限理解できて、応用が利くだろうという点を記述しておきます。

絶対に必要な基礎知識

Promiseオブジェクトは、作成された時点では Pending (待機)状態です。
その後のコールバック関数内で resolve() (解決)された時点で Fulfilled 状態となり、then() が実行されます。
逆に reject() (失敗)が呼び出された時点で Rejected 状態となり、catch() が実行されます。
つまり resolve()reject() も呼び出されない間は pending 状態のまま、次のチェーンには進みません。

状態が一度 Pending から変化すると、その後の状態は二度と変化しないことが約束されています。
resolve() または reject() は一度しか実行されません。

Promise.resolve() は渡す引数によって返されるPromiseオブジェクトが変わる

Promiseは内部処理に Promise.resolve() がよく使われているので、このメソッドの挙動について理解することはPromiseの理解に外せません。
Promise.resolve() はPromiseオブジェクトを返すメソッドですが、渡す引数によって返されるPromiseオブジェクトが下記のように変わります。

渡す引数 返されるオブジェクトの状態
Promiseオブジェクト 受け取ったPromiseオブジェクトをそのまま返す
thenableなオブジェクト 新たなpromiseオブジェクトにして返す
その他の値(オブジェクトやnull等も含む) その値でFulfilledとなった新たなpromiseオブジェクトを作り返す

then() は 非同期で行われるPromise.resolve() とほぼ同じ役割

then() は次の特徴があります。

  • 新しいPromiseオブジェクトを返す
    • どのようにオブジェクトを生成しているかというと、then に渡された関数が return した値を、Promise.resolve() に渡して新しいPromiseオブジェクトを生成している
  • 非同期で実行される

下記のサンプルで確認します。

const promise1 = Promise.resolve('hoge');
const promise1Then = promise1.then(res => res);

console.log(promise1);     // ① Promise {<resolved>: "hoge"}
console.log(promise1Then); // ② Promise {<pending>}

setTimeout(() => {
  console.log(promise1Then); // ③ Promise {<resolved>: "hoge"}
  console.log(promise1 === promise1Then); // ④ false
  console.log('非同期終了');
}, 100);

console.log('非同期開始');

/* 下記の順番で console に表示される
------
Promise {<resolved>: "hoge"}
Promise {<pending>}
非同期開始
Promise {<resolved>: "hoge"}
false
非同期終了
------
*/

① では promise1 の中身は FulFilled 状態のPromiseオブジェクトが入っているので、Promise.resolve('hoge'); は同期的に実行されていることが分かります。

次の②では promise1Then の中身は Pending 状態のPromiseオブジェクトが入っているので、まだ resolve() が実行されていない、つまり then() が実行されていないことが分かります。

setTimeout 内の③で、promise1Then の中身が FulFilled 状態になったことが確認できたので、then() は非同期で実行されていることが分かります。

④の promise1promise1Then の比較で false になっていることから、then() は新しいオブジェクトを返していることが分かります。

Promiseオブジェクトを渡した例

Promiseオブジェクトを引数に渡すと受け取ったPromiseオブジェクトをそのまま返すというのがよく使われる特性かと思います。

具体的には下記のような fetchAPIからデータを取得する場合などです。

// 祝日一覧APIをコール
fetch('https://holidays-jp.github.io/api/v1/date.json')
  .then(res => {
    if (res.ok) {
      return res.json(); 
    }
  })
  .then(json => {
    console.log(json); // {2017-01-01: "元日", 2017-01-02: "元日 振替休日", …}
  });

下記の部分を見てみます。

// 略
  .then(res => {
    if (res.ok) {
      return res.json(); 
    }
  })
// 略

res.json() は、ボディテキストを Json として解析した結果で解決されるPromiseを返すResponseオブジェクトのメソッドです。
(fetch については先日書いた記事に基本がまとまっています。
【JavaScript基礎】Fetch APIの基礎 - KDE BLOG

then に渡された無名関数は res.json() を返しているので、then が返すものとしては Promise.resolve(res.json()) となります。

res.json() の返り値はPromiseオブジェクトです。
Promise.resolve() にPromiseオブジェクトを渡した場合は、そのPromiseオブジェクトがそのまま返されるので、res.json() が返したPromiseオブジェクトが Fulfilled (解決)状態になったら次の then に進むということになります。

Promiseでの逐次(直列)処理

逐次処理とは、 ひとつずつ順番に処理する方法です。対して並列処理は同時に複数の処理をする方法です。
Promiseには並列処理を行う Promise.all() という便利なメソッドがありますが、逐次処理を行う特別なメソッドはありません。
何かと使うことが多い逐次処理の仕方を記述します。

とはいっても難しいことはなく、基本的には then() で繋いでいけば逐次処理が可能です。

下記は、timer 関数を逐次処理でつないで実行しているサンプルになります。
timer 関数は resolve() または reject() される確率が1/2ずつで、resolve() されれば次の処理が進みます。
無事最後まで処理されて結果の配列が表示されるのは 1/2 ** 3 = 1/8 の確率です…!

/**
 * 指定秒数後にコールバックを実行してPromiseを返す
 * @param ms {Number}
 * @param callback {Function}
 * @return {Promise}
 */
const timer = (ms, callback) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.floor(Math.random() * 10) % 2 === 0) {
        const result = callback();
        resolve(result)
      } else {
        reject(new Error('エラーです'));
      }
    }, ms);
  });
};

// 結果を入れる配列
const result = [];

timer(1000, () => {
  console.log('1つめ実行');
  return 1;
})
  .then(value => {
    result.push(value);
    return timer(1000, () => {
      console.log('2つめ実行');
      return 2;
    });
  })
  .then(value => {
    result.push(value);
    return timer(1000, () => {
      console.log('3つめ実行');
      return 3;
    });
  })
  .then(value => {
    result.push(value);
    console.log(result); // [1, 2, 3]
  })
  .catch(console.error);

Array.prototype.reduce() と組み合わせて見やすく書く

上記の then をつなげていく書き方では、処理が増えればどんどんと縦に長くなっていってしまいます。
JavaScript Promiseの本 に倣い、Promiseを使った逐次処理と相性の良い Array.prototype.reduce() を使って、逐次処理を行う関数を定義して記述します。

const sequenceTasks = (tasks) => {
  const recordValue = (results, value) => {
    results.push(value);
    return results;
  };
  const pushValue = recordValue.bind(null, []);
  return tasks.reduce((prev, task) => {
    const promise = prev.then(task).then(pushValue);
    return promise;
  }, Promise.resolve());
};

const timer = (ms, callback) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (Math.floor(Math.random() * 10) % 2 === 0) {
        const result = callback();
        resolve(result)
      } else {
        reject(new Error('エラーです'));
      }
    }, ms);
  });
};

const tasks = {
  task1() {
    return timer(1000, () => {
      console.log('1つめ実行');
      return 1;
    });
  },
  task2() {
    return timer(1000, () => {
      console.log('2つめ実行');
      return 2;
    });
  },
  task3() {
    return timer(1000, () => {
      console.log('3つめ実行');
      return 3;
    });
  }
};

// Object.values(tasks) は [tasks.task1, tasks.task2, tasks.task3] と同じ意味
const main = () => sequenceTasks(Object.values(tasks));

main()
  .then(console.log)
  .catch(console.error);

役割ごとに関数で分かれてだいぶ見やすくなりました。

Promise.race() を使ったタイムアウト処理

Promise.race() は、引数に配列で渡したPromiseオブジェクトの中で、どれか一つでもFulfilled または Rejectedになったら次の処理を実行するPromiseの静的メソッドです。

使う機会が他のメソッドに比べると少なく、私は1回も実務で使ったことがありません。
しかしこのメソッドを使うとタイムアウト処理が簡単に実装できることを JavaScript Promiseの本 で知ったので、こちらに倣いコードを書いてみます。

下記は、祝日一覧APIからfetchするサンプルですが、1秒を超すとタイムアウトするサンプルです。

/**
 * 指定ミリ秒でタイムアウト
 * @param {Number} ms
 * @return {Promise}
 */
const timeout = (ms) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('Timeout...'));
    }, ms);
  })
};

/**
 * fetch
 * @param {String} path
 */
const fetchData = (path) => {
  return fetch(path)
    .then(res => {
      if (!res.ok) {
        return Promise.reject(new Error(res.status));
      }
      return res.json();
    })
    .catch(error => Promise.reject(error));
};

Promise.race([
  fetchData('https://holidays-jp.github.io/api/v1/date.json'),
  timeout(1000)
])
  .then(console.log)
  .catch(console.error);

Promise.race の部分を丸めて関数にすると下記のような形で書けます。

const timeout = // 略

const fetchData = // 略

// 新規
const timeoutPromise = (promise, ms) => Promise.race([promise, timeout(ms)])

timeoutPromise(fetchData('http://httpbin.org/get'), 1000)
  .then(console.log)
  .catch(console.error);

まとめ

Promiseを扱う際には、Promiseが今どのような状態(pending、Fulfilled、Rejected)になっているのかをイメージしながら実装すると理解しやすいように感じます。
Promiseを理解できれば async/await も理解できるはず。

参考