はじめに #
javascript 勉強中です。javascript は構文が多くて理解しづらいイメージです。
今は javascript の Promise の使い方がしっくり来なくて困っています。
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. 用語 #
- “promise” は、この仕様に準拠した動作をする
thenメソッドを持つオブジェクトまたは関数です。 - “thenable” は、
thenメソッドを定義するオブジェクトまたは関数です。 - “value”(値) は、任意の正当な JavaScript 値です(
undefined、thenable、promise を含む)。 - “exception”(例外) は、
throw文を使用してスローされる値です。 - “reason”(理由) は、promise が拒否された理由を示す値です。
2. 要件 #
2.1 Promise の状態 #
promise は、pending(保留中)、fulfilled(履行済み)、rejected(拒否済み)の3つの状態のいずれかでなければなりません。
-
pending の場合、promise は:
- fulfilled または rejected 状態に遷移できる。
-
fulfilled の場合、promise は:
- 他の状態に遷移してはならない。
- 変更不可の value を持たなければならない。
-
rejected の場合、promise は:
- 他の状態に遷移してはならない。
- 変更不可の reason を持たなければならない。
ここで「変更不可」とは、不変の同一性(つまり
===)を意味し、深い不変性を意味するものではありません。
const obj = { name: "Alice" } で resolve した場合、後から obj.name = "Bob" と変更しても仕様違反ではありません。
2.2 then メソッド
#
promise は、現在または最終的な value や reason にアクセスするための then メソッドを提供しなければなりません。
promise の then メソッドは2つの引数を受け取ります:
promise.then(onFulfilled, onRejected)
-
onFulfilledとonRejectedは両方ともオプションの引数です:onFulfilledが関数でない場合、無視されなければならない。onRejectedが関数でない場合、無視されなければならない。
-
onFulfilledが関数の場合:promiseが履行された後に、promiseの value を第1引数として呼び出されなければならない。promiseが履行される前に呼び出されてはならない。- 2回以上呼び出されてはならない。
-
onRejectedが関数の場合:promiseが拒否された後に、promiseの reason を第1引数として呼び出されなければならない。promiseが拒否される前に呼び出されてはならない。- 2回以上呼び出されてはならない。
-
onFulfilledまたはonRejectedは、実行コンテキストスタックにプラットフォームコードのみが含まれるようになるまで呼び出されてはならない。[注釈 3.1]
-
onFulfilledとonRejectedは関数として呼び出されなければならない(つまりthis値なしで)。[注釈 3.2] -
thenは同じ promise に対して複数回呼び出すことができる。promiseが履行された場合、すべてのonFulfilledコールバックは、thenへの呼び出し順序で実行されなければならない。promiseが拒否された場合、すべてのonRejectedコールバックは、thenへの呼び出し順序で実行されなければならない。
-
thenは promise を返さなければならない。[注釈 3.3]promise2 = promise1.then(onFulfilled, onRejected);onFulfilledまたはonRejectedが値xを返した場合、Promise Resolution Procedure[[Resolve]](promise2, x)を実行する。onFulfilledまたはonRejectedが例外eをスローした場合、promise2はeを reason として拒否されなければならない。onFulfilledが関数ではなく、promise1が履行された場合、promise2はpromise1と同じ value で履行されなければならない。onRejectedが関数ではなく、promise1が拒否された場合、promise2はpromise1と同じ reason で拒否されなければならない。
2.3 Promise Resolution Procedure #
Promise Resolution Procedure は、promise と値を入力として受け取る抽象的な操作であり、[[Resolve]](promise, x) と表記します。x が thenable の場合、x が少なくともある程度 promise のように振る舞うという前提のもとで、promise に x の状態を採用させようとします。そうでなければ、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) を実行するには、以下の手順を実行します:
promiseとxが同じオブジェクトを参照している場合、TypeErrorを reason としてpromiseを拒否する。
TypeError で拒否することで、デッドロックを防ぎます。
-
xが promise の場合、その状態を採用する: [注釈 3.4]xが pending の場合、promiseはxが履行または拒否されるまで pending のままでなければならない。xが履行された場合、同じ value でpromiseを履行する。xが拒否された場合、同じ reason でpromiseを拒否する。
-
そうでなければ、
xがオブジェクトまたは関数の場合:thenをx.thenとする。[注釈 3.5]x.thenプロパティの取得が例外eをスローした場合、eを reason としてpromiseを拒否する。thenが関数の場合、xをthisとして、第1引数をresolvePromise、第2引数をrejectPromiseとして呼び出す。ここで:resolvePromiseが値yで呼び出された場合、[[Resolve]](promise, y)を実行する。rejectPromiseが reasonrで呼び出された場合、rを reason としてpromiseを拒否する。resolvePromiseとrejectPromiseの両方が呼び出された場合、または同じ引数への複数回の呼び出しが行われた場合、最初の呼び出しが優先され、それ以降の呼び出しは無視される。thenの呼び出しが例外eをスローした場合:resolvePromiseまたはrejectPromiseがすでに呼び出されている場合、無視する。- そうでなければ、
eを reason としてpromiseを拒否する。
thenが関数でない場合、xでpromiseを履行する。
then メソッドがあれば thenable として扱いますが、それが正しく動作する保証はありません。仕様はこのリスクを認識しており、例外処理や called フラグによる防御的な実装を要求しています。これは異なる Promise ライブラリ間の相互運用性を優先した設計上のトレードオフです。
xがオブジェクトでも関数でもない場合、xでpromiseを履行する。
promise が循環する thenable チェーンに参加する thenable で解決された場合、[[Resolve]](promise, thenable) の再帰的な性質により、最終的に [[Resolve]](promise, thenable) が再び呼び出され、無限再帰につながります。実装は、このような再帰を検出し、情報を提供する TypeError を reason として promise を拒否することが推奨されますが、必須ではありません。[注釈 3.6]
3. 注釈 #
- ここで「プラットフォームコード」とは、エンジン、環境、および promise 実装コードを指します。実際には、この要件により、
onFulfilledとonRejectedは、thenが呼び出されたイベントループのターンの後、新しいスタックで非同期に実行されることが保証されます。これは、setTimeoutやsetImmediateなどの「マクロタスク」メカニズム、またはMutationObserverやprocess.nextTickなどの「マイクロタスク」メカニズムで実装できます。promise 実装はプラットフォームコードと見なされるため、ハンドラをスケジュールするためのタスクキューまたは「トランポリン」を含む場合があります。
queueMicrotask 自体がトランポリンの役割を果たしています。
- つまり、strict モードでは、ハンドラ内で
thisはundefinedになります。sloppy モードでは、グローバルオブジェクトになります。
"use strict" を書かない場合のデフォルトの動作モードで、エラー検出が緩く、this の挙動なども異なります。
- 実装は、すべての要件を満たす限り、
promise2 === promise1を許可することができます。各実装は、promise2 === promise1を生成できる条件があるかどうか、またその条件を文書化する必要があります。
promise2 === promise1 とは: 通常 then は新しい Promise を返しますが、最適化として同じ Promise を返すことも許可されています。ただし現実にはほとんどの実装(ネイティブ Promise 含む)は常に新しい Promise を返します。これはオプションの許可であり、必須要件ではありません。
- 一般的に、
xが現在の実装からの真の promise であるかどうかは、実装固有の手段を使用して知ることができます。この条項により、実装固有の手段を使用して、準拠する promise の状態を採用することができます。
then メソッド経由でしか処理できませんが、自分の実装が作った Promise なら instanceof で判別して内部状態に直接アクセスする最適化が許可されています。これにより効率的な処理が可能になります。
-
この手順では、まず
x.thenへの参照を格納し、その参照をテストしてから呼び出すことで、x.thenプロパティへの複数のアクセスを回避します。このような予防措置は、アクセス間で値が変化する可能性があるアクセサプロパティに対して一貫性を確保するために重要です。 -
実装は、thenable チェーンの深さに任意の制限を設けるべきではなく、その制限を超えると再帰は無限であると想定します。真の循環のみが
TypeErrorにつながるべきであり、異なる thenable の無限チェーンに遭遇した場合、永遠に再帰することが正しい動作です。
resolve(p) で自分自身を参照する「真の循環」は検出可能なので TypeError で拒否すべきです。(2) 毎回新しい thenable を返す無限チェーンは、異なるオブジェクトなので循環とは言えず、深さ制限を設けるべきではありません。1000回リトライするような正当なユースケースが動かなくなるためです。