🤩

piniaを理解して作る(2)

に公開

piniaを模倣

https://zenn.dev/hanpenman/articles/ea3867374cffb5 につづき 第二段

piniaを模倣したsmall-piniaを作ります.英語でドキュメント作ったものをgeminiで日本語にしたのでおかしな文章があるかもです.注意してね.

目次

  • コンポーネント以外でstore参照
  • options パターンの適応

Piniaインスタンスへのグローバルアクセス

これまでのセクションでは、createPiniaがアプリケーションごとに一意のPiniaインスタンスを作成する方法と、このインスタンスがVueのprovide/injectメカニズムを介して提供され、コンポーネントのsetup関数および関連するライフサイクルフック内でアクセス可能になることを見てきました。

しかし、Vueのprovide/injectメカニズムは、コンポーネントツリー構造とVueのレンダリングまたはコンポーネントセットアッププロセスによって提供される実行コンテキストに依存しています。これは、以下のような、このコンポーネントベースのフローの外部で実行されるコンテキストでは利用できないことを意味します。

  • グローバルルーターミドルウェア(Nuxt 3のdefineNuxtRouteMiddlewareなど (ssrの時) )
  • コンポーネントの以外で利用する

ルーターミドルウェアにおける課題

Nuxt 3のグローバルルーターミドルウェアがストアにアクセスする必要があるケースを考えてみましょう。

// playground/nuxt/middleware/route-logger.global.ts
import { useCounterSmallStore } from "../stores/small-pinia-counter";

export default defineNuxtRouteMiddleware(async (to, from) => {
  const counterSmallStore = useCounterSmallStore(); // This line will cause an error
  console.log("Counter value in middleware:", counterSmallStore.count);
});

