🤔

piniaを理解して作る(1)

に公開

piniaを模倣

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

https://why-oss-work.netlify.app/

なぜ作ったか

英語を勉強したかった.英語を書く題材が何かないかなと思った.https://github.com/chibivue-land/chibivue を見てossを模倣するのは良い題材だなと思った.このような取り組みを英語の文章で書くことで英語が勉強できると思った.ではどのoss?と検討していたところ,piniaを業務でも使うので選んだ.英語の文章は別のドメインで公開する予定です.

英語が難しすぎて浅はかすぎた(´Д`). geminiと対話しながら文章修正やなんやかんやをやっています.

英語で作っていったものの,日本語で書き起こすことでvue界隈に少しでも貢献できるかなと思ったので書き起こしました.どうぞ.

目次

  • piniaについて
  • ローカル環境作成
  • plugin作成
  • store作成
  • 型を付与

Piniaとは?

Piniaは、Vueアプリケーション向けに推奨される状態管理ライブラリです。モダンなVueの機能(Composition APIやTypeScriptなど)を活用した、よりシンプルで直感的、かつ堅牢なAPIを提供します。

reactive({})exportのような基本的なComposition APIの機能を使用して簡単なグローバル状態を管理することも可能ですが、このアプローチはより大きなアプリケーションではすぐに保守が難しくなります。開発効率やアプリケーションの堅牢性に不可欠な機能(devtools連携、ホットモジュールリプレイスメント、テストサポート、特に強力な型推論、およびサーバーサイドレンダリング(SSR)互換性など)が不足しており、これらはハイドレーションの不一致やセキュリティの脆弱性にアプリケーションを晒す可能性があります。

Piniaは、これらの課題に対処するために、コンポーネントやページを跨いだ共有状態を効果的に管理するための、構造化され機能豊富なソリューションを提供します。

公式ドキュメントでは、その利点が美しくまとめられています。

  • Testing utilities
  • Plugins: extend Pinia features with plugins
  • Proper TypeScript support or autocompletion for JS users
  • Server Side Rendering support
  • Devtools support
    • A timeline to track actions and mutations
    • Stores appear in components where they are used
    • Time travel and easier debugging
  • Hot module replacement
    • Modify your stores without reloading your page
    • Keep any existing state while developing

このドキュメントの内容

このドキュメントは、Piniaのコアメカニズムを簡易的な実装を通して探求することで、Piniaの理解を深めることを目的としています。Piniaが提供する多くの機能の中でも、重要な側面に関連する原理を具体的に実証することに焦点を当てます。

  1. Piniaのグローバル状態管理メカニズム: Piniaはどのように機能するのか?
  2. 適切なTypeScriptサポート、またはJSユーザー向けのオートコンプリート: Piniaがどのように強力な型推論を可能にし、型チェックとオートコンプリートを通じて優れた開発体験を提供するかを理解する。

注意:
このドキュメントは、HMR(ホットモジュールリプレイスメント)およびdevtoolsのコアメカニズムは提供していません。めんどくさい

対象読者

このドキュメントは、以下のような方々を対象としています。

  • Pinia、Vue.js、またはTypeScriptについてもっと学びたい方。
  • オープンソースライブラリの内部動作に興味がある方。
  • 基本的な例を超えた状態管理パターンを理解したい方。

Piniaはどのように作成されるか

Piniaパッケージのディレクトリ構造

Piniaのソースコードの構造は以下。Piniaコアパッケージのディレクトリ(https://github.com/vuejs/pinia/tree/v3/packages/pinia/srcで利用可能)は、次のように構成されています。

createPinia.ts
env.ts
global.d.ts
globalExtensions.ts
hmr.ts
index.ts
mapHelpers.ts
rootStore.ts
store.ts
storeToRefs.ts
subscriptions.ts
types.ts

ご覧の通り、主要なロジックは主にcreatePinia.tsとstore.tsにあります。より大きなフレームワークのディレクトリ構造と比較すると、Piniaのソースディレクトリは比較的シンプルであり、コア実装ファイルを理解しようとする開発者にとって非常にアクセスしやすいものとなっています。

Piniaのコアメカニズムを理解する

グローバルリアクティブ状態の課題
Vueアプリケーションでグローバル状態を実装する際、最初の、そして一見簡単なアプローチとして、別のファイル内でシンプルでグローバルにエクスポートされたreactive変数を直接使用することが考えられます。

例えば、状態と基本的なミューテーションを次のように定義するかもしれません。

// store.ts
import { reactive } from 'vue';

export const counterState = reactive({
  count: 0
});

export function increment() {
  counterState.count++;
}

export function decrement() {
  counterState.count--;
}

そして、この状態とその関数をコンポーネントにインポートします。

// Component.vue
import { counterState, increment, decrement } from './store';
import { defineComponent } from 'vue';

export default defineComponent({
  setup() {
    return {
      counterState,
      increment,
      decrement
    };
  }
});

この方法でもグローバルな状態共有は実現できます。なぜなら、counterStateをインポートする全てのコンポーネントは、基となる同じリアクティブオブジェクトを参照するからです。しかし、この単純なグローバルエクスポートパターンには、特にサーバーサイドレンダリング(SSR)環境において致命的な欠陥があります。それは「Cross-Request State Pollution(リクエスト間状態汚染)」です。

Cross-Request State Pollutionは、共有シングルトンオブジェクトを使用するSSRアプリケーション特有の重大なセキュリティ脆弱性です。これは、異なるユーザーからの複数の独立したサーバーリクエスト間で、共有状態オブジェクトの同じインスタンスが意図せず再利用される場合に発生します。これにより、あるユーザーのリクエストからの機密データが、別のユーザーに露出または漏洩する可能性があります。なぜなら、彼らはサーバー上で同じ状態インスタンスを読み書きする可能性があるからです。

Provide/Injectの役割

SSRにおけるCross-Request State Pollutionの問題に対処し、サーバー上でユーザーリクエストごとに隔離された方法でVueのリアクティビティシステムを安全に拡張するために、PiniaはVueに組み込まれたProvide/Injectメカニズムを基本的なコアパターンとして利用しています。

VueのProvide/Injectを使用すると、祖先コンポーネント(Piniaの場合は通常、ルートアプリケーションインスタンス)によってデータが提供され、そのコンポーネントツリー内の全ての子孫コンポーネントによってインジェクトされることができます。提供された例は、次のようなVueのProvide/Injectの基本的なデモンストレーションです。

// parent
<script setup>
import { provide, ref } from 'vue'
const message = ref('Hello from parent!')
provide('messageKey', message)
</script>

// child.vue
<script setup>
import { inject } from 'vue'
const injectedMessage = inject('messageKey')
</script>

<template>
  <p>Injected message: {{ injectedMessage }}</p>
</template>

PiniaのcreatePinia関数は、コアとなるPiniaインスタンスを作成する役割を担います。このインスタンスは、アプリケーションインスタンスごとに一意になるように設計されており、これはSSRにとって非常に重要です。app.use(pinia)を使用してこのPiniaインスタンスをVueアプリケーションのルートにインストールすることにより、この一意のインスタンスはVueのProvide/Injectメカニズムを通じてアプリケーションツリー全体に提供されます。これにより、全てのコンポーネントがこの特定のPiniaインスタンスをインジェクトしてアクセスできるようになります。

コンポーネントのsetup関数内でdefineStoreによって定義されたストア関数(useCounterStore()など)を呼び出すと、Piniaは内部的にインジェクトされたPiniaインスタンスを使用して、その特定のアプリケーションツリーに対応する実際のストアインスタンスを見つけるか、または作成します。このパターンにより、SSRにおける単一のユーザーリクエストに関連付けられたコンポーネントツリーにストアインスタンスを効果的に隔離します。この使用パターンはVue 3のComposableに非常によく似ており、PiniaはインジェクトされたPiniaインスタンスを介してComposableライクな関数(use...Store)を通じてストアインスタンスにアクセス可能にしていると概念的に考えることができます。

このフローを簡略化した図は以下の通りです。

Provide/Inject単独の限界(後述)

Provide/InjectはPiniaのコアメカニズムですが、特にVue Routerミドルウェアを含むSSRシナリオなど、全ての場合で正しいコンテキストを維持することは、Provide/Inject単独では難しい場合があります。後のセクションで詳しく説明します。

開発環境のセットアップ

このセクションでは、Piniaの内部を探求し、独自の簡易的な模倣を構築するために必要な開発環境をセットアップします。

開発フレームワークとして Nuxt 3 を使用します。Nuxt 3は、箱から出してすぐにSSR環境を提供するため、選ばれています。これは、標準的なVite + Vue 3のセットアップをSSRのために手動で複雑に構成する必要がなく、PiniaのSSR機能を検証するために不可欠です。そのような手動構成にはかなりの時間がかかります。

プロジェクトディレクトリ構造

公式Piniaソースコード、私たちの模倣、そしてそれを使用するアプリケーションをきれいに分離するために、プロジェクトは以下の構造を持つようにします。

your-project/
├── packages/
│   ├── pinia/         # 公式 Pinia ソースコード (submodule として追加)
│   └── small-pinia/   # 私たちの簡易 Pinia 模倣
└── playground/
└── nuxt/

公式Piniaソースコードをgit submoduleとして追加することで、公式の実装詳細と私たちの簡易バージョン(small-pinia)を同じプロジェクト構造内で直接参照および比較することができます。

コアプロジェクトの作成

以下の手順に従って、基本的なプロジェクトディレクトリを作成し、Nuxt 3アプリケーションをセットアップします。

  1. プロジェクトディレクトリの作成:
mkdir your-project
cd your-project
mkdir packages playground
mkdir packages/small-pinia

これにより、プロジェクトのルートディレクトリとpackages、playgroundサブディレクトリが作成されます。

  1. package.jsonの初期化:
npm init -y
npm install vue@3.5.13

これにより、プロジェクトのルートにpackage.jsonファイルが初期化されます。

  1. Nuxt 3アプリケーションの作成:
cd playground
npm create nuxt@latest nuxt

playgroundディレクトリに移動し、create-nuxtを使用してnuxtという名前の新しいNuxt 3プロジェクトをスキャフォールドします。

  1. 追加の開発依存関係のインストール(任意ですが推奨):
cd nuxt
npm install --save-dev eslint-plugin-prettier

パッケージマネージャーに関する注意

このプロジェクトの開発では、モノレポとローカルパッケージのリンクにおける効率性から、主にpnpmがパッケージマネージャーとして使用されました。上記のコマンドはnpmを使用して示されていますが、お使いのプロジェクトセットアップに合う任意のパッケージマネージャー(npm、yarn、pnpm)を自由に使用できます。

公式Piniaソースコードの追加

次に、公式Pinia GitHubリポジトリを、packagesディレクトリ内にgit submoduleとして追加します。これにより、実際のPiniaソースコードをローカルで参照できるようになります。

プロジェクトディレクトリの作成:

cd ./playground/nuxt
Pinia submoduleの追加:
git submodule add [https://github.com/vuejs/pinia.git](https://github.com/vuejs/pinia.git) ../../packages/pinia

このコマンドは、Piniaリポジトリをsubmoduleとして追加します。

Nuxt 3のソースコード開発のための設定

公開されたnpmパッケージをnode_modulesからインストールするのではなく、packagesディレクトリ内のPiniaソースファイルに直接リンクしているため、Nuxt(Viteを使用)とTypeScriptの両方を設定して、パスを正しく解決し、Piniaの内部要件を処理する必要があります。

playground/nuxt/nuxt.config.tsファイルを変更して、Piniaのビルドで内部的に使用される必要なVite defineフラグと、ローカルパッケージを簡単にインポートするためのエイリアスを追加します。

//nuxt.config.ts
import { dirname, resolve } from "path";
import { fileURLToPath } from "url";
const currentDir = dirname(fileURLToPath(import.meta.url));
const piniaRoot = resolve(currentDir, "../../packages/pinia/packages/pinia");
const smallPiniaRoot = resolve(currentDir, "../../packages/small-pinia");
export default defineNuxtConfig({
  compatibilityDate: "2025-04-12",
  devtools: { enabled: true },
  vite: {
    define: {
      __DEV__: "true",
      __BROWSER__: "true",
      __USE_DEVTOOLS__: "true",
      __TEST__: "false",
    },
  },
  alias: {
    "@pinia": piniaRoot,
    "@small-pinia": smallPiniaRoot,
  },
  modules: ["@nuxt/eslint"],
});


// tsconfig.json
{
  "extends": "./.nuxt/tsconfig.json",
  "compilerOptions": {
    "paths": {
      "@pinia/*": ["../../packages/pinia/packages/pinia/*"],
      "@small-pinia/*": ["../../packages/small-pinia/*"]
    }
  }
}

createPinia関数の実装

このセクションでは、createPinia関数の実装に焦点を当てます。createPiniaの主な役割は、全てのストアを管理する中央Piniaインスタンスを作成することです。このインスタンスは、VueのProvide/Injectメカニズムを介して、Vueアプリケーションのコンポーネントツリー全体で利用可能になります。公式Piniaソースコードに見られる原則を参照しながら、簡易バージョンを実装していきます。

Piniaインスタンス

createPiniaによって返されるオブジェクトは、中央のPiniaインスタンスです。このインスタンスは、個々のストアをVueアプリケーションに接続するハブとして機能します。これは、アプリケーションのルートで「provide」され、その後の全ての子孫コンポーネントで「inject」されることができるオブジェクトです。

この中央Piniaインスタンスは何の情報を持つ必要があるでしょうか?全てのストアの状態の値を直接保持するわけではありません(その状態は、defineStoreによって作成される個々のストアインスタンス内で管理されます)が、アプリケーション内のアクティブなストアインスタンス全てを追跡する方法が必要です。

Piniaインスタンスの基本的な要件は、ストアインスタンスを動的に登録および取得するメカニズムです。これを実現する最も簡単な方法は、マップ(JavaScriptのMapまたはプレーンオブジェクトなど)を使用することです。この内部マップには、実際の、アクティブなストアインスタンスが格納され、通常はストアの一意なID(defineStoreで提供される)をキーとします。

コンポーネントがストア関数(例:useCounterStore())を呼び出すと、基となるuseStoreロジックは、インジェクトされたPiniaインスタンス上のこの内部マップ(Piniaソースでは_s)にアクセスします。要求されたストアインスタンスがマップに既に存在する場合、それが返されます。存在しない場合は、defineStoreのブループリントに基づいて新しいインスタンスが作成され、そのIDを使用してマップに登録された後、返されます。このメカニズムにより、各ストアIDがPiniaインスタンスごとにアクティブなインスタンスを1つだけ持つことが保証され、コンポーネント間での状態共有が可能になると同時に、重要なSSRの隔離(各SSRリクエストが独自のユニークなPiniaインスタンスを持ち、したがって独自のストアインスタンスのセットを持つ)が尊重されます。

この図は、createPinia関数がPinia全体のアーキテクチャにどのように適合するか、特に中央インスタンスを提供する上でのその役割を示しています。

簡易的なcreatePiniaの実装を始めましょう!

createPiniaのプロジェクトファイル

以前作成したsmall-piniaパッケージディレクトリに移動し、必要なファイルを作成します。

cd ./packages/small-pinia/
touch createPinia.ts types.ts rootStore.ts

主要な関数用にcreatePinia.ts、PiniaインターフェースやProvide/Injectに使用するシンボルなどの共有定義を保持するためにrootStore.ts、後で追加する他の型定義のためにtypes.tsを作成します。今のところ、createPinia関数に十分な基本的なPiniaインターフェースをrootStore.tsに定義します。完全なStore型は複雑なので、後のセクションで扱います。

createPinia.tsの実装

以下は、createPinia関数の基本的な実装です。

// createPinia.ts
import { type App } from "vue";
import { type Pinia, piniaSymbol } from "./rootStore";

export function createPinia(): Pinia {
  const pinia: Pinia = {
    install(app: App) {
      // 一意なPiniaインスタンスをアプリケーションツリー全体に提供する
      app.provide(piniaSymbol, pinia);
    },
    _s: new Map<string, any>(),
  };
  return pinia;
}
// rootStore.ts
import type { InjectionKey } from "vue";
import type { App } from "vue";

export interface Pinia {
  install: (app: App) => void;

  _s: Map<string, any>;
}

export const piniaSymbol = Symbol("smallPinia") as InjectionKey<Pinia>;

プラグインを介したVueアプリケーションとの統合

createPiniaによって作成されたpiniaインスタンスをVueアプリケーションコンポーネントツリー全体で利用可能にするには、app.use()を使用してVueプラグインとしてインストールする必要があります。Nuxt 3アプリケーションでは、これを行う標準的で推奨される場所は、pluginsディレクトリにあるNuxtプラグインファイル内です。Nuxtはこのディレクトリ内のファイルをVueプラグインとして自動的に登録します。

Nuxtアプリケーションのルート(playground/nuxt/)に移動し、pluginsディレクトリと2つのプラグインファイルを作成します。

cd ./nuxt
mkdir plugins
touch plugins/small-pinia.ts plugins/pinia.ts

公式Piniaインスタンス用と私たちのsmall-pinia模倣用の2つのファイルを作成します。これにより、それらの動作を簡単に比較できるようになります。

// plugins/pinia.ts
import { createPinia } from "@pinia/src";

export default defineNuxtPlugin((nuxtApp) => {
  const pinia = createPinia();
  nuxtApp.vueApp.use(pinia);
});
// plugins/small-pinia.ts
import { createPinia } from "@small-pinia/createPinia";
export default defineNuxtPlugin((nuxtApp) => {
  const pinia = createPinia();
  nuxtApp.vueApp.use(pinia);
});

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

defineStore関数の実装

Piniaインスタンスを提供するcreatePiniaの実装に続き、次にdefineStoreに注目します。この関数は、ストアの状態、ゲッター、アクションを指定する、ストアを提供します。重要な点として、defineStoreはストアインスタンスを直ちに作成しません。代わりに、コンポーネント内で呼び出して実際の、アクティブなストアインスタンスを取得するための関数(しばしばuse...Storeという名前)を返します。

公式ドキュメントはその基本的な使用方法を示しています。

import { defineStore } from 'pinia'

// You can name the return value of `defineStore()` anything you want,
// but it's best to use the name of the store and surround it with `use`
// and `Store` (e.g. `useUserStore`, `useCartStore`, `useProductStore`)
// the first argument is a unique id of the store across your application
export const useAlertsStore = defineStore('alerts', {
  // other options...
})

公式の使用例(useAlertsStore)に見られるように、返される関数にはVue 3 Composablesと同様のuse...Storeパターンを使用するのが慣例です。この命名は、コンポーネントや他のComposablesのsetupコンテキスト内で呼び出す必要があるフックとしての使用方法を示唆しています。

このセクションでは、Composition APIスタイル(2番目の引数がセットアップ関数)に焦点を当て、defineStoreの簡易バージョンを実装します。これを以前作成したPiniaインスタンスに統合し、このパターンを使用できるようにするかを示します。正確なTypeScriptの型付けは、次のセクションで扱います。

Composition APIスタイル

defineStoreは、ストアのロジックを定義する2つの主要な方法をサポートしています。Composition APIスタイル(セットアップ関数を使用)とOptions APIスタイル(オプションオブジェクトを使用)です。このセクションでは、モダンなVue 3開発と私たちの模倣の構造によく合致するComposition APIスタイルに焦点を当てます。Options APIスタイルは後で扱います。

defineStore(id, setup)を呼び出すとき、このスタイルでは概念的に主に2つのことが起こります。

  • ストア定義を登録します。idは、後でこの特定のストアを識別するために使用されるキーとして機能します。
  • useStore関数を返します。これは、コンポーネントでインポートして呼び出す関数(例:useCounterStore)です。実際のストアインスタンスを取得するコアロジックは、この返された関数の中にあります。

useStore関数とは何でしょう?🤔

useStore関数のロジック

コンポーネントのsetup関数や他のComposablesの中でuseStore()が呼び出されるとき、それは以下のことが行われる。

  • Piniaインスタンスを取得します。app.use()およびprovideを介してアプリケーションルートで提供された、一意のPiniaインスタンスにアクセスする必要があります。このインスタンスを取得する正しい方法は、provide中に使用された特定のシンボル(piniaSymbol)を使用してVueのinject関数を使うことです。
  • 既存のストアインスタンスを確認します。ストアの一意なidを使用して、このストアのアクティブなインスタンスがPiniaインスタンスの内部マップ(_s)にすでに存在するかどうかを確認します。
  • 必要に応じて作成して登録します。このidに対してアクティブなインスタンスが見つからない場合(つまり、このアプリケーションツリーでこのストアに対してuseStoreが初めて呼び出された場合)、内部関数(createSetupStoreと名付けます)を呼び出して、提供されたsetup関数に基づいてストアをインスタンス化し、そのidを使用してpinia._sマップに登録し、準備を行います。
  • ストアインスタンスを返します。最後に、pinia._sマップから(おそらく新しく作成された)ストアインスタンスを取得して返します。

これにより、use...Store()を呼び出すと、現在のPiniaアプリケーションインスタンス内のその特定のストアIDに対して常に同じアクティブなインスタンスが得られることが保証されます。

defineStore関数の実装

small-piniaパッケージディレクトリ(packages/small-pinia/)に戻り、まだ作成していない場合はstore.tsファイルを作成します。

cd packages/small-pinia
touch store.ts

次に、以下のコードをstore.tsに追加します。

import { piniaSymbol, type Pinia } from "./rootStore";

export function defineStore(id: string, setup?: any) {
  const isSetupStore = typeof setup === "function";

  /**  This is what components import and call. */ 
  function useStore() {
    const pinia = inject(piniaSymbol, null);
    if (!pinia) {
      throw new Error("not call createPinia");
    }

    if (!pinia._s.has(id)) {
      if (isSetupStore) {
        /** setup method */
        createSetupStore(id, setup, pinia);
      } else {
        /**TODO: options method */
      }
    }

    const store: Record<string, any> = pinia._s.get(id)!;

    return store;
  }

  useStore.$id = id;
  return useStore;
}

function createSetupStore<Id extends string, SS extends Record<any, unknown>>(
  $id: Id,
  setup: () => SS,
  pinia: Pinia
) {
  const partialStore = {
    _p: pinia,
  };
  const store = reactive(partialStore);

  // register store into the pinia
  pinia._s.set($id, store);

  const setupStore = setup();

  Object.assign(store, setupStore);
  return store;
}

現在の制限:型の不足

前述したように、そしてコンポーネントでカウンターオブジェクトを使用している際におそらく明らかになったように、現在の実装ではいくつかの場所(Piniaインターフェース、defineStore、createSetupStore、pinia._s)でany型を使用しています。これは、useCounterSmallStoreを使用したり、返されたカウンターオブジェクトのプロパティにアクセスしたりする際に、適切な型チェック、オートコンプリート、または型推論が得られないことを意味します。

コアのリアクティブ機能は動作していますが、堅牢な型サポートはPiniaの主要な機能です。この制限に対処し、次のセクションでsmall-piniaに適切なTypeScript型付けを導入します。

Reference: You can check the code I write in the pr link
https://github.com/KOBATATU/small-pinia/pull/2

ストアの型の実装

前のセクションで述べたように、現在のdefineStoreおよびそれが返すストアインスタンスの実装は、特定の型情報に欠け、anyに依存しています。Piniaを使用する主要な機能であり大きな利点は、その優れたTypeScriptサポートであり、強力な型チェックとオートコンプリートを提供します。このセクションでは、Store型のための堅牢な型定義を導入し、ユーザーのdefineStoreセットアップ関数からこれらの型を自動的に推論するために必要な型レベルのロジックを実装することにより、small-piniaを大幅に改善します。

ストア型(状態、ゲッター、アクション)の詳細

概念的に、Piniaストアインスタンスは、ユーザーがuse...Store()を呼び出したときに公開される3つの主要な種類のプロパティを結合します。状態(State)、ゲッター(Getters)、アクション(Actions)です。型レベルでは、ユーザーのdefineStore()関数によって提供されるソース型(その戻り値型、SSと呼びましょう)と、ストアインスタンス上の最終的な公開される型Store)を区別することが重要です。

setup()関数の戻り値に含まれる型と、それらが最終的なストアインスタンスでどのように公開されるべきかを考えてみましょう。

    • 状態 (S): setup()関数では、リアクティブな状態は通常、ref()またはreactive()を使用して定義されます。setupの戻り値でこの状態を保持するプロパティは、Ref<T>のような型またはリアクティブオブジェクト型({ count: Ref<number> })を持つことになります。しかし、最終的なストアインスタンスで状態プロパティにアクセスするとき(例:counter.count)、Vueのリアクティビティシステムは自動的にRefアンラップし、Refオブジェクト自体ではなく、生の値を(この例ではnumber)にアクセスします。Store型はこのアンラップされた状態型を反映する必要があります。
    • ゲッター (G): setup()関数では、ゲッターはcomputed()を使用して定義されます。setupの戻り値でゲッターを保持するプロパティは、計算された値の型がRであるComputedRef<R>の型を持つことになります。状態と同様に、ストアインスタンスでアクセスするとき(例:counter.doubleCount)、ComputedRefオブジェクトではなく、アンラップされた計算値number)が得られます。Store型もこのアンラップされたゲッター値の型を反映する必要があります。
    • アクション (A): アクションは、setup()関数で直接定義される関数です。ストアインスタンスでアクセスするとき(例:counter.increment)、メソッドとして直接呼び出されます。Store型は、特別なアンラップなしにこれらの関数型を直接含めることができます。

.valueの問題(useStore()によって返されたオブジェクトに対してcounterSmallStore.count.valueにアクセスしようとした)は、setup()の戻り値型(RefComputedRefを含む)と最終的なStoreインスタンス型(これらがアンラップされる)との間のこの違いがあります。useStore()によって返される型(私たちのStore型)は、公式Piniaおよびテンプレートにおけるリアクティブオブジェクトの振る舞いに合わせて、.valueアクセスを必要とせずに状態とゲッターのRef<T>におけるTの型を取得/分離する必要があります.

例として、
前のセクションに戻り、私たちはuseCounsterSmallStoreを実装し、const counter = useCounterSmallStore()を呼び出しました。次のようなコードを書こうとしました。

const counterSmallStore = useCounterSmallStore();
console.log(counterSmallStore.count.value);

直感的にはブラウザに0の値が表示されると思うでしょう。しかし、ブラウザコンソールはundefined値を表示します。このとき、defineStore関数の戻り値が型として使用されている場合、私たちは間違った型定義に結びついてしまいます。なぜなら、valueオブジェクトを参照できないからです。実際には、私たちはref<T>のT型を参照しています。したがって、ref<T>のT型を抽出する必要があります。このロジックはcomputedプロパティと同じです。

したがって、Store型定義は、ベースプロパティと、セットアップ関数で定義された状態およびゲッターから派生したアンラップされた型、およびアクションの型を組み合わせる必要があります。ユーティリティメソッドを今は無視した、Store型の簡略化された構造は、概念的には次のようになります。

export type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {},
> = _StoreWithState<Id, S, G, A> & // Core properties ($id, _p)
  UnwrapRef<S> & // State properties (unwrapped)
  _StoreWithGetters<G> & // Getters (computed refs)
  A // Actions (functions become methods)
  • _SmallStoreWithState<Id, S, G, A>は、idやPiniaインスタンスへのリンク(_p)のようなコアストアプロパティ、および公式Pinia Store型に存在するdisposeや$onActionのようなユーティリティメソッドの型定義を保持します。このガイドではこれらのユーティリティの実装は省略しますが、それらの型はここに記述されます。
  • UnwrapRef<S>は、Vueのユーティリティ型で、型({ count: Ref<number> }など)を受け取り、全てのRefプロパティを再帰的にアンラップし、{ count: number }のような型を生成します。
  • _StoreWithGetters<G>は、セットアップ関数からのゲッタープロパティ({ doubleCount: ComputedRef<number> }など)を表す型を受け取り、そのアンラップされた値の型を抽出する、私たちが作成する必要のあるカスタムユーティリティ型です。これにより、{ doubleCount: number }のような型が生成されます。
  • Aは、アクション関数の型を表し、直接含まれます。

defineStoreへの型の適用と抽出ヘルパー

ユーザーのdefineStore関数の戻り値型(SS)を私たちのStore型構造(S、G、A)に接続するには、型レベルのヘルパーユーティリティが必要です。これらのユーティリティは、SS型を分析し、プロパティがRef/Reactive、ComputedRef、または関数であるかに基づいて、プロパティを状態ライク(S)、ゲッターライク(G)、アクションライク(A)の型にフィルタリング/分類します。

まず、完全な型定義を見て、それをpackages/small-pinia/types.tsに追加しましょう。

// .types
import type { ComputedRef, UnwrapRef } from "vue";
import type { Pinia } from "./rootStore";

export type StateTree = Record<PropertyKey, any>;

export type _Method = (...args: any[]) => any;

/**
 * Base properties common to all store instances.
 */
export interface SmallStoreProperties<Id extends string> {
  /**
   * Unique identifier of the store.
   */
  $id: Id;

  /**
   * The Pinia instance this store belongs to.
   */
  _p: Pinia;
}

export interface _SmallStoreWithState<
  Id extends string,
  S extends StateTree,
  G,
  A
> extends SmallStoreProperties<Id> {
  // $dispose and $patch utils type
}

/**
 * Getters become readonly properties.
 */
export type _StoreWithGetters<G> = {
  readonly [K in keyof G]: G[K] extends ComputedRef<infer R> ? R : never;
};

export type Store<
  Id extends string = string,
  S extends StateTree = {},
  G = {},
  A = {}
> = _SmallStoreWithState<Id, S, G, A> & // Core properties ($id, _p)
  UnwrapRef<S> & // State properties (unwrapped)
  _StoreWithGetters<G> & // Getters (computed refs)
  A; // Actions (functions become methods)

export type _ExtractStateFromSetupStore<SS> = SS extends undefined | void
  ? {} // No state if setup returns nothing
  : {
      [K in keyof SS as SS[K] extends _Method | ComputedRef ? never : K]: SS[K];
    };
export type _ExtractActionsFromSetupStore<SS> = SS extends undefined | void
  ? {}
  : {
      [K in keyof SS as SS[K] extends _Method ? K : never]: SS[K];
    };
export type _ExtractGettersFromSetupStore<SS> = SS extends undefined | void
  ? {}
  : {
      [K in keyof SS as SS[K] extends ComputedRef ? K : never]: SS[K];
    };

defineStoreの更新(packages/small-pinia/store.ts)

次に、これらの型をdefineStore関数のシグネチャで使用して、TypeScriptに期待される型を伝えます。createSetupStore内のランタイムロジックは、セットアップの結果をリアクティブオブジェクトに正しくマージすることがその仕事であるため、ほとんど同じままです。私たちが定義した型は、そのマージ結果を型レベルで記述するものです。

// store.ts
export function defineStore<
  Id extends string,
  SS extends Record<PropertyKey, unknown>
>(id: Id, setup: () => SS) {
  const isSetupStore = typeof setup === "function";

  function useStore(): Store<
    Id,
    _ExtractStateFromSetupStore<SS>,
    _ExtractGettersFromSetupStore<SS>,
    _ExtractActionsFromSetupStore<SS>
  > {
    const pinia = inject(piniaSymbol, null);
    if (!pinia) {
      throw new Error("not call createPinia");
    }

    if (!pinia._s.has(id)) {
      if (isSetupStore) {
        /** setup method */
        createSetupStore(id, setup, pinia);
      } else {
        /**TODO: options method */
      }
    }

    const store = pinia._s.get(id)! as Store<
      Id,
      _ExtractStateFromSetupStore<SS>,
      _ExtractGettersFromSetupStore<SS>,
      _ExtractActionsFromSetupStore<SS>
    >;
    ...

これで、useCounterSmallStoreを使用する際に、counter.countやcounter.doubleCount、counter.incrementにアクセスするときに適切な型を取得できるようになります!

Reference: You can check the code I write in the pr link
https://github.com/KOBATATU/small-pinia/pull/3

サマリ

(2)のセクションで,middlewareでの取得やoptions方式の実装をします.また気が向いたらhydrationの対策を行います.

Discussion