Deep JavaScript
この本のサポートをお願いします: 購入する または 寄付する
(広告です。ブロックしないでください。)

17 Promiseの実装による探求



  必要な知識: Promise

この章では、Promiseについて大まかに理解している必要がありますが、関連する知識の多くはここで復習します。必要であれば、「JavaScript for impatient programmers」のPromiseに関する章を読むことができます。

この章では、Promiseに別の角度からアプローチします。このAPIを使用する代わりに、簡単な実装を作成します。この異なる角度は、かつて私がPromiseを理解するのに大いに役立ちました。

Promiseの実装は、クラスToyPromiseです。理解しやすくするために、APIとは完全に一致していません。しかし、Promiseの仕組みを理解するには十分に近いものです。

  コードを含むリポジトリ

ToyPromise は、GitHub の toy-promise リポジトリで入手できます。

17.1 復習: Promiseの状態

図11: Promiseの状態(簡略版): Promiseは最初は保留中です。解決すると、履行済みになります。拒否すると、拒否済みになります。

Promiseの状態の仕組みの簡略版から始めます(図11)

17.2 バージョン1: スタンドアロンPromise

最初の実装は、最小限の機能を備えたスタンドアロンPromiseです。

ToyPromise1 は、3つのプロトタイプメソッドを持つクラスです。

つまり、resolvereject はメソッドです(コンストラクターのコールバックパラメーターに渡される関数ではありません)。

これが最初の実装の使用方法です。

// .resolve() before .then()
const tp1 = new ToyPromise1();
tp1.resolve('abc');
tp1.then((value) => {
  assert.equal(value, 'abc');
});
// .then() before .resolve()
const tp2 = new ToyPromise1();
tp2.then((value) => {
  assert.equal(value, 'def');
});
tp2.resolve('def');

12は、最初のToyPromiseの仕組みを示しています。

  Promiseのデータフローの図はオプションです

図の目的は、Promiseの仕組みを視覚的に説明することです。しかし、それらはオプションです。混乱する場合は、無視してコードに集中できます。

図12: ToyPromise1: Promiseが解決されると、提供された値は*履行反応*(.then() の最初の引数)に渡されます。Promiseが拒否されると、提供された値は*拒否反応*(.then() の2番目の引数)に渡されます。

17.2.1 メソッド .then()

最初に .then() を調べてみましょう。2つのケースを処理する必要があります。

then(onFulfilled, onRejected) {
  const fulfillmentTask = () => {
    if (typeof onFulfilled === 'function') {
      onFulfilled(this._promiseResult);
    }
  };
  const rejectionTask = () => {
    if (typeof onRejected === 'function') {
      onRejected(this._promiseResult);
    }
  };
  switch (this._promiseState) {
    case 'pending':
      this._fulfillmentTasks.push(fulfillmentTask);
      this._rejectionTasks.push(rejectionTask);
      break;
    case 'fulfilled':
      addToTaskQueue(fulfillmentTask);
      break;
    case 'rejected':
      addToTaskQueue(rejectionTask);
      break;
    default:
      throw new Error();
  }
}

前のコードスニペットでは、次のヘルパー関数を使用しています。

function addToTaskQueue(task) {
  setTimeout(task, 0);
}

Promiseは常に非同期に解決される必要があります。そのため、タスクを直接実行するのではなく、イベントループ(ブラウザ、Node.jsなど)のタスクキューに追加します。実際のPromise APIは通常のタスク(setTimeout()など)を使用せず、現在の通常のタスクと緊密に連携し、常にその直後に実行される*マイクロタスク*を使用することに注意してください。

17.2.2 メソッド .resolve()

.resolve() は次のように動作します。Promiseがすでに解決されている場合、何も行いません(Promiseが一度だけ解決されるようにします)。そうでない場合、Promiseの状態は 'fulfilled' に変わり、結果は this.promiseResult にキャッシュされます。次に、これまでにエンキューされたすべての履行反応が呼び出されます。

resolve(value) {
  if (this._promiseState !== 'pending') return this;
  this._promiseState = 'fulfilled';
  this._promiseResult = value;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
  return this; // enable chaining
}
_clearAndEnqueueTasks(tasks) {
  this._fulfillmentTasks = undefined;
  this._rejectionTasks = undefined;
  tasks.map(addToTaskQueue);
}

reject()resolve() に似ています。

17.3 バージョン2: .then() 呼び出しのチェーン

図13: ToyPromise2.then() 呼び出しをチェーンします。.then() は、履行反応または拒否反応によって返される値によって解決されるPromiseを返します。

次に実装する機能はチェーンです(図13): 履行反応または拒否反応から返す値は、後続の .then() 呼び出しの履行反応で処理できます。(次のバージョンでは、Promiseを返すための特別なサポートにより、チェーンがはるかに便利になります。)

次の例では

new ToyPromise2()
  .resolve('result1')
  .then(x => {
    assert.equal(x, 'result1');
    return 'result2';
  })
  .then(x => {
    assert.equal(x, 'result2');
  });

