Closed11

[キャッチアップ] もう一度 pinia

shingo.sasakishingo.sasaki

Pinia 発表当時に一度キャッチアップしてたけど、現職で採用していこうという方向になったので、忘却した内容も含めて改めてキャッチアップしていく。

過去の投稿物は一旦無視する。

https://zenn.dev/sa2knight/scraps/872c058cbb82b2
https://zenn.dev/sa2knight/articles/74f066b7be24c3

基本的な概念、使い方は流石に覚えているので、もう少し WHY に着目してドキュメントを読み直していく。
https://pinia.vuejs.org/

shingo.sasakishingo.sasaki

What is Pinia?

https://pinia.vuejs.org/introduction.html

Introduction

  • pinia は Vue 3 及び Composition API におけるストアの再設計の実験として2019年に始まった
  • 出来上がったものは Vue 3 及び Composition API に必ずしも依存するものではなくなったが、基本的にはドキュメントでもそれらを前提とする

Why should I use Pinia?

  • piniha はコンポーネントやページをまたいで状態を共有するためのストアライブラリ
  • export const state = reactive({}) でモジュールを公開して共有するのと似ている
    • SSRでは脆弱性となるので危険
    • SPAにおいても、単なるモジュール公開するよりも以下のサポートを受けられる
      • devtool
      • HMR
      • プラグイン拡張
      • TypeScript
      • SSR

Basic example

細かいコードは後々出てくるのでポイントだけ

  • ストアの宣言は Option API 風にも、 Composition API 風にもできる
  • ストアの利用は Composition API (setup()) からでも、Vuex 風 (mapState, mapActions) にもできる

Composition API に用途を限定しないことである種の自由度があるので悩ましいところではある。

Why Pinia

名前の話。パイナップル由来で、その果物の形状がストアっぽいとかそんな話なんだろうけど興味はない。

A more realistic example

より実用的なTODOストアのサンプル。
state / getters / actions 全体で型安全化ができてるよという話。

Comparison with Vuex

  • Pinia は元々、Vuex 5 の実験として始まった
  • 気づいたら既に必要十分な実装が pinia で完了した
  • だから Vuex じゃなくて pinia として独立して、Vue の推奨ライブラリに成り代わった
  • Vuex と比べて pinia は
    • シンプルで覚えることの少ない API スタイル
    • 堅牢な型システムを備えている

RFCs

最初は RFC のスタイルを取らずに、メンテナの経験や、アーリーアダプタとの実用を経て良い感じになってから、Vue 公式エコシステムに加入して、現在では他のエコシステムと同様に RFC のシステムを採用するようになったよ

Comparison with Vuex 3.x/4.x

Vuex との仕様の違い

  • ミューテーションの廃止
    • ほとんどのケースでは冗長すぎるため、ステートの更新を直接、またはアクションを介して行うように
  • コンフィグレスで TypeScript を使用可能に
  • アクション名やゲッター名を文字列で指定せずに、型推論された関数呼び出しで済むように
  • ストアの動的な登録が不要に
    • 普通に呼び出すだけで、自動で動的登録が行われる
  • ストアのネストを廃止
    • ストア内で別のストアをインポートすれば出来なくはない、が、機能としては提供しない
  • ネームスペースの廃止
shingo.sasakishingo.sasaki

リアクティブモジュールの公開がSSRで脆弱性になる理屈がわからなかったので確認した。

https://vuejs.org/guide/scaling-up/ssr.html#cross-request-state-pollution

Cross-Request State Pollution

  • 本パターンは JS のルートスコープに状態を共有する
  • いわばシングルトンモジュールの状態になる
  • 純粋な CSR のみのサイトなら、各ブラウザにて唯一のシングルトンが作成される
  • SSR が絡むと、サーバサイドで一度だけシングルトンが生成され、複数のクライアントリクエストに対して同じ状態が再利用されてしまう
  • シングルトンのステートをDBから初期化しているとしたら、最初にリクエストしたクライアントの情報が他のクライアントに漏れ出してしまう
  • 対応として、SSR のリクエストごとにシングルトンの初期化を再実行する必要がある
  • ただし初期化処理にコストがかかる場合、これは大きなパフォーマンス影響がある
  • 代替えとして、provide/inject パターンを用いて、Vue アプリケーションごとにグローバルストアを埋め込む手法がある

