跳至主要內容
Skip to content

Vue 狀態管理:Pinia、Vuex 與狀態歸屬

狀態管理不是「資料都放進 store」。真正重要的是判斷狀態應該屬於哪裡:元件、父層、Composable、URL、全域 store,還是後端快取。狀態放錯地方,元件通訊、效能、測試和維護都會一起變難。

一句話回答:Vue 狀態管理的核心是狀態歸屬與資料流設計;局部 UI 狀態留在元件,父子共享用 props / emit 或 v-model,可復用邏輯放 Composable,可分享和刷新恢復的狀態放 URL,多頁共享的業務狀態才放 Pinia / Vuex;Pinia 相比 Vuex 更輕量、TypeScript 友好,也更貼近 Composition API。


一、 必考觀念

1. 狀態管理先問「誰擁有狀態」

面試常問:「什麼情況要用 Vuex 或 Pinia?」

不要一開始就回答 API。先問:

text
這個狀態是誰擁有?
誰需要讀?
誰可以改?
需要跨頁保存嗎?
需要刷新後恢復嗎?
需要被分享嗎?
需要 SSR 嗎?

不同答案會導向不同位置。

狀態類型適合放哪裡
按鈕 loading、dialog open元件內部
父子共用表單值父層 state + props / emit
多個相鄰元件共用提升到共同父層
可復用的狀態邏輯Composable
搜尋條件、分頁、排序URL query
登入使用者、權限、購物車Pinia / Vuex
後端查詢結果store、query cache、memory cache 視場景

狀態管理不是越集中越好,而是要放在最能表達它生命週期的位置。

2. 不是所有狀態都該進 store

容易犯的錯:

text
只要兩個元件要用,就放 Pinia

這會讓 store 變成全域垃圾桶。

不適合過早放 store 的狀態:

  • Modal 是否打開。
  • 單一元件的 hover 狀態。
  • 局部表單暫存。
  • 某個頁面一次性使用的 loading。
  • 不需要跨頁共享的列表 UI 狀態。

這些狀態放太全域,會讓資料流變得難追,也容易造成不必要的更新。

3. Store 解決的是遠距共享與業務狀態

適合放 store:

  • 當前登入使用者。
  • 權限與角色。
  • 全站設定。
  • 購物車。
  • 多頁共用的業務資料。
  • 需要跨路由保留的狀態。
  • 多個遠距元件都需要讀寫的資料。

Store 的價值是讓遠距共享狀態有一致入口,而不是取代所有元件狀態。


二、 Pinia 基本模型

1. Pinia store 長什麼樣?

Option Store 寫法:

typescript
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    profile: null as User | null,
    loading: false,
  }),
  getters: {
    isLogin: state => Boolean(state.profile),
  },
  actions: {
    async fetchProfile() {
      this.loading = true

      try {
        this.profile = await fetchUserProfile()
      } finally {
        this.loading = false
      }
    },
  },
})

使用:

typescript
const userStore = useUserStore()

await userStore.fetchProfile()

2. Setup Store 寫法

Pinia 也可以用 Composition API 風格:

typescript
export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  const totalPrice = computed(() => {
    return items.value.reduce((sum, item) => {
      return sum + item.price * item.quantity
    }, 0)
  })

  function addItem(item: CartItem) {
    items.value.push(item)
  }

  function clear() {
    items.value = []
  }

  return {
    items,
    totalPrice,
    addItem,
    clear,
  }
})

Setup Store 更像 Composable,但具備 store 的 devtools、插件、SSR 整合與全域共享語意。

3. state、getter、action 的角色

概念角色
state真正保存的狀態
getter從 state 推導出的衍生資料
action修改狀態、處理非同步、副作用

不要把所有東西都放 state。

例如是否登入:

typescript
const isLogin = computed(() => Boolean(profile.value))

它是衍生資料,通常不需要額外存一份 isLogin,避免資料不同步。


三、 Pinia 和 Vuex 差異

1. Vuex 的核心模型

Vuex 經典模型:

text
state
getters
mutations
actions
modules

Vuex 強調同步 mutation:

