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。先問:
這個狀態是誰擁有?
誰需要讀?
誰可以改?
需要跨頁保存嗎?
需要刷新後恢復嗎?
需要被分享嗎?
需要 SSR 嗎?不同答案會導向不同位置。
| 狀態類型 | 適合放哪裡 |
|---|---|
| 按鈕 loading、dialog open | 元件內部 |
| 父子共用表單值 | 父層 state + props / emit |
| 多個相鄰元件共用 | 提升到共同父層 |
| 可復用的狀態邏輯 | Composable |
| 搜尋條件、分頁、排序 | URL query |
| 登入使用者、權限、購物車 | Pinia / Vuex |
| 後端查詢結果 | store、query cache、memory cache 視場景 |
狀態管理不是越集中越好,而是要放在最能表達它生命週期的位置。
2. 不是所有狀態都該進 store
容易犯的錯:
只要兩個元件要用,就放 Pinia這會讓 store 變成全域垃圾桶。
不適合過早放 store 的狀態:
- Modal 是否打開。
- 單一元件的 hover 狀態。
- 局部表單暫存。
- 某個頁面一次性使用的 loading。
- 不需要跨頁共享的列表 UI 狀態。
這些狀態放太全域,會讓資料流變得難追,也容易造成不必要的更新。
3. Store 解決的是遠距共享與業務狀態
適合放 store:
- 當前登入使用者。
- 權限與角色。
- 全站設定。
- 購物車。
- 多頁共用的業務資料。
- 需要跨路由保留的狀態。
- 多個遠距元件都需要讀寫的資料。
Store 的價值是讓遠距共享狀態有一致入口,而不是取代所有元件狀態。
二、 Pinia 基本模型
1. Pinia store 長什麼樣?
Option Store 寫法:
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
}
},
},
})使用:
const userStore = useUserStore()
await userStore.fetchProfile()2. Setup Store 寫法
Pinia 也可以用 Composition API 風格:
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。
例如是否登入:
const isLogin = computed(() => Boolean(profile.value))它是衍生資料,通常不需要額外存一份 isLogin,避免資料不同步。
三、 Pinia 和 Vuex 差異
1. Vuex 的核心模型
Vuex 經典模型:
state
getters
mutations
actions
modulesVuex 強調同步 mutation:
actions: {
async fetchUser({ commit }) {
const user = await fetchUser()
commit('setUser', user)
}
}mutations: {
setUser(state, user) {
state.user = user
}
}這種模式資料流明確,但樣板程式碼較多。
2. Pinia 少了 mutations
Pinia 可以在 action 裡直接改 state:
async function fetchUser() {
user.value = await fetchUserApi()
}或 Option Store:
async fetchUser() {
this.user = await fetchUserApi()
}少了 mutation,程式碼更簡潔,TypeScript 推導也通常更自然。
3. 面試回答 Pinia vs Vuex
可以這樣回答:
| 面向 | Vuex | Pinia |
|---|---|---|
| API 模型 | state / getters / mutations / actions | state / getters / actions |
| TypeScript | 較需要額外型別處理 | 較友好 |
| Composition API | 可用但不是最自然 | 設計上更貼近 |
| 模組拆分 | modules | 多個 store 自然拆分 |
| 樣板程式碼 | 較多 | 較少 |
| 適用 | Vue 2 / 既有專案常見 | Vue 3 新專案常見 |
如果面試問 Vuex 3,通常指 Vue 2 生態中的 Vuex。實務上維護舊專案仍要懂 Vuex;新 Vue 3 專案多半會優先考慮 Pinia。
四、 狀態歸屬怎麼判斷?
1. 局部 UI 狀態留在元件
const opened = ref(false)
const submitting = ref(false)如果只有目前元件使用,就不要放 store。
例如:
- Dropdown 是否展開。
- Tooltip 是否顯示。
- 單一按鈕是否提交中。
- 目前 tab 是否展開。
這些狀態放在元件內最直觀。
2. 父子共享用 props / emit
父層擁有資料,子層顯示並回報事件:
<UserForm
:model-value="form"
@update:model-value="form = $event"
/>或使用 v-model:
<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。
例如:
/users?keyword=ada&page=2&role=admin好處:
- 可以分享。
- 重新整理後恢復。
- 瀏覽器上一頁下一頁可用。
- 更適合列表查詢。
延伸讀:Vue 路由實現:Hash 路由與 History 路由原理。
5. 遠距共享業務狀態放 store
例如:
const authStore = useAuthStore()
const cartStore = useCartStore()
const permissionStore = usePermissionStore()這些狀態通常需要多個頁面或遠距元件讀取,使用 store 比層層 props 更合理。
五、 Store 拆分與設計
1. 按業務 domain 拆分
不建議只有一個巨大 store:
useAppStore裡面放 user、cart、permission、product、order、ui、cache。
更好的拆法:
useAuthStore
usePermissionStore
useCartStore
useProductStore
useOrderStore拆分依據不是資料表,而是業務邊界與使用場景。
2. Store 不要和頁面強綁定
如果 store 名稱像這樣:
useUserListPageStore不一定錯,但要小心它變成「某個頁面的所有東西」:
- 表格資料。
- 搜尋條件。
- modal open。
- 按鈕 loading。
- 權限。
- 快取。
- UI 展開狀態。
這種 store 很容易長大。
可以拆:
- URL query 管搜尋條件。
- 元件內管 modal open。
- store 管跨頁共享的 user cache。
- Composable 管列表查詢流程。
3. Getter 不要做太重的計算
Getter 本質上是衍生資料。
如果每次都對巨大列表做 filter / sort:
const activeUsers = computed(() => {
return users.value
.filter(user => user.active)
.toSorted(sortByName)
})資料量大時要考慮:
- 後端分頁。
- 後端排序。
- 建索引。
- 快取查詢結果。
- 避免所有元件都依賴同一個重 getter。
延伸讀:Vue 響應式性能優化。
4. Action 負責流程,不只負責改值
Action 適合處理:
- 非同步請求。
- loading / error。
- 請求去重。
- 快取失效。
- 樂觀更新。
- 多個 state 一起更新。
例如:
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。
- 敏感資料不要長期存。
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,可能發生:
使用者 A 的資料
-> 被使用者 B 的 render 讀到所以 SSR 通常要為每個 request 建立新的 app、router、store。
延伸讀:Vue SSR 的實現原理。
2. 狀態注水要小心敏感資料
SSR 常會把 server 端狀態序列化到 HTML,讓 client 接手。
要注意:
- 不要注入 token。
- 不要注入敏感個資。
- 只注入首屏必要資料。
- 防止 XSS。
- 確保 server/client 初始狀態一致。
3. Pinia 在 SSR 中要建立獨立實例
概念上是:
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 時清理狀態。
可以這樣設計:
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 名稱通常像:
authcartpermissioncheckoutproductCatalog
壞味道是:
commonglobalpagetempdata
不是不能有 app-level store,而是要避免它變成所有狀態的停車場。
3. 團隊要有一致規範
成熟專案會規範:
- 哪些狀態可以進 store。
- Store 如何拆分。
- Actions 是否負責請求。
- server state 是否使用 query cache。
- 是否允許直接在元件改 store state。
- 持久化策略。
- SSR 建立與注水流程。
這些規範比單一 API 選型更重要。
總結
| 問題 | 回答重點 |
|---|---|
| 狀態管理核心 | 判斷狀態歸屬與資料流 |
| 什麼放元件 | 局部 UI 狀態、短生命週期狀態 |
| 什麼放 URL | 可分享、可刷新恢復的查詢狀態 |
| 什麼放 Pinia / Vuex | 多頁或遠距共享的業務狀態 |
| Pinia vs Vuex | Pinia 更簡潔、少 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 狀態隔離與敏感資料安全。