はじめに #
前回は Promise/A+ 仕様を読み解きました。今回はその仕様に基づき、実際に JavaScript で Promise を実装してみます。
仕様を忠実に再現することで、普段何気なく使っている Promise の内部でどのような処理が行われているのか、特に「なぜ非同期なのか」「なぜチェーンできるのか」といった疑問を解消することを目指します。
以下に今回の実装コードがあります。 kiitosu/my-promise: 勉強用の promiseの実装
構造の全体像 #
実装の全体的な構造と、各コンポーネントの相互作用を図解すると以下のようになります。
主要な構成要素 #
- State Management (状態管理):
state(pending,fulfilled,rejected) と、その結果であるvalueまたはreasonを保持します。 - Callback Arrays (コールバックキュー):
Promise が
pending状態でthenが呼ばれた際、実行すべき関数を保存しておく配列です。 - constructor & Executor:
ユーザーが渡した
executorを実行します。内部で定義されたresolve/reject関数は、状態を変更し、保存されていたコールバックを一斉に実行する役割を持ちます。 - then メソッド:
常に新しい Promise (
promise2) を生成して返します。これがメソッドチェーンを可能にする鍵です。内部では、現在の Promise の状態に応じて、即座にタスクを実行するか、キューに保存します。 - resolvePromise (解決手続き):
thenの戻り値xを解析し、それが Promise ならその解決を待ち、値ならpromise2を確定させます。thenableに対応するための再帰的な構造を持っています。
実装のポイント #
1. 状態の管理 #
Promise は pending, fulfilled, rejected のいずれかの状態を持ち、一度遷移するとそれ以上変わることはありません。resolve や reject の中で、現在の状態が pending であることを必ずチェックしています。
2. 非同期実行の強制 #
仕様 2.2.4 では、onFulfilled や onRejected が実行されるのは「プラットフォームコードのみが含まれるようになるまで」と定められています。これを実現するために、queueMicrotask を使用してコールバックの実行をマイクロタスクとして登録しています。
これにより、以下のコードが期待通り 1 -> 3 -> 2 の順で出力されます。
console.log(1);
new MyPromise(resolve => resolve()).then(() => console.log(2));
console.log(3);
3. Promise Resolution Procedure #
もっとも複雑な部分が resolvePromise 関数です。
then の戻り値 x が別の Promise や thenable(then メソッドを持つオブジェクト)だった場合、その結果が確定するまで再帰的に解決を繰り返します。
また、called フラグを用いることで、悪意のある thenable が resolve と reject を両方呼び出したり、複数回呼び出したりしても、最初の一回だけが有効になるように制御しています。
実装したコード #
完成した MyPromise クラスのコードです。
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
constructor(executor) {
this.state = PENDING;
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== PENDING) return;
this.state = FULFILLED;
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
};
try {
executor(resolve, reject);
} catch(e){
reject(e)
}
}
then(onFulfilled, onRejected) {
// 仕様 2.2.1: 引数が関数でなければ無視(値を素通しさせる)
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
onRejected = typeof onRejected === 'function' ? onRejected : (r) => {throw r;};
// 仕様 2.2.7: thenは新しい promiseを返す
const promise2 = new MyPromise((resolve, reject) => {
const fulfilledTask = () => {
// 仕様 2.2.4: コールバックは非同期で実行
queueMicrotask(() => {
try {
const x = onFulfilled(this.value);
// 仕様 2.2.7.1: 戻り値 x で Promise Resolution Procedureを実行
resolvePromise(promise2, x, resolve, reject);
} catch (e) {
// 仕様 2.2.7.2: 例外が出たら reject
reject(e)
}
})
}
const rejectedTask = () => {
queueMicrotask(()=>{
try {
const x = onRejected(this.reason);
resolvePromise(promise2, x, resolve, reject);
} catch(e) {
reject(e)
}
})
};
if (this.state === FULFILLED) {
fulfilledTask();
} else if (this.state === REJECTED) {
rejectedTask();
} else {
// pending の場合はコールバック配列に追加して後で実行
this.onFulfilledCallbacks.push(fulfilledTask);
this.onRejectedCallbacks.push(rejectedTask);
}
})
return promise2;
}
}
function resolvePromise(promise2, x, resolve, reject) {
// 仕様 2.3.1: promiseとxが同じオブジェクトならTypeError
if (promise2 === x){
return reject(new TypeError('Chaining cycle detected for promise.'))
}
// 仕様 2.3.2 & 2.3.3: xがobjectかfunctionの場合
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let called = false;
try {
// 仕様 2.3.3.1: thenを取得(getterで例外が出る可能性がある)
const then = x.then;
if (typeof then === 'function') {
// 仕様 2.3.3.3: thenをxをthisとして呼び出す
then.call(
x,
(y) => {
if (called) return;
called = true;
// 仕様 2.3.3.3.1: 再帰的に解決
resolvePromise(promise2, y, resolve, reject);
},
(r) => {
if (called) return;
called = true;
reject(r);
}
);
} else {
// 仕様 2.3.3.4: thenが関数でなければ x でfulfill
resolve(x);
}
} catch(e) {
// 仕様 2.3.3.2 & 2.3.3.3.4: 例外処理
if (called) return;
called = true;
reject(e);
}
} else {
// 仕様 2.3.4: x がオブジェクトでも関数でもなければ x でfulfill
resolve(x);
}
}
仕様準拠の確認 #
実装が正しいことを確認するために、公式のテストスイートである promises-aplus-tests を実行します。
テストの準備 #
以下の adapter.js を用意します。
const MyPromise = require('./src/promise')
module.exports = {
deferred() {
let resolve, reject;
const promise = new MyPromise((res, rej) => {
resolve = res;
reject = rej
});
return {promise, resolve, reject};
},
resolved(value){
return new MyPromise((resolve)=>resolve(value));
},
rejected(reason) {
return new MyPromise((_, reject) => reject(reason));
},
};
テストの実行 #
npx promises-aplus-tests adapter.js
872個のテストケースがすべてパスすれば、Promises/A+ 仕様に準拠していると言えます!
おわりに #
Promise を自作してみることで、状態遷移、非同期スケジューリング、転が複雑な解決手順について深く理解することができました。特に then が常に新しい Promise を返し、その解決を resolvePromise という独立した手続きで行う設計は非常に美しく、JavaScript の非同期処理の基盤となっている理由がよく分かりました。
次は Promise.all や Promise.race といったスタティックメソッドの実装にも挑戦してみたいと思います。