typescript
actions: {
  async fetchUser({ commit }) {
    const user = await fetchUser()
    commit('setUser', user)
  }
}
typescript
mutations: {
  setUser(state, user) {
    state.user = user
  }
}

這種模式資料流明確,但樣板程式碼較多。

2. Pinia 少了 mutations

Pinia 可以在 action 裡直接改 state:

typescript
async function fetchUser() {
  user.value = await fetchUserApi()
}

或 Option Store:

typescript
async fetchUser() {
  this.user = await fetchUserApi()
}

少了 mutation,程式碼更簡潔,TypeScript 推導也通常更自然。

3. 面試回答 Pinia vs Vuex

可以這樣回答:

面向VuexPinia
API 模型state / getters / mutations / actionsstate / getters / actions
TypeScript較需要額外型別處理較友好
Composition API可用但不是最自然設計上更貼近
模組拆分modules多個 store 自然拆分
樣板程式碼較多較少
適用Vue 2 / 既有專案常見Vue 3 新專案常見

如果面試問 Vuex 3,通常指 Vue 2 生態中的 Vuex。實務上維護舊專案仍要懂 Vuex;新 Vue 3 專案多半會優先考慮 Pinia。


四、 狀態歸屬怎麼判斷?

1. 局部 UI 狀態留在元件

typescript
const opened = ref(false)
const submitting = ref(false)

如果只有目前元件使用,就不要放 store。

例如:

  • Dropdown 是否展開。
  • Tooltip 是否顯示。
  • 單一按鈕是否提交中。
  • 目前 tab 是否展開。

這些狀態放在元件內最直觀。

2. 父子共享用 props / emit

父層擁有資料,子層顯示並回報事件:

vue
<UserForm
  :model-value="form"
  @update:model-value="form = $event"
/>

或使用 v-model

vue
<UserForm v-model="form" />

延伸讀:v-model 雙向綁定的原理

3. 跨層上下文用 Provide / Inject

適合:

  • Form 和 FormItem。
  • Table 和 TableColumn。
  • Theme。
  • i18n。
  • 父容器提供上下文給深層子元件。

Provide / Inject 是依賴注入,不是全域狀態管理。

延伸讀:Vue 元件通訊方式有哪些及原理

4. 可分享與可恢復狀態放 URL

適合放 URL query:

  • keyword。
  • page。
  • pageSize。
  • sort。
  • filters。

例如:

text
/users?keyword=ada&page=2&role=admin

好處:

  • 可以分享。
  • 重新整理後恢復。
  • 瀏覽器上一頁下一頁可用。
  • 更適合列表查詢。

延伸讀:Vue 路由實現:Hash 路由與 History 路由原理

5. 遠距共享業務狀態放 store

例如:

typescript
const authStore = useAuthStore()
const cartStore = useCartStore()
const permissionStore = usePermissionStore()

這些狀態通常需要多個頁面或遠距元件讀取,使用 store 比層層 props 更合理。


五、 Store 拆分與設計

1. 按業務 domain 拆分

不建議只有一個巨大 store:

text
useAppStore

裡面放 user、cart、permission、product、order、ui、cache。

更好的拆法:

text
useAuthStore
usePermissionStore
useCartStore
useProductStore
useOrderStore

拆分依據不是資料表,而是業務邊界與使用場景。

2. Store 不要和頁面強綁定

如果 store 名稱像這樣:

text
useUserListPageStore

不一定錯,但要小心它變成「某個頁面的所有東西」:

  • 表格資料。
  • 搜尋條件。
  • modal open。
  • 按鈕 loading。
  • 權限。
  • 快取。
  • UI 展開狀態。

這種 store 很容易長大。

可以拆:

  • URL query 管搜尋條件。
  • 元件內管 modal open。
  • store 管跨頁共享的 user cache。
  • Composable 管列表查詢流程。

3. Getter 不要做太重的計算

Getter 本質上是衍生資料。

如果每次都對巨大列表做 filter / sort:

typescript
const activeUsers = computed(() => {
  return users.value
    .filter(user => user.active)
    .toSorted(sortByName)
})

