[キャッチアップ] もう一度 pinia
Pinia 発表当時に一度キャッチアップしてたけど、現職で採用していこうという方向になったので、忘却した内容も含めて改めてキャッチアップしていく。
過去の投稿物は一旦無視する。
基本的な概念、使い方は流石に覚えているので、もう少し WHY に着目してドキュメントを読み直していく。
What is Pinia?
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 を使用可能に
- アクション名やゲッター名を文字列で指定せずに、型推論された関数呼び出しで済むように
- ストアの動的な登録が不要に
- 普通に呼び出すだけで、自動で動的登録が行われる
- ストアのネストを廃止
- ストア内で別のストアをインポートすれば出来なくはない、が、機能としては提供しない
- ネームスペースの廃止
リアクティブモジュールの公開がSSRで脆弱性になる理屈がわからなかったので確認した。
Cross-Request State Pollution
- 本パターンは JS のルートスコープに状態を共有する
- いわばシングルトンモジュールの状態になる
- 純粋な CSR のみのサイトなら、各ブラウザにて唯一のシングルトンが作成される
- SSR が絡むと、サーバサイドで一度だけシングルトンが生成され、複数のクライアントリクエストに対して同じ状態が再利用されてしまう
- シングルトンのステートをDBから初期化しているとしたら、最初にリクエストしたクライアントの情報が他のクライアントに漏れ出してしまう
- 対応として、SSR のリクエストごとにシングルトンの初期化を再実行する必要がある
- ただし初期化処理にコストがかかる場合、これは大きなパフォーマンス影響がある
- 代替えとして、provide/inject パターンを用いて、Vue アプリケーションごとにグローバルストアを埋め込む手法がある
つまりサーバーサイド上のシングルトンに特定クライアントの情報が入るようにするんじゃねえってシンプルな問題かな。
Getting Started
Installation
普通のインストール手順なのでほぼ割愛。
- Vue 3 の場合、
createPinia
でルートストアをセットアップ - 個々のストアは使用時に動的に追加されるのでセットアップ作業は不要
- これだけで devtool からトレースできるようになる
What is a Store?
- いわゆるグローバルステート
- アプリケーションの状態やビジネスロジックをコンポーネントツリーに縛られずに共有できる
ストアは以下の3大要素で構成される
- state
- コンポーネントにおける data
- getters
- コンポーネントにおける computed
- actions
- コンポーネントにおける methods
When should I use a Store
- アプリケーション全体で利用されるデータの保持に使える
- 例えばログイン中ユーザー情報(ヘッダーにもサイドバーにもコンテンツにも現れる)
- 例えばページを跨いだ入力情報(マルチステップになっているフォームなど)
- 特定コンポーネントに閉じた状態はストアで持つべきではない
- そもそもすべてのアプリケーションでストアが必要とは限らない
Defining a Store
- ストアの定義には
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)
State
- 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
オプションでそれを防ぐことも出来る
Getters
- 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,
}),
},
}
mapGetters
も一応あるけど、非推奨化しててドキュメント内でもリファレンス内にだけひっそりと記載されてた。
Actions
- 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>
Plugins
プラグインは 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 を使うならとりあえず公式モジュールを入れようという話
Using a store outside of a component
- 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()