メインコンテンツへスキップ

Promiseの仕様

· loading · loading ·
kiitosu
著者
kiitosu
画像処理やデバイスドライバ、データ基盤構築からWebバックエンドまで、多様な領域に携わってきました。地図解析や地図アプリケーションの仕組みにも経験があり、幅広い技術を活かした開発に取り組んでいます。休日は草野球とランニングを楽しんでいます。
目次

はじめに
#

javascript 勉強中です。javascript は構文が多くて理解しづらいイメージです。
今は javascriptPromise の使い方がしっくり来なくて困っています。

Claudeに聞いてみたところ、Promise は仕様が定まっていて、仕様に従った自作の Promise を自作することもできるようです! 今日は Promise の仕組みを理解し、自作してみたいと思います。

まずは仕様の理解から。

Promise/A+ 仕様(日本語訳)
#

原文: https://promisesaplus.com/


実装者による、実装者のための、健全で相互運用可能な JavaScript Promise のオープン標準

Promise は非同期処理の最終的な結果を表します。Promise とやり取りする主な方法は then メソッドを通じて行われ、このメソッドにコールバックを登録することで、Promise の最終的な値、または Promise が履行できなかった理由を受け取ることができます。

この仕様は then メソッドの動作を詳細に定義しており、すべての Promises/A+ 準拠の実装が相互運用可能な基盤を提供します。そのため、この仕様は非常に安定していると考えられています。Promises/A+ 組織は、新たに発見されたコーナーケースに対処するため、下位互換性のある軽微な変更を加えることがありますが、大規模な変更や下位互換性のない変更は、慎重な検討、議論、テストを経てからのみ統合されます。

歴史的に、Promises/A+ は以前の Promises/A 提案の動作に関する条項を明確化し、デファクトの動作をカバーするように拡張し、仕様が不十分または問題のある部分を省略しています。

最後に、Promises/A+ のコア仕様は Promise の作成、履行、拒否の方法を扱わず、相互運用可能な then メソッドの提供に焦点を当てています。今後の関連仕様でこれらの主題に触れる可能性があります。

1. 用語
#

  1. “promise” は、この仕様に準拠した動作をする then メソッドを持つオブジェクトまたは関数です。
  2. “thenable” は、then メソッドを定義するオブジェクトまたは関数です。
  3. “value”(値) は、任意の正当な JavaScript 値です(undefined、thenable、promise を含む)。
  4. “exception”(例外) は、throw 文を使用してスローされる値です。
  5. “reason”(理由) は、promise が拒否された理由を示す値です。

2. 要件
#

2.1 Promise の状態
#

promise は、pending(保留中)、fulfilled(履行済み)、rejected(拒否済み)の3つの状態のいずれかでなければなりません。

  1. pending の場合、promise は:

    1. fulfilled または rejected 状態に遷移できる。
  2. fulfilled の場合、promise は:

    1. 他の状態に遷移してはならない。
    2. 変更不可の value を持たなければならない。
  3. rejected の場合、promise は:

    1. 他の状態に遷移してはならない。
    2. 変更不可の reason を持たなければならない。

    ここで「変更不可」とは、不変の同一性(つまり ===)を意味し、深い不変性を意味するものではありません。

「変更不可」の意味: Promise が保持する値への参照が変わらないことを要求しています。オブジェクトの中身(プロパティ)まで凍結する必要はありません。例えば const obj = { name: "Alice" } で resolve した場合、後から obj.name = "Bob" と変更しても仕様違反ではありません。

2.2 then メソッド
#

promise は、現在または最終的な value や reason にアクセスするための then メソッドを提供しなければなりません。

promise の then メソッドは2つの引数を受け取ります:

promise.then(onFulfilled, onRejected)
  1. onFulfilledonRejected は両方ともオプションの引数です:

    1. onFulfilled が関数でない場合、無視されなければならない。
    2. onRejected が関数でない場合、無視されなければならない。
  2. onFulfilled が関数の場合:

    1. promise が履行された後に、promise の value を第1引数として呼び出されなければならない。
    2. promise が履行される前に呼び出されてはならない。
    3. 2回以上呼び出されてはならない。
  3. onRejected が関数の場合:

    1. promise が拒否された後に、promise の reason を第1引数として呼び出されなければならない。
    2. promise が拒否される前に呼び出されてはならない。
    3. 2回以上呼び出されてはならない。
  4. onFulfilled または onRejected は、実行コンテキストスタックにプラットフォームコードのみが含まれるようになるまで呼び出されてはならない。[注釈 3.1]