資料量大時要考慮:

  • 後端分頁。
  • 後端排序。
  • 建索引。
  • 快取查詢結果。
  • 避免所有元件都依賴同一個重 getter。

延伸讀:Vue 響應式性能優化

4. Action 負責流程,不只負責改值

Action 適合處理:

  • 非同步請求。
  • loading / error。
  • 請求去重。
  • 快取失效。
  • 樂觀更新。
  • 多個 state 一起更新。

例如:

typescript
async function addToCart(product: Product) {
  const snapshot = [...items.value]

  items.value.push({
    productId: product.id,
    quantity: 1,
    price: product.price,
  })

  try {
    await addCartItem(product.id)
  } catch (error) {
    items.value = snapshot
    throw error
  }
}

這裡 action 管理了樂觀更新與失敗回滾。

延伸讀:Vue 請求、快取與體驗優化


六、 持久化與快取

1. 不是所有 store 都要持久化

常見持久化:

  • token。
  • theme。
  • locale。
  • 使用者偏好。
  • 購物車。

不適合隨便持久化:

  • 權限細節。
  • 高敏感個資。
  • 大型列表資料。
  • 即時性很高的資料。
  • 錯誤狀態與 loading。

localStorage 很方便,但它不是安全儲存,也不是資料庫。

2. 持久化要考慮版本與清理

如果資料結構改了,舊 localStorage 可能導致錯誤。

常見策略:

  • 加版本號。
  • 登出時清理。
  • token 過期時清理。
  • schema 不相容時 reset。
  • 敏感資料不要長期存。
typescript
const STORAGE_VERSION = 2

版本策略看似小事,實務上很能避免線上奇怪狀態。

3. Store cache 和 server state 不一樣

後端查詢結果常被稱為 server state。

它有幾個特點:

  • 來源在後端。
  • 可能過期。
  • 需要 refetch。
  • 需要 cache key。
  • mutation 後要 invalidate。

如果把所有 server state 都當 client state 手動管理,store 很容易變複雜。

實務上可以:

  • 簡單場景用 Pinia cache。
  • 複雜查詢用資料請求層。
  • 大型應用評估 query library。
  • 對不同資料設 TTL 與失效策略。

七、 SSR 與狀態隔離

1. SSR 中 store 要避免跨請求污染

在 SSR 中,server 會同時處理多個使用者請求。

如果全域共享一份 store,可能發生:

text
使用者 A 的資料
-> 被使用者 B 的 render 讀到

所以 SSR 通常要為每個 request 建立新的 app、router、store。

延伸讀:Vue SSR 的實現原理

2. 狀態注水要小心敏感資料

SSR 常會把 server 端狀態序列化到 HTML,讓 client 接手。

要注意:

  • 不要注入 token。
  • 不要注入敏感個資。
  • 只注入首屏必要資料。
  • 防止 XSS。
  • 確保 server/client 初始狀態一致。

3. Pinia 在 SSR 中要建立獨立實例

概念上是:

typescript
export function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()

  app.use(pinia)

  return {
    app,
    pinia,
  }
}

重點不是背程式碼,而是知道每個請求不能共用同一份狀態容器。


八、 追問題庫

Q1:什麼情況要用 Pinia?

當狀態需要被多個遠距元件或多個頁面共享,且屬於業務狀態時,可以用 Pinia。

例如登入使用者、權限、購物車、全站設定、跨頁資料快取。

如果只是單一元件 UI 狀態,不需要放 Pinia。

Q2:Pinia 和 Vuex 最大差異是什麼?

Pinia 沒有 mutations,actions 可以直接改 state;API 更簡潔,TypeScript 推導更友好,多 store 拆分也更自然。

Vuex 的 mutation/action 模型更嚴格,舊 Vue 2 專案常見,維護時仍需要理解。

Q3:Store 和 Composable 怎麼選?

Composable 適合可復用邏輯與局部狀態,store 適合跨頁共享的業務狀態。

如果一段邏輯只是多個元件都會用,但每個元件都要有自己的獨立狀態,用 Composable。

如果多個頁面要共享同一份資料,用 store。