つまりサーバーサイド上のシングルトンに特定クライアントの情報が入るようにするんじゃねえってシンプルな問題かな。

shingo.sasakishingo.sasaki

Getting Started

https://pinia.vuejs.org/getting-started.html

Installation

普通のインストール手順なのでほぼ割愛。

  • Vue 3 の場合、 createPinia でルートストアをセットアップ
  • 個々のストアは使用時に動的に追加されるのでセットアップ作業は不要
  • これだけで devtool からトレースできるようになる

What is a Store?

  • いわゆるグローバルステート
  • アプリケーションの状態やビジネスロジックをコンポーネントツリーに縛られずに共有できる

ストアは以下の3大要素で構成される

  • state
    • コンポーネントにおける data
  • getters
    • コンポーネントにおける computed
  • actions
    • コンポーネントにおける methods

When should I use a Store

  • アプリケーション全体で利用されるデータの保持に使える
    • 例えばログイン中ユーザー情報(ヘッダーにもサイドバーにもコンテンツにも現れる)
    • 例えばページを跨いだ入力情報(マルチステップになっているフォームなど)
  • 特定コンポーネントに閉じた状態はストアで持つべきではない
  • そもそもすべてのアプリケーションでストアが必要とは限らない
shingo.sasakishingo.sasaki

Defining a Store

https://pinia.vuejs.org/core-concepts/

  • ストアの定義には defineStore を使用し、グローバルユニークなストア名を付与する必要がある
  • 生成されたストアの変数名は任意だが、 useHogeStore が推奨されている

export const useAlertsStore = defineStore('alerts', {})

Option Stores

二種類あるストア生成方法の一つで、Vue の Option API と似た感じで、 state actions getters を定義する。

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

それぞれが Vue コンポーネントにおける data computed methods と同じようなものであるため直感的に書き始められるが、シンプルなストアしか定義することができない。

Setup Stores

二種類あるストア生成方法の一つで、Vue の setup() と似た感じで、ストアを定義する。

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const name = ref('Eduardo')
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, name, doubleCount, increment }
})

Options Store と比べ、Composition API を使用してより柔軟なストア定義が可能だが、その分実装が複雑になる。

What syntax should I pick?

Vue コンポーネントで Option API と Composition API どちらを使うかという話に似てる。快適なほうを選べばいいけど、とりあえず Option Stores から始めるのが良いんじゃないかな。

Using the store

  • ストアはコンポーネントから参照されることで初めて作成される
  • 参照は <script setup> 内、または setup 関数ないから、 useHogeStore が呼び出されたとき

ストアは reactive でラップされているため、分割代入をするとリアクティブが壊れるので注意。

<script setup>
const store = useCounterStore()
// ❌ This won't work because it breaks reactivity
// it's the same as destructuring from `props`
const { name, doubleCount } = store 
name // will always be "Eduardo" 
doubleCount // will always be 0 

setTimeout(() => {
  store.increment()
}, 1000)

// ✅ this one will be reactive
// 💡 but you could also just use `store.doubleCount` directly
const doubleValue = computed(() => store.doubleCount)
</script>

storeToRefs() を使えば、分割代入時に個々のリアクティブを保護することはできるよ。

const { name, doubleCount } = storeToRefs(store)
shingo.sasakishingo.sasaki

State

https://pinia.vuejs.org/core-concepts/state.html

  • State は Store の中心部で、アプリケーションの状態を保持する
  • state は関数で定義し、初期状態を返す関数にする

TypeScript

  • strict: true, noImplicitThis: true を設定することでデフォルトで型安全な State になる
  • 初期値が空配列、または NULL の場合は as を使って明示的に型を指定する必要はあるので注意
    • State 型を事前に作っておくでもヨシ

Accessing the state

State は利用側から直接読み書きが可能。ただし初期化時に定義したフィールドしか利用できない。

const store = useStore()
store.count++