「then のコールバックは、then を呼び出したコードと同じ実行コンテキスト内で即座に実行してはならない」という意味です。 コールバックはマイクロタスクキューにスケジューリングされ、現在の実行コンテキストスタックが空になった後に実行されます。
  1. onFulfilledonRejected は関数として呼び出されなければならない(つまり this 値なしで)。[注釈 3.2]

  2. then は同じ promise に対して複数回呼び出すことができる。

    1. promise が履行された場合、すべての onFulfilled コールバックは、then への呼び出し順序で実行されなければならない。
    2. promise が拒否された場合、すべての onRejected コールバックは、then への呼び出し順序で実行されなければならない。
  3. then は promise を返さなければならない。[注釈 3.3]

    promise2 = promise1.then(onFulfilled, onRejected);
    
    1. onFulfilled または onRejected が値 x を返した場合、Promise Resolution Procedure [[Resolve]](promise2, x) を実行する。
    2. onFulfilled または onRejected が例外 e をスローした場合、promise2e を reason として拒否されなければならない。
    3. onFulfilled が関数ではなく、promise1 が履行された場合、promise2promise1 と同じ value で履行されなければならない。
    4. onRejected が関数ではなく、promise1 が拒否された場合、promise2promise1 と同じ reason で拒否されなければならない。

2.3 Promise Resolution Procedure
#

Promise Resolution Procedure は、promise と値を入力として受け取る抽象的な操作であり、[[Resolve]](promise, x) と表記します。x が thenable の場合、x が少なくともある程度 promise のように振る舞うという前提のもとで、promisex の状態を採用させようとします。そうでなければ、promise を値 x で履行します。

[[Resolve]] とは: 二重角括弧 [[ ]] は ECMAScript 仕様書で使われる慣例的な表記で、外部から直接呼び出せない内部的な処理を意味します。promise.[[Resolve]](x) のようには書けません。実際のコードでは resolve(x) を呼ぶと内部で [[Resolve]] が実行されます。

「状態を採用する」とは: x が普通の値ならその値で即座に履行しますが、x が Promise などの thenable なら、その結果を「待って」から次に進みます。これにより fetch().then(res => res.json()).then(data => ...) のようにPromiseチェーンで非同期処理を繋げられます。

[[Resolve]](promise, x) を実行するには、以下の手順を実行します:

  1. promisex が同じオブジェクトを参照している場合、TypeError を reason として promise を拒否する。
なぜ自己参照チェックが必要か: Promise が自分自身で解決しようとすると、「自分の解決を待つ → でも自分はまだ解決していない → 自分の解決を待つ…」という無限ループになります。これを早期に検出して TypeError で拒否することで、デッドロックを防ぎます。
  1. x が promise の場合、その状態を採用する: [注釈 3.4]

    1. x が pending の場合、promisex が履行または拒否されるまで pending のままでなければならない。
    2. x が履行された場合、同じ value で promise を履行する。
    3. x が拒否された場合、同じ reason で promise を拒否する。
  2. そうでなければ、x がオブジェクトまたは関数の場合:

    1. thenx.then とする。[注釈 3.5]
    2. x.then プロパティの取得が例外 e をスローした場合、e を reason として promise を拒否する。
    3. then が関数の場合、xthis として、第1引数を resolvePromise、第2引数を rejectPromise として呼び出す。ここで:
      1. resolvePromise が値 y で呼び出された場合、[[Resolve]](promise, y) を実行する。
      2. rejectPromise が reason r で呼び出された場合、r を reason として promise を拒否する。
      3. resolvePromiserejectPromise の両方が呼び出された場合、または同じ引数への複数回の呼び出しが行われた場合、最初の呼び出しが優先され、それ以降の呼び出しは無視される。
      4. then の呼び出しが例外 e をスローした場合:
        1. resolvePromise または rejectPromise がすでに呼び出されている場合、無視する。
        2. そうでなければ、e を reason として promise を拒否する。
    4. then が関数でない場合、xpromise を履行する。