この例では、ミドルウェア内でuseCounterSmallStore()を呼び出すと、以前追加した「Pinia not installed」エラーが発生します。これは、コンポーネントツリー外でのミドルウェアの実行中にはアクティブなインジェクションコンテキストが利用できないため、useStore関数内のinject(piniaSymbol, null)がnullを返すからです。ただし、ここで注意すべき点は、SPAでエラーが起こることはなくSSRの時に起こる問題です。この詳細なロジックは、リンク( https://router.vuejs.org/guide/advanced/navigation-guards.html#Global-injections-within-guards ) を参照してください。

injectが利用できないこれらのコンテキストでもPiniaがシームレスに動作できるようにするには、useStore関数が正しいPiniaインスタンスを見つける方法が必要です。Piniaが使用する解決策は、フォールバックとして機能する、グローバルにアクセス可能な「アクティブな」Piniaインスタンスを維持することです。

activePiniaの導入

Piniaは、通常activePiniaという名前のグローバルな可変変数として、現在「アクティブ」と見なされるべきPiniaインスタンスへの参照を保持します。setActivePiniaという関数は、このグローバル変数を設定するために使用されます。

setActivePiniaが呼び出される主な場所は、PiniaがVueプラグインとしてインストールされる際(app.use(pinia))です。ここでactivePiniaを設定することにより、プラグインがインストールされアプリケーションが起動した直後から、Piniaインスタンスがグローバルに到達可能であることを保証します。

small-piniaにactivePiniaとsetActivePiniaを実装しましょう。

activePiniaおよびsetActivePiniaの実装

rootStore.tsにactivePinia変数とsetActivePinia関数を宣言する必要があります。

// rootStore.ts
export let activePinia: Pinia | undefined;

//@ts-expect-error
export const setActivePinia: _SetActivePinia = (pinia) => (activePinia = pinia);

interface _SetActivePinia {
  (pinia: Pinia): Pinia;
  (pinia: undefined): undefined;
  (pinia: Pinia | undefined): Pinia | undefined;
}

次に、createPiniaのinstallメソッドでsetActivePiniaを呼び出す必要があります。また、useStore関数もactivePiniaをフォールバックとして使用するように変更します。

//store.ts
  function useStore(): Store<
    Id,
    _ExtractStateFromSetupStore<SS>,
    _ExtractGettersFromSetupStore<SS>,
    _ExtractActionsFromSetupStore<SS>
  > {
    const hasContext = hasInjectionContext();
    let pinia = hasContext ? inject(piniaSymbol, null) : null;
    // insert into activePinia
    if (pinia) {
      setActivePinia(pinia);
    }
    if (!activePinia) {
      throw new Error(
        `[🍍 pinia] Cannot get current Pinia instance. Did you forget to call "app.use(pinia)"?`
      );
    }
    pinia = activePinia!;

// createPinia.ts
  const pinia: Pinia = {
    install(app: App) {
      setActivePinia(pinia);

動作

これらの変更が適用されると、Nuxtアプリケーションが起動する際にsmall-piniaインスタンスが作成され、そのinstallメソッド(プラグイン内)内でsetActivePiniaが呼び出されます。その後、ルーターミドルウェアがuseCounterSmallStore()を呼び出すと、useStore関数内のinjectはnullを返しますが、activePiniaには正しく設定されたPiniaインスタンスが保持されているため、フォールバックとしてそれが使用され、ミドルウェアがストアインスタンスを正常に取得して操作できるようになります。

参照: 私が書いたコードは以下のPRリンクで確認できます。
https://github.com/KOBATATU/small-pinia/pull/4

注意: SSRとactivePinia

activePiniaを導入することで、コンポーネント外でPiniaインスタンスにアクセスできる問題は解決しますが、SSR環境において重大な問題を引き起こす可能性があります。それは、Cross-Request State Pollution(リクエスト間状態汚染)です(https://github.com/vuejs/pinia/discussions/2077)。

サーバー上では、複数のユーザーリクエストが同じNode.jsプロセスによって同時に処理される可能性があります。activePiniaは、進行中の全てのリクエスト間で共有される単一の、可変なグローバル変数であるため、あるユーザーのリクエスト処理中にsetActivePiniaが呼び出されて、そのリクエスト固有のPiniaインスタンスへのグローバル参照が変更される間に、別のリクエストが前のインスタンスにアクセスしている途中である可能性があります。これにより、サーバーレンダリングプロセス中に、異なるユーザーによってデータが漏洩したり、間違った状態がアクセスまたは変更されたりする可能性があります。asyncには気をつけて

Options APIスタイルの実装

defineStoreのComposition APIスタイルとPiniaインスタンスへのグローバルアクセス(activePinia)の実装に基づいて、次にOptions APIパターンのサポートを追加します。Piniaは、異なる好みや既存のコードベースに対応するために両方のスタイルを提供しており、開発者は最も使い慣れている、またはプロジェクトに最適なスタイルを選択できます。

ストア作成ロジック(createSetupStore)を重複させずにOptions APIスタイルを実装するコア戦略は、Options API定義(stategettersactionsオブジェクト)を内部的に同等のSetup API関数に変換することです。この動的に生成されたセットアップ関数は、既に構築したcreateSetupStore関数によって処理できます。

概念的に、Options APIを使用して定義されたストアのフローは次のようになります。
defineStore() → createOptionsStore() → 同等のセットアップ関数を生成 → createSetupStore() → ストアインスタンスを返す

Options APIの使用例

実装に入る前に、公式ドキュメントに示されている、馴染みのあるOptions APIパターンでのストア定義を見てみましょう。

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => {
    return { count: 0 }
  },
  getters: {
    doubleCount(): number {
      return this.count * 2;
    },
  },
  actions: {
    increment() {
      this.count++
    },
  },
})

Options APIスタイルの実装

defineStoreのComposition APIスタイルとPiniaインスタンスへのグローバルアクセス(activePinia)の実装に基づいて、次にOptions APIパターンのサポートを追加します。Piniaは、異なる好みや既存のコードベースに対応するために両方のスタイルを提供しており、開発者は最も使い慣れている、またはプロジェクトに最適なスタイルを選択できます。

ストア作成ロジック(createSetupStore)を重複させずにOptions APIスタイルを実装するコア戦略は、Options API定義(stategettersactionsオブジェクト)を内部的に同等のSetup API関数に変換することです。この動的に生成されたセットアップ関数は、既に構築したcreateSetupStore関数によって処理できます。

概念的に、Options APIを使用して定義されたストアのフローは次のようになります。
defineStore() → createOptionsStore() → 同等のセットアップ関数を生成 → createSetupStore() → ストアインスタンスを返す

Options APIの使用例

実装に入る前に、公式ドキュメントに示されている、馴染みのあるOptions APIパターンでのストア定義を見てみましょう。

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => {
    return { count: 0 }
  },
  getters: {
    doubleCount(): number {
      return this.count * 2;
    },
  },
  actions: {
    increment() {
      this.count++
    },
  },
})

この構造は、コンポーネントのVue Options APIに似ており、状態(ファクトリ関数で定義)、ゲッター(計算プロパティ)、アクション(メソッド)の明確なセクションがあります。ここでの重要な特徴は、ゲッターとアクション内のthisがストアインスタンス自体を参照し、状態(this.count)や他のゲッターまたはアクション(this.someOtherGetter)にアクセスできるという期待です。私たちの実装は、このthisコンテキストが正しくバインドされることを保証する必要があります。(アクションの適切なハンドリングは未実装).

Options APIロジックの実装

セットアップスタイルとOptions APIスタイルの両方をサポートするために、defineStore関数は2番目の引数としてセットアップ関数またはオプションオブジェクトのいずれかを受け入れる必要があります。TypeScriptでは、これは関数オーバーロードを使用して実現されます。

defineStoreのオーバーロード

次に、defineStore関数のシグネチャを更新し、オーバーロードされたシグネチャがセットアップ関数またはオプションオブジェクトのいずれかを受け入れるようにします。実装シグネチャは、どちらかになりうるパラメータを受け入れる必要があります。

// store.ts
// options api  type
export function defineStore<
  Id extends string,
  S extends StateTree = {},
  G extends _GettersTree<S> = {},
  A = {}
>(
  id: Id,
  options: Omit<DefineStoreOptions<Id, S, G, A>, "id">
): StoreDefinition<Id, S, G, A>;
// setup function
export function defineStore<Id extends string, SS extends Record<any, unknown>>(
  id: any,
  setup: () => SS
): StoreDefinition<
  string,
  _ExtractStateFromSetupStore<SS>,
  _ExtractGettersFromSetupStore<SS>,
  _ExtractActionsFromSetupStore<SS>
>;
export function defineStore(id: any, setup?: any): StoreDefinition {}

createOptionsStoreの実装

createOptionsStore関数を追加します。この関数はセットアップ関数を生成し、状態、アクション、そして特にゲッターのthisバインディング(.call(store, store)を使用)を処理します。

useStore関数内のロジックを修正して、isSetupStoreフラグに基づいてcreateSetupStoreまたはcreateOptionsStoreを呼び出すようにします。

// store.ts
    if (!pinia._s.has(id)) {
      if (isSetupStore) {
        createSetupStore(id, setup, pinia);
      } else {
        createOptionsStore(id, setup, pinia);
      }
    }
// store.ts
function createOptionsStore<
  Id extends string,
  S extends StateTree,
  G extends _GettersTree<S>,
  A extends _ActionsTree
>(
  id: Id,
  options: DefineStoreOptions<Id, S, G, A>,
  pinia: Pinia
): Store<Id, S, G, A> {
  const { state, actions, getters } = options;

  let store: Store<Id, S, G, A>;

  function setup() {
    const localState = state ? state() : {};

    return Object.assign(
      localState,
      actions,
      Object.keys(getters || {}).reduce((computedGetters, name) => {
        if (name in localState) {
          console.warn(
            `[🍍]: A getter cannot have the same name as another state property. Rename one of them. Found with "${name}" in store "${id}".`
          );
        }

        computedGetters[name] = markRaw(
          computed(() => {
            setActivePinia(pinia);
            const store = pinia._s.get(id)!;
            // @ts-expect-error
            return getters![name].call(store, store);
          })
        );
        return computedGetters;
      }, {} as Record<string, ComputedRef>)
    );
  }

  store = createSetupStore(id, setup, pinia, true);

  return store;
}

以前のスクリーンショットと同様の画面が表示されるはずですが、Options APIストアによって駆動されます。

export const useCounterOptionsSmallStore = defineStore("options-counter", {
  state: () => {
    return {
      count: 0,
    };
  },
  getters: {
    doubleCount(): number {
      return this.count * 2;
    },
  },
  actions: {
    increment() {
      this.count++;
    },
  },
});

参照: 私が書いたコードは以下のPRリンクで確認できます。
https://github.com/KOBATATU/small-pinia/pull/5

終わり

piniaの模倣を実装してきました.基本的なグローバルの機能は既にほぼほぼ書きました.後,細かいhydrationなどはありますが,piniaの骨格は実装しました.残すは ユーティリティの関数やpluginの処理がありますが,そこまで難しくありません.それらの関数はこれまで実装してきた型に追加で書いていくだけでほぼほぼ終わります.

これ以上記事は書かないと思います!

Discussion