JavaScriptで作成するProviderPattern解剖図

今回の記事ではJavaScriptで作成するRiverpodのようなProviderPatternについて解剖していく。
今回見ていくコードはJavaScriptProviderPatternSnippetにもあるが、これを使用していく。

class ProviderContainer {
    constructor() {
        if(!ProviderContainer.instance){
            ProviderContainer.instance = this;
        }
        
        this._providers = new Map();
        this._dependencies = new Map();
        this._listeners = new Map();
 
        return ProviderContainer.instance;
    }
 
    createProvider(createFn) {
        const provider = new ProviderCore(
            createFn,
            null,
            false
        );
 
        return provider;
    }
 
    read(provider) {
        if (!provider._isInitialized) {
            const ref = this._createRef(provider);
 
            provider._value = provider._create(ref);
            provider._isInitialized = true;
        }
 
        return provider._value;
    }
 
    watch(provider, listener) {
        if (!this._listeners.has(provider)) {
            this._listeners.set(provider, new Set());
        }
 
        this._listeners.get(provider).add(listener);
 
        listener(this.read(provider));
 
        return () => {
            this._listeners.get(provider).delete(listener);
        };
    }
 
    update(provider, updateFn) {
        const currentValue = this.read(provider);
        const newValue = updateFn(currentValue);
 
        provider._value = newValue;
        provider._isInitialized = true;
 
        this._notifyListeners(provider, newValue);
    }
 
    _notifyListeners(provider, newValue) {
        const listeners = this._listeners.get(provider);
        if (listeners) {
            listeners.forEach(listener => listener(newValue));
        }
    }
 
	_createRef(currentProvider) {
	    const ref = {
	        watch: (otherProvider) => {
	            if (!this._dependencies.has(currentProvider)) {
	                this._dependencies.set(currentProvider, new Set());
	            }
				this._dependencies.get(currentProvider).add(otherProvider);
	            
	            return this.read(otherProvider);
	        },
	        update: (updateFn) => {
	            this.update(currentProvider, updateFn);
	        }
	    };
	    return ref;
	}
}
 
class ProviderCore {
    constructor(createFn) {
        this._create = createFn;
        this._value = null;
        this._isInitialized = false;
    }
}
 
// コンテナの作成
const PROVIDER_COTAINER = new ProviderContainer();

このProviderについて深く理解していく。

コンストラクタ

このProviderContainerクラスでは以下のようなコンストラクタの実装がなされている。

    constructor() {
        if(!ProviderContainer.instance){
            ProviderContainer.instance = this;
        }
        
        this._providers = new Map();
        this._dependencies = new Map();
        this._listeners = new Map();
 
        return ProviderContainer.instance;
    }

上から3行と一番下のコードはシングルトンを実装しているものである。

実行中央では三つのインスタンス変数に対してMap型で初期化している。
providersは作成されたProviderを格納する変数。
dependenciesは依存関係にあるProvider群を保持しておく変数。
listenerswatchを実行してリッスン状態になったProviderを格納している。

createProvider

次のメソッド、createProviderは以下のような実装。

    createProvider(createFn) {
        const provider = new ProviderCore(
            createFn,
            null,
            false
        );
 
        return provider;
    }

ここでは単に新たにPoviderを作成している。
ここでProviderに相当するモデルクラスとしてProviderCoreを生成している。

ProviderCore

ProviderCoreは以下のような実装

class ProviderCore {
    constructor(createFn) {
        this._create = createFn;
        this._value = null;
        this._isInitialized = false;
    }
}

_createには関数オブジェクトを入れる。
ここでこのオブジェクトを初期化する際に以下のような書き方をする。

const counterProvider = container.createProvider((ref) => {   
	return 0;
});

これは数値に関わるproviderを実装したもの。
引数に(ref) => {}として関数を代入している。

valueにはまだ値を入れない。
isInitializedはproviderがXXXメソッドによって初期化された際に行われる。

read

値の読み取りのみを担当するreadメソッドはこれ。

    read(provider) {
        if (!provider._isInitialized) {
            const ref = this._createRef(provider);
 
            provider._value = provider._create(ref);
            provider._isInitialized = true;
        }
 
        return provider._value;
    }

readでは渡されたproviderの値を読み取っているが、ここで初期化されていない(isInitializedfalse)の場合、_createRefメソッドを呼び出している。refの動作はcreateRefの項を参照。

