上一篇我們介紹了如何在 Nuxt 3 使用 useState 來建立一個元件間的共享狀態,隨著專案的健壯增大,我們就需要一個更好的方式來管理與儲存這些狀態,例如在 Vue 中使用 Vuex 或 Pinia 來建立一個 Store 管理這些狀態就是一個解決方案。如果你還不了解 Pinia,可以理解為是 Vuex v5。因為目前 Pinia 已經成為 Vue 官方推薦的狀態管理解決方案,本篇將針對 Nuxt 使用 Pinia 做一個簡單的介紹。
如果你使用過 Vuex 大概會知道 Vue 如何建立 Store 來做狀態管理,隨著時間 Vuex 很積極的蒐集社群及使用者的意見來規劃 Vuex v5。Pinia 的作者 Eduardo 是 Vue.js 核心團隊的成員之一,也參與著 Vuex 的開發,當時他正測試著 Vuex v5 的提案,而 Pinia 成為探索這些意見及可能性的先驅,實現了 Vuex v5 可能的樣子,現在 Pinia 的 API 已經進入穩定狀態,也成為 Vue 官方推薦使用的狀態管理解決方案,並遵循著 Vue 生態的 RFC 流程。
Pinia 相較於 Vuex 有以下差異:
mutation
,只需要使用 action
就可以改狀態。modules
巢狀的結構,也不再需要為模組定義命名空間,因為在 Pinia 中,可以定義多個 Store 而且每個都是獨立的也都具有自己的命名空間。npm install -D pinia @pinia/nuxt --force
目前照著官方安裝 Pinia,會發生一些問題,所以我們在安裝時加上 --force 參數
添加 @pinia/nuxt
至 nuxt.config.ts
的 modules 屬性中。
export default defineNuxtConfig({
modules: ['@pinia/nuxt']
})
Pinia 提供了一個函數 defineStore
用來定義 store,呼叫時需要一個唯一的名稱來當作第一個參數傳遞,也稱之為 id
,Pinia 會使用它來將 store 連接到 devtools。
建議將回傳的函數命名為 use...
,例如 useCounterStore
,use
作為開頭是組合式函數命名的約定,來符合使用上的習慣。
而 defineStore
的第二個參數,可以傳入 Options 物件
或是 Setup 函數
,例如我們使用 Opsions 來定義一個 Store,新增 ./stores/counter.js
,內容如下:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count += 1
},
decrement() {
this.count -= 1
}
},
getters: {
doubleCount: (state) => state.count * 2
}
})
可以發現到與 Vue 的 Options API 非常類似,我們可以傳遞帶有 state
、actions
和getters
屬性的物件。這些屬性正好讓Store 與 Options API 呼應彼此的關係,如 state
對應 data
、actions
對應 methods
而 getters
對應 computed
。
還有另一種方式可以來定義 Store ,與 Vue Composition API 的 setup 函數類似,我們可以傳入一個函數,這個函數裡面定義響應式屬性、方法等函數,最後回傳我們想公開的屬性和方法所組成的物件。
以 setup 函數定義 counter store,內容如下:
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const increment = () => {
count.value += 1
}
const decrement = () => {
count.value -= 1
}
const doubleCount = computed(() => count.value * 2)
return {
count,
increment,
decrement,
doubleCount
}
})
我們只需要在元件中,如下程式碼匯入並呼叫 useCounterStore()
就可以操作 store 裡面的方法或屬性囉!
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
我們新增一個頁面元件 ./pages/counter.vue
,內容如下:
<template>
<div class="bg-white py-24">
<div class="flex flex-col items-center">
<span class="text-9xl font-semibold text-sky-600">{{ counterStore.count }}</span>
<div class="mt-8 flex flex-row">
<button
class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
@click="counterStore.increment"
>
增加
</button>
<button
class="font-base mx-2 rounded-full bg-sky-500 px-4 py-2 text-xl text-white hover:bg-sky-600 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
@click="counterStore.decrement"
>
減少
</button>
</div>
<div class="mt-8">
<NuxtLink to="/">回首頁</NuxtLink>
</div>
</div>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
</script>
這樣我們就完成了一個 store 的顯示狀態值,透過呼叫 counterStore
內定義的 increment
與 decrement
來改變狀態。
在不同的元件間,你也可以使用 useCounterStore
取得已經建立好的 store 來共享這些狀態或進行操作。
預設情況下,可以直接對 store 的實例來取得狀態,而使用 Pinia 定義的 store 比較特別得是,我們可以不用透過呼叫函數來修改狀態,也可以直接對 sotre 的狀態進行修改。
const counterStore = useCounterStore()
counterStore.count += 10
除了直接使用 counterStore.count += 10
修改 store,你也可以使用 store 提供的 helper $patch
來修改部分
的狀態。
userStore.$patch({
name: 'Ryan'
money: '88888888',
})
對於集合類型的修改,例如陣列的新增、刪除或指定修改某一個元素等操作,你可以使用 $patch
傳入一個函數,這個函數會接收一個 state
讓你可以修改,對於比較複雜的操作會很方便。
cartStore.$patch((state) => {
state.items.push({ name: 'shoes', quantity: 1 })
state.hasChanged = true
})
如果你需要,也可以將 store 的整個 state 重新設置成一個新的物件。
cartStore.$state = {
items: [],
hasChanged: false,
}
sotre 的實例提供了一個 $reset()
的 helper,呼叫它就可以將 store 的狀態重置至初始值,不過目前只在使用 Option 物件定義的 store 才有實作。
const counterStore = useCounterStore()
counterStore.$reset()
在 store 內你可以組合多個 getter,在 Option 物件下,可以透過使用 this
來呼叫使用其他的 getter。
export const useStore = defineStore('main', {
state: () => ({
counter: 0,
}),
getters: {
doubleCount: (state) => state.counter * 2,
doubleCountPlusOne() {
return this.doubleCount + 1
},
}
})
在 store 內你也可以組合其他 store 的 getter,只要建立出其他 store 實例就可以呼叫使用了。
import { useOtherStore } from './other-store'
export const useStore = defineStore('main', {
state: () => ({
// ...
}),
getters: {
otherGetter(state) {
const otherStore = useOtherStore()
return state.localData + otherStore.data
},
},
})
Actions 相當於元件中的方法,也是修改狀態的商業邏輯定義的位置,action 可以是同步也可以是異步的,因此,我們也能在 action 中打後端 API 來取得資料後更新狀態。
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
profile: {
name: '',
gender: '',
email: ''
}
}),
actions: {
async getUserProfile() {
try {
const { data } = await useFetch('/api/profile')
this.profile = data
} catch (error) {
return error
}
}
}
})
有些情況,你可能需要將 Store 中的屬性或方法獨立的提取出來,但為了保持屬性的響應性,你需要使用 storeToRefs
來建立屬性的參考,就像使用 toRefs
來建立 props
的參考一樣。
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counterStore = useCounterStore()
const { count } = storeToRefs(counterStore)
const { increment, decrement } = counterStore
Pinia 是個非常輕量的狀態管理解決方案,而且也提供底層 API 使得 Pinia 能夠自定義插件來擴展功能,舉例來說,我們有些狀態需要儲存在使用者瀏覽器中,下次再瀏覽時可以取的當時儲存的狀態資料,我們就需要將 store 的狀態持久化。
我們可以使用 Pinia Plugin Persistedstate 這個插件,來做到持久化這件事,這對於儲存使用者資訊或登入狀態非常的方便。
npm install -D @pinia-plugin-persistedstate/nuxt --force
目前照著官方安裝 Pinia,會發生一些問題,所以我們在安裝時加上 --force 參數
添加 @pinia-plugin-persistedstate/nuxt
至 nuxt.config.ts
的 modules 屬性中。
export default defineNuxtConfig({
modules: ['@pinia/nuxt', @pinia-plugin-persistedstate/nuxt]
})
在現有的 store 定義中添加,persist
屬性,來配置 store 持久化,將狀態儲存在瀏覽器的 localStorage
。
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count += 1
},
decrement() {
this.count -= 1
}
},
getters: {
doubleCount: (state) => state.count * 2
},
persist: {
key: 'counter',
storage: persistedState.localStorage
}
})
如果是使用 setup 函數定義 store,你可以在 defineStore 傳入第三個參數並添加 persist
屬性。
import { defineStore } from 'pinia'
export const useCounterStore = defineStore(
'counter',
() => {
const count = useState('count', () => 0)
const increment = () => {
count.value += 1
}
const decrement = () => {
count.value -= 1
}
const doubleCount = computed(() => count.value * 2)
return {
count,
increment,
decrement,
doubleCount
}
},
{
persist: {
key: 'counter',
storage: persistedState.localStorage
}
}
)
當我們設置好 counterStore 的持久化後,我們的狀態就會被儲存在瀏覽器的 localStorage
之中,就算關閉瀏覽器或重新整理網頁,store 的狀態都會再從 localStorage
讀取出來。
在小型的專案中,你可以使用 useState 來管理,但大專案你就需要一個更好的方式來管理這些狀態,如 Pinia 來為我們管理這些狀態,甚至定義多個 store,Pinia 支援的插件能協助我們擴展 Pinia 的功能,Pinia Plugin Persistedstate 就是一個很常用的插件,能協助我們將 Pinia 的狀態持久化至瀏覽器的 localStorage
或 sessionStorage
中。
感謝大家的閱讀,這是我第一次參加 iThome 鐵人賽,請鞭小力一些,也歡迎大家給予建議 :)
如果對這個 Nuxt 3 系列感興趣,可以訂閱接收通知,也歡迎分享給喜歡或正在學習 Nuxt 3 的夥伴。
感謝 Ryan 系列文章協助 Nuxt3 苦手~
我想透過 Pinia state 來處理共享組件的狀態,如果把 state 或 actions 包在另一個函數裡面就會失效,有什麼建議可以處理這個問題嗎?
先貼我的 code 上來:
stores/store.js (我想將 isLoading 狀態放在 store 管理)
import { defineStore } from 'pinia'
export const useStore = defineStore('piniaStore', () => {
const isLoading = ref(false)
function toggleLoading() {
isLoading.value = !isLoading.value
}
return {
isLoading,
toggleLoading,
}
})
layouts/default.vue (我在這裡定義了共享的 overlay 組件,並透過 isLoading 狀態來操作顯示與否)
<template>
<div class="appContainer">
<slot/>
</div>
<v-overlay
v-model="isLoading"
>
</v-overlay>
</template>
<script setup>
import { useStore } from '/stores/store.js'
const store = useStore()
const isLoading = store.isLoading
</script>
pages/Login.vue(不管把 store 的 actions 或是 state 放到函數裡面皆會失效)
<script setup>
import { useStore } from '/stores/store.js'
const store = useStore()
store.toggleLoading // 如果直接放在 script setup 裏面,我可以成功調整 isLoading 狀態並打開 overlay
const loginRequest = () => {
store.toggleLoading // 不管是將 state 或是 actions 放在任一函數裡都會失效
};
</script>
<template>
<v-form @submit="loginRequest">
<v-input></v-input>
<v-btn></v-btn>
</v-form>
</template>
感謝 ><
嗨,您好
首先,store 的定義看起來沒有問題,但預設的布局模板 (layouts/default.vue),const isLoading = store.isLoading
這段程式碼,
看得出來,您想要取得 store 內的狀態,不過這裡你可能需要注意一下狀態的響應性,否則原本你的寫法只會指派一次狀態值。
所以你需要使用 storeToRefs
或 computed
來計算這個 isLoading 狀態。
layouts/default.vue
// ...
// const isLoading = store.isLoading
// 調整為
const { isLoading } = storeToRefs(store)
再來就是登入的頁面 pages/Login.vue,邏輯基本上沒錯,但是語法可能您在描述的時候忘記你的 actions 需要呼叫,store.toggleLoading -> store.toggleLoading()
。
最後你在使用 form 的 submit 事件時,需要加上 prevent
修飾符,防止原生的表單 submit 時頁面會刷新,當然你也可以先使用單純的按鈕來進行上面的修正測試,這只是個小細節。
pages/Login.vue
<script setup>
import { useStore } from '/stores/store.js'
const store = useStore()
// store.toggleLoading // 如果直接放在 script setup 裏面,我可以成功調整 isLoading 狀態並打開 overlay
const loginRequest = () => {
store.toggleLoading() // 不管是將 state 或是 actions 放在任一函數裡都會失效
};
</script>
<template>
<v-form @submit.prevent="loginRequest">
<v-input></v-input>
<v-btn></v-btn>
</v-form>
</template>
這邊是我簡單起的範例程式碼,你可以再測試看看是否能解決所遇到的問題。
https://stackblitz.com/edit/nuxt-starter-vkhp9m?file=layouts%2Fdefault.vue&file=pages%2Flogin.vue
早,
在 layouts/default.vue 佈局中改成 const { isLoading } = storeToRefs(store)
, 並且在 pages/Login.vue 改用 store.toggleLoading()
就可以了!
解答很清楚,衷心感謝!