Pinia 状态管理:前端的全局单例 Service

为什么需要"全局状态"

Vue 组件通过 Props 向下 / Emit 向上 通信,provide / inject 跨层级传递。这两种能覆盖大部分场景,但有几种情况搞得很痛苦:

问题:这些数据不属于任何"父组件子树",没有天然的祖先可以 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 }
})

优势

写法 2:选项式(options 对象)

export const useUserStore = defineStore('user', {
  state: () => ({ name: '游客' }),
  getters: {
    isLoggedIn: (state) => state.name !== '游客',
  },
  actions: {
    login(n: string) { this.name = n },
  },
})

熟悉 Vuex 的人更亲切,但项目里全部用组合式写法。原因:


3. state:组件里怎么用

直接读写

<script setup>
const user = useUserStore()

console.log(user.name)     // 读
user.name = '张三'          // 直接改 state(不推荐,但能用)
</script>

能直接改 state 吗? 能。Pinia 没有 Vuex 的 mutation 强制。但不推荐

惯例: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>

规则


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

关键点


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 做"每个页面独立的搜索状态"
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" 面板:

这是选 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 })

小练习

  1. 新建 src/stores/theme.ts,按第 8 节写一个主题 store
  2. DefaultLayout.vue 里加一个切换按钮,调 themeStore.toggle
  3. 打开 Vue DevTools 的 Pinia 面板,观察状态变化
  4. 刷新浏览器——主题状态是否保留?(watch 持久化到了 localStorage,所以会保留;但 store 本身是不持久化的,只是初始值从 localStorage 读)
  5. 思考:如果改用 pinia-plugin-persistedstatewatch 那段能不能删?(能,插件自动处理)

做完这步,你就用上了前端最主流的状态管理方案


延伸阅读