Q4:搜尋條件要放 store 還是 URL?

如果搜尋條件需要分享、刷新恢復、上一頁下一頁可用,優先考慮 URL query。

如果只是當前元件內部暫存,不需要分享,就放局部狀態。

不要把所有 query 狀態都塞進 store。

Q5:Pinia 狀態持久化要注意什麼?

注意敏感資料、過期、版本、登出清理與資料結構變更。

不是所有 store 都需要持久化,尤其 loading、error、大型列表、即時性資料通常不適合長期持久化。


九、 實作題

題目:設計一個登入狀態 store

需求:

  • 保存 user。
  • 提供 isLogin。
  • 支援 login / logout / fetchProfile。
  • token 持久化。
  • logout 時清理狀態。

可以這樣設計:

typescript
export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const token = ref<string | null>(localStorage.getItem('token'))
  const loading = ref(false)

  const isLogin = computed(() => Boolean(user.value && token.value))

  async function login(payload: LoginPayload) {
    loading.value = true

    try {
      const result = await loginApi(payload)

      token.value = result.token
      user.value = result.user
      localStorage.setItem('token', result.token)
    } finally {
      loading.value = false
    }
  }

  async function fetchProfile() {
    if (!token.value) {
      user.value = null
      return
    }

    user.value = await fetchProfileApi()
  }

  function logout() {
    token.value = null
    user.value = null
    localStorage.removeItem('token')
  }

  return {
    user,
    token,
    loading,
    isLogin,
    login,
    fetchProfile,
    logout,
  }
})

面試時要補充:

  • token 是否應該放 localStorage 要看安全策略。
  • 權限資料可能要另外拆 store。
  • API 401 時要統一 logout 或 refresh token。
  • SSR 時不能直接在 server 讀 localStorage
  • 敏感資訊不要直接注水到 HTML。

十、 資深視角

1. 狀態管理是架構決策

狀態放錯位置會帶來長期成本:

  • 局部狀態全域化,store 變很亂。
  • 全域狀態局部化,props 傳遞很痛苦。
  • URL 狀態沒放 URL,重新整理後消失。
  • server state 當 client state 管,快取失效很難。

資深工程師要能說出為什麼這個狀態放在這裡,而不只是會用 Pinia API。

2. Store 要保持業務語意

好的 store 名稱通常像:

  • auth
  • cart
  • permission
  • checkout
  • productCatalog

壞味道是:

  • common
  • global
  • page
  • temp
  • data

不是不能有 app-level store,而是要避免它變成所有狀態的停車場。

3. 團隊要有一致規範

成熟專案會規範:

  • 哪些狀態可以進 store。
  • Store 如何拆分。
  • Actions 是否負責請求。
  • server state 是否使用 query cache。
  • 是否允許直接在元件改 store state。
  • 持久化策略。
  • SSR 建立與注水流程。

這些規範比單一 API 選型更重要。


總結

問題回答重點
狀態管理核心判斷狀態歸屬與資料流
什麼放元件局部 UI 狀態、短生命週期狀態
什麼放 URL可分享、可刷新恢復的查詢狀態
什麼放 Pinia / Vuex多頁或遠距共享的業務狀態
Pinia vs VuexPinia 更簡潔、少 mutation、TypeScript 友好
Store 設計按 domain 拆分,避免全域大 store
資深取捨持久化、server state、SSR 隔離與安全

一句完整的面試回答可以是:

Vue 狀態管理我會先判斷狀態歸屬,而不是直接放 Pinia。局部 UI 狀態留在元件,父子共用用 props/emit 或 v-model,可復用邏輯放 Composable,可分享和刷新恢復的查詢條件放 URL query,多頁共享的業務狀態才放 Pinia 或 Vuex。Pinia 相比 Vuex 少了 mutations,API 更簡潔,TypeScript 推導和 Composition API 整合更自然;但維護 Vue 2 舊專案仍常會遇到 Vuex。Store 設計上要按業務 domain 拆分,避免全域大 store,也要處理持久化、快取失效、SSR 狀態隔離與敏感資料安全。

延伸閱讀