ダックタイピングの限界: then メソッドがあれば thenable として扱いますが、それが正しく動作する保証はありません。仕様はこのリスクを認識しており、例外処理や called フラグによる防御的な実装を要求しています。これは異なる Promise ライブラリ間の相互運用性を優先した設計上のトレードオフです。
  1. x がオブジェクトでも関数でもない場合、xpromise を履行する。

promise が循環する thenable チェーンに参加する thenable で解決された場合、[[Resolve]](promise, thenable) の再帰的な性質により、最終的に [[Resolve]](promise, thenable) が再び呼び出され、無限再帰につながります。実装は、このような再帰を検出し、情報を提供する TypeError を reason として promise を拒否することが推奨されますが、必須ではありません。[注釈 3.6]

3. 注釈
#

  1. ここで「プラットフォームコード」とは、エンジン、環境、および promise 実装コードを指します。実際には、この要件により、onFulfilledonRejected は、then が呼び出されたイベントループのターンの後、新しいスタックで非同期に実行されることが保証されます。これは、setTimeoutsetImmediate などの「マクロタスク」メカニズム、または MutationObserverprocess.nextTick などの「マイクロタスク」メカニズムで実装できます。promise 実装はプラットフォームコードと見なされるため、ハンドラをスケジュールするためのタスクキューまたは「トランポリン」を含む場合があります。
トランポリンとは: 再帰呼び出しをループに変換する技法です。コールバックを直接呼び出す代わりにキューに入れて順番に処理することで、深いPromiseチェーンでもスタックオーバーフローを防ぎます。queueMicrotask 自体がトランポリンの役割を果たしています。
  1. つまり、strict モードでは、ハンドラ内で thisundefined になります。sloppy モードでは、グローバルオブジェクトになります。
sloppy モードとは: strict モードの反対で、通常の JavaScript モードの俗称です。"use strict" を書かない場合のデフォルトの動作モードで、エラー検出が緩く、this の挙動なども異なります。
  1. 実装は、すべての要件を満たす限り、promise2 === promise1 を許可することができます。各実装は、promise2 === promise1 を生成できる条件があるかどうか、またその条件を文書化する必要があります。
promise2 === promise1 とは: 通常 then は新しい Promise を返しますが、最適化として同じ Promise を返すことも許可されています。ただし現実にはほとんどの実装(ネイティブ Promise 含む)は常に新しい Promise を返します。これはオプションの許可であり、必須要件ではありません。
  1. 一般的に、x が現在の実装からの真の promise であるかどうかは、実装固有の手段を使用して知ることができます。この条項により、実装固有の手段を使用して、準拠する promise の状態を採用することができます。
自分の Promise かどうかの判別: 外部の thenable は then メソッド経由でしか処理できませんが、自分の実装が作った Promise なら instanceof で判別して内部状態に直接アクセスする最適化が許可されています。これにより効率的な処理が可能になります。
  1. この手順では、まず x.then への参照を格納し、その参照をテストしてから呼び出すことで、x.then プロパティへの複数のアクセスを回避します。このような予防措置は、アクセス間で値が変化する可能性があるアクセサプロパティに対して一貫性を確保するために重要です。

  2. 実装は、thenable チェーンの深さに任意の制限を設けるべきではなく、その制限を超えると再帰は無限であると想定します。真の循環のみが TypeError につながるべきであり、異なる thenable の無限チェーンに遭遇した場合、永遠に再帰することが正しい動作です。

2種類の「無限」: (1) resolve(p) で自分自身を参照する「真の循環」は検出可能なので TypeError で拒否すべきです。(2) 毎回新しい thenable を返す無限チェーンは、異なるオブジェクトなので循環とは言えず、深さ制限を設けるべきではありません。1000回リトライするような正当なユースケースが動かなくなるためです。
Reply by Email

関連記事

プロトタイプベース言語
· loading · loading
AIに声援を送ると隠れたパワーが解放される
· loading · loading
VSCodeのターミナルをフローティングする
· loading · loading