Pinia 状态管理:前端的全局单例 Service
为什么需要"全局状态"
Vue 组件通过 Props 向下 / Emit 向上 通信,provide / inject 跨层级传递。这两种能覆盖大部分场景,但有几种情况搞得很痛苦:
- 当前登录用户:顶栏要显示、页面要根据角色过滤、API 要带 token → 几乎每个组件都要
- 全局主题 / 语言:所有页面都要读
- 购物车 / 未读消息数:A 页面改了,B 页面要立刻感知
问题:这些数据不属于任何"父组件子树",没有天然的祖先可以 provide。硬用 provide/inject 就要把所有东西塞到根组件里,根组件爆炸。
Pinia 的解法:提供一个脱离组件树的全局容器。任何组件都能 useXxxStore() 拿到同一份状态。
Java 对照:Spring 的全局 Bean
@Service
@Scope("singleton") // 默认就是单例
public class UserService {
private User currentUser;
private Map<Long, Product> cart = new HashMap<>();
public void login(User u) { this.currentUser = u; }
public User getCurrentUser() { return currentUser; }
}
// 任何地方:
@Autowired
private UserService userService;
userService.getCurrentUser();
Pinia 就是这套机制的前端版:
| Spring | Pinia |
|---|---|
@Service 单例 Bean |
defineStore() 定义一个 store |
@Autowired 注入 |
useXxxStore() 在组件里取出 |
| Bean 的字段 | store 的 state |
| Bean 的方法 | store 的 actions |
| 响应式? | Spring Bean 不响应式;Pinia 的所有字段自动响应式 |
最大区别:Pinia 的 state 改了,所有读它的组件自动重渲染。这就是响应式状态管理的价值。
1. 最小可用的 store
in4vue 的 src/main.ts 已经注册了 Pinia:
import { createPinia } from 'pinia'
app.use(createPinia())
只需新建 store 文件:
// src/stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// state(类比 Bean 的字段)
const count = ref(0)
// getter(类比带缓存的 getter)
const double = computed(() => count.value * 2)
// action(类比 Bean 的方法)
function increment() { count.value++ }
function reset() { count.value = 0 }
return { count, double, increment, reset }
})
在组件里用:
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<p>{{ counter.count }}</p>
<p>double: {{ counter.double }}</p>
<button @click="counter.increment">+1</button>
</template>
任何组件都能 useCounterStore(),拿到的是同一个实例(单例),改一处所有地方自动更新。
2. 两种写法:setup 函数 vs options 对象
写法 1:组合式(setup 函数)—— 推荐
export const useUserStore = defineStore('user', () => {
const name = ref('游客')
const isLoggedIn = computed(() => name.value !== '游客')
function login(n: string) { name.value = n }
return { name, isLoggedIn, login }
})
优势:
- 和
<script setup>语法一致,心智统一 - 可以直接用
ref/computed/watch - 可以自由组合多个 composable
写法 2:选项式(options 对象)
export const useUserStore = defineStore('user', {
state: () => ({ name: '游客' }),
getters: {
isLoggedIn: (state) => state.name !== '游客',
},
actions: {
login(n: string) { this.name = n },
},
})
熟悉 Vuex 的人更亲切,但项目里全部用组合式写法。原因:
- 选项式的
this绑定容易搞错(特别是 arrow function 里) - 选项式不好组合多个 composable
- Vue 官方自己也在主推组合式
3. state:组件里怎么用
直接读写
<script setup>
const user = useUserStore()
console.log(user.name) // 读
user.name = '张三' // 直接改 state(不推荐,但能用)
</script>
能直接改 state 吗? 能。Pinia 没有 Vuex 的 mutation 强制。但不推荐:
- state 到处可写会让数据流不可追踪
- 复杂逻辑应该走 action,保持封装
惯例:state 通过 action 修改,组件只读不写。
解构会丢响应式(Pinia 的坑)
<script setup>
const user = useUserStore()
const { name } = user // ❌ 变成普通字符串,失去响应式
</script>
<template>
<p>{{ name }}</p> <!-- 不会更新 -->
</template>
解法:用 storeToRefs:
<script setup>
import { storeToRefs } from 'pinia'
const user = useUserStore()
const { name, isLoggedIn } = storeToRefs(user) // ✅ 保持响应式
const { login } = user // 方法直接解构,不需要 storeToRefs
</script>
规则:
- state / getters → 用
storeToRefs - actions(方法) → 直接解构
4. getters:派生值
getters 就是 store 里的 computed:
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0))
const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0))
return { items, total, count }
})
Java 对照:像 Bean 里的 @Cacheable 方法——结果被缓存,参数没变就不重算。
5. actions:状态变更
actions 就是 store 里的方法。可以是同步的,也可以是异步的。
export const useUserStore = defineStore('user', () => {
const currentUser = ref<User | null>(null)
const loading = ref(false)
async function login(email: string, password: string) {
loading.value = true
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})
currentUser.value = await res.json()
} finally {
loading.value = false
}
}
function logout() {
currentUser.value = null
}
return { currentUser, loading, login, logout }
})
关键点:
- action 里可以 await 异步操作(Vuex 的 mutation 不行,所以要拆成 action/mutation 两层,Pinia 没这种区分)
- action 里可以调其他 action、其他 store
- action 可以返回值(
await userStore.login(...)拿结果)
6. store 之间互调
export const useOrderStore = defineStore('order', () => {
const userStore = useUserStore() // 在 store 里调另一个 store
async function placeOrder(items: Item[]) {
if (!userStore.currentUser) throw new Error('未登录')
// ...
}
return { placeOrder }
})
Java 对照:@Autowired 注入另一个 Service。
注意:useUserStore() 要在 action 里调(执行时),别写在 store 的顶层——此时 Pinia 可能还没初始化。
7. 选择 store 还是 composable?
两者都能"封装带状态的逻辑"。怎么选?
| 维度 | composable(useXxx) |
Pinia store |
|---|---|---|
| 状态归属 | 每次调用一份独立的 | 全局单例,所有调用共享 |
| 作用域 | 使用它的组件(及子树) | 整个应用 |
| DevTools | 不显示 | 专属面板 + 时间旅行 |
| 典型场景 | 独立组件的可复用逻辑(useMouse、useFetch) | 需要跨组件/页面共享的状态(用户、购物车) |
判断准则:
- "两个不相关的组件要看到同一份数据" → Pinia
- "不同组件各有一份独立状态,只是逻辑相同" → composable
反例判断:
// ❌ 不要用 Pinia 做"每个页面独立的搜索状态"
export const useSearchStore = defineStore('search', () => {
const query = ref('')
return { query }
})
// 问题:A 页面搜索"vue",切到 B 页面也显示"vue"——因为共享了
// ✅ 用 composable,每个页面独立
export function useSearch() {
const query = ref('')
return { query }
}
8. in4vue 可能用到 Pinia 的场景
场景 1:主题 store(暗色 / 亮色)
// src/stores/theme.ts
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const theme = ref<'light' | 'dark'>(
(localStorage.getItem('theme') as 'light' | 'dark') ?? 'light'
)
watch(theme, (v) => {
localStorage.setItem('theme', v)
document.documentElement.setAttribute('data-theme', v)
}, { immediate: true })
function toggle() {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
return { theme, toggle }
})
任何组件里:
<script setup>
import { useThemeStore } from '@/stores/theme'
import { storeToRefs } from 'pinia'
const themeStore = useThemeStore()
const { theme } = storeToRefs(themeStore)
</script>
<template>
<button @click="themeStore.toggle">
{{ theme === 'light' ? '切到暗色' : '切到亮色' }}
</button>
</template>
场景 2:AI 会话 store(全局会话历史)
// src/stores/aiChat.ts
export const useAiChatStore = defineStore('aiChat', () => {
const messages = ref<Message[]>([])
const sending = ref(false)
async function send(text: string) {
messages.value.push({ role: 'user', content: text })
sending.value = true
try {
const reply = await callAiApi(text)
messages.value.push({ role: 'assistant', content: reply })
} finally {
sending.value = false
}
}
function clear() { messages.value = [] }
return { messages, sending, send, clear }
})
用户从笔记详情页点"问 AI" → 跳到 /ai → 再切回详情页 → 再回 /ai:消息历史还在,因为 store 是全局单例。
场景 3:笔记元信息缓存(可选)
如果以后笔记从远程 API 拉,可以放 store 缓存:
export const useNotesStore = defineStore('notes', () => {
const list = ref<NoteListItem[] | null>(null)
async function ensureLoaded() {
if (list.value) return // 已加载过,不重复请求
list.value = await fetch('/api/notes').then(r => r.json())
}
return { list, ensureLoaded }
})
避免重复请求——任何组件调 ensureLoaded 都共享同一份数据。
9. DevTools
浏览器装 Vue DevTools 后,会多一个 "Pinia" 面板:
- 列出所有 store
- 实时查看 state / getters 的值
- 手动触发 action、修改 state
- 时间旅行:回放状态变更历史
这是选 Pinia 相对 composable 的一大优势——全局状态 debug 起来极方便。
10. 持久化:用 pinia-plugin-persistedstate
原生 Pinia 不持久化,刷新页面状态重置。安装插件:
pnpm add pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import persistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(persistedstate)
app.use(pinia)
在 store 里声明要持久化的字段:
export const useUserStore = defineStore('user', () => {
const token = ref('')
const name = ref('')
return { token, name }
}, {
persist: true, // 全部持久化到 localStorage
})
// 或精细控制
{
persist: {
key: 'my-user',
pick: ['token'], // 只持久化 token
storage: sessionStorage,
},
}
Java 对照:像把 Bean 的字段序列化到 Redis。下次应用启动时恢复。
11. 新手常见误区
误区 1:解构 store 忘了 storeToRefs
上面第 3 节讲过。state / getters 必须 storeToRefs,actions 直接解构。这是 Pinia 最常见的坑。
误区 2:把"局部状态"塞到 Pinia
每个页面的搜索框状态、分页游标这类不需要跨组件共享的状态,放 composable 或组件 ref 就行。塞 Pinia 不仅浪费,还制造隐性耦合。
误区 3:忘记 store 里的 this
选项式 actions 里用 this.xxx 访问 state,别用箭头函数(箭头函数的 this 不是 store):
actions: {
increment() { this.count++ }, // ✅
increment: () => { this.count++ }, // ❌ this 错
}
组合式写法里没这个问题——所以再强调一遍,优先用组合式。
误区 4:到处写 store.$patch
store.$patch({ count: 5, name: 'new' }) // 批量更新
store.$reset() // 重置到初始状态
这些是 Pinia 的"内置工具"。但99%的场景直接改 state 就够,别滥用 $patch。
速查表
// 定义 store(组合式,推荐)
export const useXxxStore = defineStore('xxx', () => {
const state = ref(...)
const getter = computed(...)
function action() { ... }
return { state, getter, action }
})
// 组件里用
const store = useXxxStore()
const { state, getter } = storeToRefs(store) // 保持响应式
store.action()
// 跨 store 调用
const userStore = useUserStore() // 在 action 里调
// 持久化
defineStore('xxx', setup, { persist: true })
小练习
- 新建
src/stores/theme.ts,按第 8 节写一个主题 store - 在
DefaultLayout.vue里加一个切换按钮,调themeStore.toggle - 打开 Vue DevTools 的 Pinia 面板,观察状态变化
- 刷新浏览器——主题状态是否保留?(
watch持久化到了 localStorage,所以会保留;但 store 本身是不持久化的,只是初始值从 localStorage 读) - 思考:如果改用
pinia-plugin-persistedstate,watch那段能不能删?(能,插件自动处理)
做完这步,你就用上了前端最主流的状态管理方案。