iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
Vue.js

Vue3.6 的革新:深入理解 Composition API系列 第 27

Day 27: 狀態管理的下一步 - Pinia × Alien Signals

  • 分享至 

  • xImage
  •  

Day 23: 跨套件的響應管理 我們討論了跨套件的響應管理,發現 Pinia 在 Vapor + Alien Signals 的世界裡能天然受惠,但也有不少潛在的陷阱。

今天這篇要深入 Pinia 的使用模式、store 設計、插件相容性與最佳實踐,重點是:如何設計 signal-friendly store、如何避免常見失去 reactivity 的錯誤、以及如何測試專案的 store 在高更新量下的效能。

為何 Pinia 能受惠於 Alien Signals


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

設計 signal-friendly Store + Repo 架構


今天用過去職場上最常使用的專案 dashboard,有用戶資訊 + 主題設定 + 通知中心三個功能模組當作範例。

repo 架構

my-vue-vapor-app/
├── src/
│   ├── stores/
│   │   ├── user.ts
│   │   ├── theme.ts
│   │   └── notification.ts
│   ├── components/
│   ├── composables/
│   ├── App.vue
│   └── main.ts
├── vite.config.ts
└── package.json

stores/user.ts

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 }
})

stores/theme.ts

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 }
})

stores/notification.ts

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 }
})

App.vue

<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>

main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

vite.config.ts

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 變數可以加這行
      },
    },
  },
})

小結


  • 使用 signalref 混用時,選用正確暴露方式 storeToRefs / computed

  • plugin 的序列化 / persist 要控制範圍、避免頻繁寫入

  • Devtools 的 support 是關鍵:檢查工具版本,確保 signal / Vapor 模式下可以看到更新邊界

  • 在專案中為 store 的更新量設定測試條件(例如一秒內多次更新)來觀察是否出現瓶頸

資料來源


  1. Pinia 官方文件:Introduction / Composition API 模式與 storeToRefs

    從 Store 解構

  2. github - stackblitz / alien-signals
  3. vue3使用pinia倉庫解構賦值響應式遺失的解決方式

上一篇
Day 26: Vite & Vue Devtools 的 Vapor 支援
下一篇
Day 28: SSR / Router - 全鏈路 Vapor 化挑戰
系列文
Vue3.6 的革新:深入理解 Composition API28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言