ProviderCoreクラスのcreate変数に格納されている関数オブジェクトを使用して初期化している。
上の例で言うと(ref) => { return 0 }なら0が返されるのでProviderの_valueプロパティには0が代入されることとなる。

createRef

readメソッドにて初期化されていないproviderが渡された場合に呼び出さるメソッド。

_createRef(currentProvider) {
	const ref = {
		watch: (otherProvider) => {
			if (!this._dependencies.has(currentProvider)) {
				this._dependencies.set(currentProvider, new Set());
			}
			this._dependencies.get(currentProvider).add(otherProvider);
			
			return this.read(otherProvider);
		},
		update: (updateFn) => {
			this.update(currentProvider, updateFn);
		}
	};
	return ref;
}

このcreateRefメソッドでは、refオブジェクトを作成している。
このrefオブジェクトは最終的に以下のコードの作成時のrefである。

const counterProvider = container.createProvider((ref) => {   
	return 0;
});

なぜこれが存在しているのかというと、Providerの値の中にProviderを適用する際に使用される。

// ユーザープロバイダーの作成
const userProvider = container._createProvider((ref) => {
	return { name: '太郎', age: 30 };
});
 
// 派生プロバイダーの例
const userNameProvider = container._createProvider((ref) => {
    const user = ref.watch(userProvider);
    return user.name;
 });

このコード例ではユーザ情報を管理しているuserProviderの中に存在しているnameプロパティの変更も検知したいときに使用される。
ここでupdateメソッドを実行することもできる。
この際、userNameProvideruserProviderに依存しているということができる。userProviderの値がupdateメソッドによって変更されたとき、このuserNameProviderの値も変更しなければならないためである。

この依存関係を記録するコードが、

if (!this._dependencies.has(currentProvider)) {
	this._dependencies.set(currentProvider, new Set());
}
			this._dependencies.get(currentProvider).add(otherProvider);

にて、実装されている。
ここでdependencies変数にProviderと紐図けられたオブジェクトが存在しないとき、dependenciesにProviderをキーとしたMapを作成する。バリューとしてはSetを生成、代入している。
そして、dependenciesに存在するキーがProviderのSetに対して依存関係にあるProviderを記録している。

watch

値の変更を検知するリスナーを登録するメソッド群へのエントリポイント。

    watch(provider, listener) {
        if (!this._listeners.has(provider)) {
            this._listeners.set(provider, new Set());
        }
 
        this._listeners.get(provider).add(listener);
 
        listener(this.read(provider));
 
        return () => {
            this._listeners.get(provider).delete(listener);
        };
    }

watchメソッドは二つの引数をとる。まずリスナーを登録したいProvider本体。そして、値の変更が検知された際に実行されるlistener引数である。
listenerメソッドのコード例

container.watch(counterProvider, (value) => { console.log('値の変更を検知:', value); });

まず、watchメソッドではクラスメンバのlistener変数が渡されたProviderを保持しているかを確認する。