次の例では

new ToyPromise2()
  .reject('error1')
  .then(null,
    x => {
      assert.equal(x, 'error1');
      return 'result2';
    })
  .then(x => {
    assert.equal(x, 'result2');
  });

17.4 簡便メソッド .catch()

新しいバージョンでは、拒否反応のみを提供しやすくする簡便メソッド .catch() が導入されています。履行反応のみを提供することはすでに簡単です。.then() の2番目のパラメーターを省略するだけです(前の例を参照)。

前の例は、それを使用すると見栄えが良くなります(行A)。

new ToyPromise2()
  .reject('error1')
  .catch(x => { // (A)
    assert.equal(x, 'error1');
    return 'result2';
  })
  .then(x => {
    assert.equal(x, 'result2');
  });

次の2つのメソッド呼び出しは同等です。

.catch(rejectionReaction)
.then(null, rejectionReaction)

これが .catch() の実装方法です。

catch(onRejected) { // [new]
  return this.then(null, onRejected);
}

17.5 反応の省略

新しいバージョンでは、履行反応を省略すると履行が転送され、拒否反応を省略すると拒否が転送されます。なぜそれが役に立つのでしょうか?

次の例は、拒否の受け渡しを示しています。

someAsyncFunction()
  .then(fulfillmentReaction1)
  .then(fulfillmentReaction2)
  .catch(rejectionReaction);

rejectionReaction は、someAsyncFunction()fulfillmentReaction1、および fulfillmentReaction2 の拒否を処理できるようになりました。

次の例は、履行の受け渡しを示しています。

someAsyncFunction()
  .catch(rejectionReaction)
  .then(fulfillmentReaction);

someAsyncFunction() がPromiseを拒否した場合、rejectionReaction は問題を修正し、fulfillmentReaction によって処理される履行値を返すことができます。

someAsyncFunction() がPromiseを履行した場合、.catch() がスキップされるため、fulfillmentReaction もそれを処理できます。

17.6 実装

これはすべてどのように内部で処理されるのでしょうか?

.then() のみが変更されます。

then(onFulfilled, onRejected) {
  const resultPromise = new ToyPromise2(); // [new]

  const fulfillmentTask = () => {
    if (typeof onFulfilled === 'function') {
      const returned = onFulfilled(this._promiseResult);
      resultPromise.resolve(returned); // [new]
    } else { // [new]
      // `onFulfilled` is missing
      // => we must pass on the fulfillment value
      resultPromise.resolve(this._promiseResult);
    }  
  };

  const rejectionTask = () => {
    if (typeof onRejected === 'function') {
      const returned = onRejected(this._promiseResult);
      resultPromise.resolve(returned); // [new]
    } else { // [new]
      // `onRejected` is missing
      // => we must pass on the rejection value
      resultPromise.reject(this._promiseResult);
    }
  };

  ···

  return resultPromise; // [new]
}

.then() は新しいPromiseを作成して返します(メソッドの最初の行と最後の行)。さらに

17.7 バージョン3: .then() コールバックから返されたPromiseのフラット化

17.7.1 .then() のコールバックからPromiseを返す

Promiseのフラット化は、主にチェーンをより便利にすることです。ある .then() コールバックから次のコールバックに値を渡したい場合は、前者でそれを返します。その後、.then() はそれをすでに返したPromiseに入れます。

.then() コールバックからPromiseを返すと、このアプローチは不便になります。たとえば、Promiseベース関数の結果(行A)

asyncFunc1()
.then((result1) => {
  assert.equal(result1, 'Result of asyncFunc1()');
  return asyncFunc2(); // (A)
})
.then((result2Promise) => {
  result2Promise
  .then((result2) => { // (B)
    assert.equal(
      result2, 'Result of asyncFunc2()');
  });
});

今回、A行で返された値を .then() によって返されたPromiseに入れると、B行でそのPromiseをアンラップする必要があります。代わりに、A行で返されたPromiseが .then() によって返されたPromiseを置き換えればよいでしょう。それがどのように行われるかはすぐにはわかりませんが、それが機能すれば、次のようにコードを書くことができます。

asyncFunc1()
.then((result1) => {
  assert.equal(result1, 'Result of asyncFunc1()');
  return asyncFunc2(); // (A)
})
.then((result2) => {
  // result2 is the fulfillment value, not the Promise
  assert.equal(
    result2, 'Result of asyncFunc2()');
});

A行では、Promiseを返しました。Promiseのフラット化のおかげで、result2 はそのPromiseの履行値であり、Promise自体ではありません。

17.7.2 フラット化によりPromiseの状態がより複雑になる

  ECMAScript仕様におけるPromiseのフラット化

ECMAScript仕様では、Promiseのフラット化の詳細は、セクション「Promiseオブジェクト」で説明されています。

Promise APIはフラット化をどのように処理するのでしょうか?