Resetting the state

  • Option Stores の場合、$reset メソッドで State を初期値に戻せる
  • Setup Stores の場合は、自分でリセット関数を作って公開しよう

Usage with the Options API

  • mapState を用いてコンポーネントの data にバインド出来る
    • 更新した場合は mapWritableState を使用する

Mutating the state

  • $patch メソッドを使用して、複数のフィールドを同時に更新できる
  • $patch メソッドは更新データをオブジェクトで渡す方式のほか、関数を用いて更新することもできる
store.$patch({
  count: store.count + 1,
  age: 120,
  name: 'DIO',
})
store.$patch((state) => {
  state.items.push({ name: 'shoes', quantity: 1 })
  state.hasChanged = true
})

ここでいう「同時」とは、devtool 上の更新イベントが一つにまとまるというだけ。

Replacing the state

  • $state から直接 State を参照できるが、これを更新するとリアクティブが失われるので、全体を更新する際も $patch を使用する

Subscribing to the state

  • $subscribe メソッドを通じて、 State の変更を監視することができる
  • コンポーネント内で購読した場合、アンマウント時に自動で解約されるが、 detached オプションでそれを防ぐことも出来る
shingo.sasakishingo.sasaki

Getters

https://pinia.vuejs.org/core-concepts/getters.html

  • getters の実態は Computed
  • getter 関数は state を引数で受け取るので、そこから必要な値を抜き出す
  • this を通じて、getter から他の getter を参照できるが、その場合戻り値の型を明示する必要がある
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
  getters: {
    // automatically infers the return type as a number
    doubleCount(state) {
      return state.count * 2
    },
    // the return type **must** be explicitly set
    doublePlusOne(): number {
      // autocompletion and typings for the whole store ✨
      return this.doubleCount + 1
    },
  },
})
  • getter はストアから直接呼び出せる
<script setup>
import { useCounterStore } from './counterStore'

const store = useCounterStore()
</script>

<template>
  <p>Double count is {{ store.doubleCount }}</p>
</template>

Accessing other getters

前述の通り

Passing arguments to getters

  • getter の実態は computed だからパラメータを付与できない
  • getter が関数を返すようにすれば可能だが、これは関数が返るだけなのでキャッシュは一切されないため注意
export const useStore = defineStore('main', {
  getters: {
    getActiveUserById(state) {
      const activeUsers = state.users.filter((user) => user.active)
      return (userId) => activeUsers.find((user) => user.id === userId)
    },
  },
})

Accessing other stores getters

  • getter 関数内で他のストアにアクセスすればOK

Usage with setup()

<script setup> からは computed と同様にアクセス可能

const store = useCounterStore()

store.count = 3
store.doubleCount // 6

Usage with the Options API

  • setup 関数内でストアを返せば普通に使える
  • mapState を用いて getter にもアクセスできる
import { mapState } from 'pinia'
import { useCounterStore } from '../stores/counter'

export default {
  computed: {
    // gives access to this.doubleCount inside the component
    // same as reading from store.doubleCount
    ...mapState(useCounterStore, ['doubleCount']),
    // same as above but registers it as this.myOwnName
    ...mapState(useCounterStore, {
      myOwnName: 'doubleCount',
      // you can also write a function that gets access to the store
      double: (store) => store.doubleCount,
    }),
  },
}
shingo.sasakishingo.sasaki

Actions

https://pinia.vuejs.org/core-concepts/actions.html

  • action は コンポーネントの method みたいなもの
  • action は getter とも近く、this を通じてストアにアクセス可能
  • getter と異なり、非同期関数に出来る
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
  actions: {
    // since we rely on `this`, we cannot use an arrow function
    increment() {
      this.count++
    },
    randomizeCounter() {
      this.count = Math.round(100 * Math.random())
    },
  },
})

Accessing other stores actions

  • getter 同様、他のストアを参照すればOK

Usage with the Options API

  • getter 同様、 setup 関数からバインド出来る
  • mapActions も使える

Subscribing to actions

  • $onAction を利用することで、アクションの呼び出し及び結果の受け取りを監視することができる
  • $onAction にわたす関数は、アクション実行時に呼び出され、その中でさらに呼び出している after onError はそれぞれアクション完了時、アクション失敗時に呼び出される
