在 Day 23: 跨套件的響應管理 我們討論了跨套件的響應管理,發現 Pinia 在 Vapor + Alien Signals 的世界裡能天然受惠,但也有不少潛在的陷阱。
今天這篇要深入 Pinia 的使用模式、store 設計、插件相容性與最佳實踐,重點是:如何設計 signal-friendly store、如何避免常見失去 reactivity 的錯誤、以及如何測試專案的 store 在高更新量下的效能。
Pinia store 本質上是 Vue 的 reactive object 結構,所以在 Composition API 模式下,defineStore(...)
返回 reactive
/ ref
的狀態與 methods。
當 Vue 3.6 的響應追蹤系統升級為 Alien Signals,很多 ref
/ computed
的更新不再經過粗粒度的 watcher tree,而是更原子、更精準的通知,因此,Pinia 更新狀態在通知使用者組件重繪的成本會降低。
問題 | 原因 | 解法 |
---|---|---|
解構 store 後失去 reactivity | 如果直接 const { count } = useMyStore() ,這個 count 是 primitive value 而非 reactive 引用 |
用 storeToRefs(store) 或 computed(() => store.count) 來包裝,保留 reactivity |
插件 (persist / plugin) 的寫法不當導致過度 I/O 或序列化負荷 | persist 插件通常在狀態改變時序列化整個 store 或大塊狀態 | 限制 persist 範圍,只 persist 必要欄位;使用 debounce / batch 寫入;或把 persist 放在低頻率變動的子 store |
time-travel 與 signal snapshot 不匹配 | Devtools 若預期把某個狀態 snapshot 回退,但 signal 與 reactive 混用時 snapshot 行為可能不一致 | 測試 time-travel 功能,觀察狀態回滾;查看 Devtools 的警告訊息;與 plugin 作者協調更新支援 signal snapshot |
今天用過去職場上最常使用的專案 dashboard,有用戶資訊 + 主題設定 + 通知中心三個功能模組當作範例。
my-vue-vapor-app/
├── src/
│ ├── stores/
│ │ ├── user.ts
│ │ ├── theme.ts
│ │ └── notification.ts
│ ├── components/
│ ├── composables/
│ ├── App.vue
│ └── main.ts
├── vite.config.ts
└── package.json
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const name = ref('kuku')
const role = ref<'admin' | 'user'>('user')
const isAdmin = computed(() => role.value === 'admin')
function setName(n: string) { name.value = n }
function setRole(r: 'admin' | 'user') { role.value = r }
return { name, role, isAdmin, setName, setRole }
})
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const theme = ref<'light' | 'dark'>('light')
function toggleTheme() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// persist plugin(注意序列化的部分)
// watch(theme, (val) => {
// localStorage.setItem('theme', val)
// })
return { theme, toggleTheme }
})
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useNotificationStore = defineStore('notification', () => {
// 使用 ref 存儲通知列表
const notifications = ref<string[]>([])
// 添加通知的方法
function addNotification(message: string) {
notifications.value.push(message)
}
// 清空通知的方法
function clearNotifications() {
notifications.value = []
}
// 計算通知數量
const notificationCount = computed(() => notifications.value.length)
return { notifications, addNotification, clearNotifications, notificationCount }
})
<script setup lang="ts">
import { useUserStore } from './stores/user'
import { useThemeStore } from './stores/theme'
import { useNotificationStore } from './stores/notification'
// 取得 store 狀態
const user = useUserStore()
const theme = useThemeStore()
const notification = useNotificationStore()
// 切換主題
const toggleTheme = () => {
theme.toggleTheme()
}
// 添加隨機通知
const addRandomNotification = () => {
const randomMessages = [
'New message received!',
'Your profile was updated.',
'You have a new follower.',
'System update completed.'
]
const randomMessage = randomMessages[Math.floor(Math.random() * randomMessages.length)]
notification.addNotification(randomMessage)
}
// 清除通知
const clearNotifications = () => {
notification.clearNotifications()
}
</script>
<template>
<div :class="theme">
<header>
<h1>My Dashboard</h1>
<button @click="toggleTheme">Toggle Theme</button>
<p>Current theme: {{ theme }}</p>
</header>
<main>
<section>
<h2>User Info</h2>
<p>Name: {{ user.name }}</p>
<p>Role: {{ user.role }}</p>
<p v-if="user.isAdmin">You are an Admin!</p>
</section>
<section>
<h2>Notifications</h2>
<ul>
<li v-for="(notification, index) in notification.notifications" :key="index">
{{ notification }}
</li>
</ul>
<button @click="addRandomNotification">Add Random Notification</button>
<p>Total notifications: {{ notification.notificationCount }}</p>
<button @click="clearNotifications">Clear Notifications</button>
</section>
</main>
</div>
</template>
<style scoped>
/* 主題樣式 */
.light {
background-color: white;
color: black;
}
.dark {
background-color: black;
color: white;
}
</style>
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue({
// 啟用 Vue 3.6 的 Vapor 模式
vapor: true
})
],
server: {
port: 3000, // 可以根據需求修改端口
},
resolve: {
alias: {
'@': '/src',
},
},
optimizeDeps: {
include: ['pinia'],
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/assets/styles/variables.scss";`, // 如果有全域的 scss 變數可以加這行
},
},
},
})
使用 signal
或 ref
混用時,選用正確暴露方式 storeToRefs
/ computed
plugin 的序列化 / persist 要控制範圍、避免頻繁寫入
Devtools 的 support 是關鍵:檢查工具版本,確保 signal / Vapor 模式下可以看到更新邊界
在專案中為 store 的更新量設定測試條件(例如一秒內多次更新)來觀察是否出現瓶頸