Promise PがPromise Qで解決された場合、PはQをラップせず、PはQに「なります」: Pの状態と解決値は常にQと同じになります。これは、.then() がコールバックの1つによって返された値で返すPromiseを解決するため、.then() に役立ちます。

PはどのようにQになるのでしょうか? Qに*ロックイン*することによって: Pは外部から解決できなくなり、Qの解決がPの解決をトリガーします。ロックインは、状態をより複雑にする追加の不可視のPromise状態です。

Promise API には、Q が Promise である必要はなく、いわゆる *thenable* であれば良いという、Q に関して追加の機能があります。 thenable とは、`then()` メソッドを持つオブジェクトです。この柔軟性が追加された理由は、異なる Promise 実装が連携できるようにするためです(これは、Promise が最初に言語に追加されたときに重要でした)。

図 14 は、新しい状態を視覚化したものです。

図 14: Promise のすべての状態: Promise のフラット化により、不可視の擬似状態「ロックイン」が導入されます。 Promise P が thenable Q で解決されると、この状態になります。その後、P の状態と決済値は常に Q の状態と決済値と同じになります。

*解決* の概念もより複雑になっていることに注意してください。 Promise を解決するということは、もはや直接決済できないということを意味するようになりました。

ECMAScript 仕様では、次のように述べられています。「未解決の Promise は常に保留状態です。解決された Promise は、保留、履行、または拒否される可能性があります。」

17.7.3 Promise のフラット化の実装

図 15 は、`ToyPromise3` がフラット化をどのように処理するかを示しています。

図 15: `ToyPromise3` は解決された Promise をフラット化します: 最初の Promise が thenable `x1` で解決された場合、`x1` にロックインされ、`x1` の決済値で決済されます。最初の Promise が thenable ではない値で解決された場合、すべて以前と同じように動作します。

thenable は、次の関数を使用して検出します。

function isThenable(value) { // [new]
  return typeof value === 'object' && value !== null
    && typeof value.then === 'function';
}

ロックインを実装するために、新しいブール値フラグ `._alreadyResolved` を導入します。これを `true` に設定すると、`.resolve()` と `.reject()` が無効になります。例えば、

resolve(value) { // [new]
  if (this._alreadyResolved) return this;
  this._alreadyResolved = true;

  if (isThenable(value)) {
    // Forward fulfillments and rejections from `value` to `this`.
    // The callbacks are always executed asynchronously
    value.then(
      (result) => this._doFulfill(result),
      (error) => this._doReject(error));
  } else {
    this._doFulfill(value);
  }

  return this; // enable chaining
}

`value` が thenable の場合、現在の Promise をそれにロックインします。

決済は、`._alreadyResolved` による保護を回避するために、プライベートメソッド `._doFulfill()` と `._doReject()` を介して実行されます。

`._doFulfill()` は比較的単純です。

_doFulfill(value) { // [new]
  assert.ok(!isThenable(value));
  this._promiseState = 'fulfilled';
  this._promiseResult = value;
  this._clearAndEnqueueTasks(this._fulfillmentTasks);
}

`.reject()` はここでは示されていません。その唯一の新しい機能は、`._alreadyResolved` にも従うようになったことです。

17.8 バージョン 4: リアクションコールバックでスローされた例外

図 16: `ToyPromise4` は、Promise リアクションの例外を `.then()` によって返された Promise の拒否に変換します。

最後の機能として、Promise がユーザーコードの例外を拒否として処理するようにします(図 16)。この章では、「ユーザーコード」とは `.then()` の 2 つのコールバックパラメータを意味します。

new ToyPromise4()
  .resolve('a')
  .then((value) => {
    assert.equal(value, 'a');
    throw 'b'; // triggers a rejection
  })
  .catch((error) => {
    assert.equal(error, 'b');
  })

`.then()` は、ヘルパーメソッド `._runReactionSafely()` を介して、Promise リアクション `onFulfilled` と `onRejected` を安全に実行するようになりました。例えば、

  const fulfillmentTask = () => {
    if (typeof onFulfilled === 'function') {
      this._runReactionSafely(resultPromise, onFulfilled); // [new]
    } else {
      // `onFulfilled` is missing
      // => we must pass on the fulfillment value
      resultPromise.resolve(this._promiseResult);
    }  
  };

`._runReactionSafely()` は次のように実装されます。

_runReactionSafely(resultPromise, reaction) { // [new]
  try {
    const returned = reaction(this._promiseResult);
    resultPromise.resolve(returned);
  } catch (e) {
    resultPromise.reject(e);
  }
}

17.9 バージョン 5: コンストラクターパターンの公開

最後の手順を省略しています。`ToyPromise` を実際の Promise 実装に変換する場合、公開コンストラクターパターンを実装する必要があります。JavaScript の Promise は、メソッドを介して解決および拒否されるのではなく、コンストラクターのコールバックパラメーターである *executor* に渡される関数を介して解決および拒否されます。

const promise = new Promise(
  (resolve, reject) => { // executor
    // ···
  });

executor が例外をスローした場合、`promise` は拒否されます。