const unsubscribe = someStore.$onAction(
  ({
    name, // name of the action
    store, // store instance, same as `someStore`
    args, // array of parameters passed to the action
    after, // hook after the action returns or resolves
    onError, // hook if the action throws or rejects
  }) => {
    // a shared variable for this specific action call
    const startTime = Date.now()
    // this will trigger before an action on `store` is executed
    console.log(`Start "${name}" with params [${args.join(', ')}].`)

    // this will trigger if the action succeeds and after it has fully run.
    // it waits for any returned promised
    after((result) => {
      console.log(
        `Finished "${name}" after ${
          Date.now() - startTime
        }ms.\nResult: ${result}.`
      )
    })

    // this will trigger if the action throws or returns a promise that rejects
    onError((error) => {
      console.warn(
        `Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
      )
    })
  }
)

// manually remove the listener
unsubscribe()
  • サブスクリプションはコンポーネントにひも付き、アンマウント時に破棄されるが、第二引数を指定することでそれを回避できる
<script setup>
const someStore = useSomeStore()

// this subscription will be kept even after the component is unmounted
someStore.$onAction(callback, true)
</script>
shingo.sasakishingo.sasaki

Plugins

https://pinia.vuejs.org/core-concepts/plugins.html

プラグインは pinia の機能を拡張できるシステムで、以下のことができるようになる。

  • ストアへのプロパティ追加
  • ストア定義時のオプション追加
  • ストアへのメソッド追加
  • 既存メソッドのラップ
  • アクションとその結果のインターセプト
  • ローカルストレージなどの副作用の追加

以上はをストア全体だけでなく、特定のストアにのみ適用させられる。

この章は直接使うことがあまりなさそうなのでざっくりと。

Introduction

プラグインは、pinia のコンテキストを受け取る関数の形式で作成し、必要に応じてストアに紐付ける拡張プロパティをオブジェクトで返却する。

コンテキストを受け取る例

export function myPiniaPlugin(context) {
  context.pinia // the pinia created with `createPinia()`
  context.app // the current app created with `createApp()` (Vue 3 only)
  context.store // the store the plugin is augmenting
  context.options // the options object defining the store passed to `defineStore()`
  // ...
}

シンプルに hello プロパティを生やすだけの例

pinia.use(() => ({ hello: 'world' }))

プラグインは、ストアがアプリケーションに紐付けられたタイミングで実行される

Argumenting a Store

  • プラグインで追加したプロパティを devtool からも追いたい場合は _customProperties への追加が必要
  • store は reactive でラップされているので、プロパティを ref にしても自動でアンラップされる

Adding new state

SSR する場合は state の追加に注意が必要という話

Adding new external properties

ストアが reactive のラッパーだけど、リアクティブにする必要のないプロパティを追加する場合は makeRaw 関数を適用することで剥がすことができる

Calling $subscribe inside plugins

ストアのサブスクリプションをプラグイン内で定義できる

Adding new options

プラグイン関数の引数から options を取り出して、ストア定義時の拡張オプションを参照できる

TypeScript

プラグインを追加した場合の型付の話。Vue プラグインとだいたい一緒かな。

Nuxt.js

Nuxt で pinia を使うならとりあえず公式モジュールを入れようという話

shingo.sasakishingo.sasaki

Using a store outside of a component

https://pinia.vuejs.org/core-concepts/outside-component-usage.html

  • pinia はルートストアを明示的に Vue アプリケーションに注入する必要がある
  • コンポーネントからストアにアクセスする場合は、必ず Vue アプリケーションのセットアップが先に行われるため、考えることはない
  • コンポーネント外から利用する場合は、手動で Vue アプリケーション注入を行わなければならない場合がある

注入されてからストアを参照すればOK

import { useUserStore } from '@/stores/user'
import { createApp } from 'vue'
import App from './App.vue'

// ❌  fails because it's called before the pinia is created
const userStore = useUserStore()

const pinia = createPinia()
const app = createApp(App)
app.use(pinia)

// ✅ works because the pinia instance is now active
const userStore = useUserStore()
このスクラップは2023/03/08にクローズされました