if(!this._listeners.has(provider)) {

Map型におけるhasメソッドは渡された値がmapに存在しているかを確認するもの。
if節の内部ではlistenerMapにsetメソッドを使用して代入している。
Map型におけるsetメソッドの使用方法はset(key, value)である。
ここではkeyとしてProvider本体を渡し、valueとしてSet型を渡している。
Set型とは重複を許さないコレクション型である。

そして次の行、

this._listeners.get(provider).add(listener);

ここではlistenerMapに保持されている値、キーはproviderで取得してから、バリューであるSetに引数で渡されたlistenerを登録している。
ここで渡されたProviderが実際にリッスン状態となる。
リッスン状態になったProviderはupdateメソッドによって値が変更されたとき、updateFnで渡された関数オブジェクトが実行される。

listener(this.read(provider));

そしてそのlistener関数オブジェクトに対してProviderの値をreadした結果を渡している。

具体的に言うと、

container.watch(counterProvider, (value) => { console.log('値の変更を検知:', value); });

このコードのvalue部分にreadした値が代入される。

そして、watchメソッドで返されている値、

return () => {
	this._listeners.get(provider).delete(listener);
};

ではlistenerの解除を行うことのできる関数オブジェクトを返している。

これを使用すると、

let unsubscribed = container.watch(counterProvider, (value) => { console.log('値の変更を検知:', value); });
 
unsubscribed();

と記述することでリスナーの解除を行うことができる。

update

Providerの値を変更し、それをlistenerに通知するメソッドを持つ。

    update(provider, updateFn) {
        const currentValue = this.read(provider);
        const newValue = updateFn(currentValue);
 
        provider._value = newValue;
        provider._isInitialized = true;
 
        this._notifyListeners(provider, newValue);
    }

まずこのupdate関数ではProviderと値の更新を行うupdateFnを引数にもつ。
この関数の最初ではcurrentValueに現在の値をreadメソッドで代入する。
そしてnewValueに引数で渡されたupdateFn関数オブジェクトに現在の値を渡している。

このupdateFn

container.update(counterProvider, (currentValue) => currentValue + 1);

のように現在の値を参照して、新しい値を作成する際に使用される。

そのようにして更新された値はProvider本体に代入される。

そして、最後にnotifyListenerメソッドでリスナーに実際に通知される。

notifyListener

updateメソッドで変更されたProviderに対してイベントを発火する。

    _notifyListeners(provider, newValue) {
        const listeners = this._listeners.get(provider);
        if (listeners) {
            listeners.forEach(listener => listener(newValue));
        }
    }

実際にupdateされたProviderはこのNotifyListenerによって値の変更が通知される。

まず、渡されたProviderのlistenerを取得する。
ここでProviderがリッスン状態の場合はリスナー関数オブジェクトが返されるが、該当Providerがリッスン状態でない場合は、undefinedが返される。

そしてProviderがリッスン状態の時、if節の内部のコードが実行される。

listeners.forEach(listener => listener(newValue));

なぜforEachしているのかというと複数個所でProviderがwatchされその都度listenerが登録された場合に備えているものである。
ここでupdateFnによって渡されたlistenerが実行される。

所感

ここでこのProviderConatainerクラスは大域的な変数PROVIDER_CONTAINERを使用しなければProviderの作成ができない。
そして作成されたすべてのProviderはこのProviderContainerクラスに格納される。ここで関係ないProvider同士が存在するのはいかがなものか。//FIXME
解消した新しいProviderクラス↓

SeparatedProvider

ここに書いてあるdependencieslistenersはMapとSetではなく、クラスとして作成したほうがいいかも
それで通知を担当するNotifierクラスも作成したほうがいいかも

  • Dependenciesクラス ✅ 2025-05-03
    • Provider同士の依存関係管理を担当
  • Listenerクラス ✅ 2025-05-03
    • Providerが保持するlistenersの管理を担当
  • Notifierクラス ✅ 2025-05-03
    • Providerがupdateされて実際に通知するクラス
  • ProviderObserverクラス ✅ 2025-05-03
    • Providerがいつどこでupdateされたかなどを監視するクラス
      これらのクラスを盛り込んだらいい感じになるかも
class Provider {
    constructor(createFn) {
        this._provider = undefined;
        this._dependencies = new Map();
        this._listeners = new Set();
        this._create = createFn;
        this._value = null;
        this._isInitialized = false;
        this._id = Symbol('provider');
    }
 
    static createProvider(createFn) {
        return new Provider(createFn);
    }
 
    read() {
        if (!this._isInitialized) {
            const ref = this._createRef();
            this._value = this._create(ref);
            this._isInitialized = true;
        }
        
        return this._value;
    }
 
    watch(listener) {
        this._listeners.add(listener);
        listener(this.read());
 
        return () => {
            this._listeners.delete(listener);
        };
    }
 
    update(updateFn) {
        const currentValue = this.read();
        const newValue = updateFn(currentValue);
 
        this._value = newValue;
        this._notifyListeners(newValue);
    }
 
    _notifyListeners(newValue) {
        this._listeners.forEach(listener => listener(newValue));
    }
 
    _createRef() {
        const ref = {
            watch: (otherProvider) => {
                // 依存関係を追跡
                if (!this._dependencies.has(otherProvider._id)) {
                    this._dependencies.set(otherProvider._id, otherProvider);
                    otherProvider.watch((newValue) => {
                        // 依存するプロバイダーの値が変更された時に再計算
                        this._value = this._create(ref);
                        this._notifyListeners(this._value);
                    });
                }
                return otherProvider.read();
            },
            update: (updateFn) => {
                this.update(updateFn);
            }
        };
 
        return ref;
    }
}