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

Promiseを実装してみた

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

はじめに
#

前回は Promise/A+ 仕様を読み解きました。今回はその仕様に基づき、実際に JavaScript で Promise を実装してみます。

仕様を忠実に再現することで、普段何気なく使っている Promise の内部でどのような処理が行われているのか、特に「なぜ非同期なのか」「なぜチェーンできるのか」といった疑問を解消することを目指します。

以下に今回の実装コードがあります。 kiitosu/my-promise: 勉強用の promiseの実装

構造の全体像
#

実装の全体的な構造と、各コンポーネントの相互作用を図解すると以下のようになります。

MyPromise 構造図

主要な構成要素
#

  1. State Management (状態管理): state (pending, fulfilled, rejected) と、その結果である value または reason を保持します。
  2. Callback Arrays (コールバックキュー): Promise が pending 状態で then が呼ばれた際、実行すべき関数を保存しておく配列です。
  3. constructor & Executor: ユーザーが渡した executor を実行します。内部で定義された resolve / reject 関数は、状態を変更し、保存されていたコールバックを一斉に実行する役割を持ちます。
  4. then メソッド: 常に新しい Promise (promise2) を生成して返します。これがメソッドチェーンを可能にする鍵です。内部では、現在の Promise の状態に応じて、即座にタスクを実行するか、キューに保存します。
  5. resolvePromise (解決手続き): then の戻り値 x を解析し、それが Promise ならその解決を待ち、値なら promise2 を確定させます。thenable に対応するための再帰的な構造を持っています。

実装のポイント
#

1. 状態の管理
#

Promise は pending, fulfilled, rejected のいずれかの状態を持ち、一度遷移するとそれ以上変わることはありません。resolvereject の中で、現在の状態が pending であることを必ずチェックしています。

2. 非同期実行の強制
#

仕様 2.2.4 では、onFulfilledonRejected が実行されるのは「プラットフォームコードのみが含まれるようになるまで」と定められています。これを実現するために、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 や thenablethen メソッドを持つオブジェクト)だった場合、その結果が確定するまで再帰的に解決を繰り返します。

また、called フラグを用いることで、悪意のある thenableresolvereject を両方呼び出したり、複数回呼び出したりしても、最初の一回だけが有効になるように制御しています。

実装したコード
#

完成した 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.allPromise.race といったスタティックメソッドの実装にも挑戦してみたいと思います。

Reply by Email

関連記事

Promiseの仕様
· loading · loading
プロトタイプベース言語
· loading · loading
Redis使ってみた
· loading · loading