或 yarn add pinia / pnpm add pinia

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客
发布于 2026-09-25 19:55 ·3 浏览 ·3 回复

学完这篇你能得到什么:你会在一个 Vue 3 项目里从零跑通 Pinia,写出用户登录和购物车两个 store,并且清楚从 Vuex 迁移时哪些写法必须改。

第一步:先搞清楚两者差在哪

Pinia 是 Vue 官方现在推荐的 store 方案,Vuex 在 Vue 3 时代基本进入维护状态。核心区别只有四条:

  • 没有 `mutations`,只有 `state`、`getters`、`actions`,改 state 直接赋值或用 action。
  • 没有 `modules` 嵌套和 `namespaced`,一个 store 就是一个文件,天然扁平。
  • TypeScript 类型推断几乎不用手写,`state` 里的字段自动推导。
  • 体积极小,API 少,学习成本比 Vuex 低一档。

搞清楚这四条,后面的代码你基本能猜到怎么写。

第二步:安装并注册

npm install pinia

然后在入口文件注册:

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

const app = createApp(App)
app.use(createPinia())   // 先装 Pinia
app.use(router)          // 再装路由
app.mount('#app')

注意:`app.use(createPinia())` 必须早于任何 `useXxxStore()` 的调用。如果你在路由守卫里用了 store,务必把 Pinia 写在 `use(router)` 之前,否则会报 `getActivePinia was called with no active Pinia`。

注意:还在 Vue 2 项目上请装 Pinia 2.x;Pinia 3.x 只支持 Vue 3。

第三步:定义第一个 Store

在 `src/stores/` 下建文件,推荐用选项式写法,结构最清晰:

// src/stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    profile: null,
    cart: []
  }),
  getters: {
    isLogin: (state) => !!state.token,
    cartCount: (state) => state.cart.reduce((n, i) => n + i.qty, 0),
    // 需要用到其他 getter 时用普通函数 + this,不要用箭头函数
    cartTotal() {
      return this.cart.reduce((s, i) => s + i.price * i.qty, 0)
    }
  },
  actions: {
    async login(username, password) {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, password })
      })
      const data = await res.json()
      this.token = data.token
      this.profile = data.user
    },
    logout() {
      this.$reset()   // 选项式 store 自带的复位方法
    }
  }
})

喜欢组合式 API 的话可以这样写,效果等价:

export const useUserStore = defineStore('user', () => {
  const token = ref('')
  const isLogin = computed(() => !!token.value)
  async function login(username, password) { /* 同上 */ }
  return { token, isLogin, login }
})

注意:组合式写法没有 `$reset`,需要自己写一个 `reset()` 函数手动还原,并在 `return` 里导出。

第四步:在组件里读和改

<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
const { isLogin, cartCount } = storeToRefs(userStore)  // state / getter 用这个拆
const { login, logout } = userStore                    // action 直接解构没问题

// 改 state:直接赋值
userStore.token = 'abc123'
// 批量改:$patch
userStore.$patch({ token: 'abc', profile: { name: '阿乐' } })
</script>

<template>
  <p v-if="isLogin">购物车共 {{ cartCount }} 件</p>
</template>

注意:`const { token } = userStore` 会丢掉响应性,模板不会更新。拆 state 和 getter 一律用 `storeToRefs`;只有 action 可以直接解构,因为它是绑定过的函数。

第五步:跨 store 调用与持久化

在 action 里可以随意调用别的 store,不需要像 Vuex 那样 `dispatch('模块名/方法')`:

import { useCartStore } from './cart'

actions: {
  async checkout() {
    const cart = useCartStore()
    await cart.submit(this.token)
  }
}

想刷新页面不丢登录态,装个持久化插件:

npm i pinia-plugin-persistedstate
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)

// store 里加一行
persist: true

组合式写法把 `{ persist: true }` 放在 `defineStore` 的第三个参数。

注意:`persist: true` 会把整个 store 存进 localStorage,token 会明文可见。真上生产建议写 `persist: { pick: ['token'] }` 只挑需要的字段。

第六步:从 Vuex 迁移对照

VuexPinia
`mutations`删除,直接赋值或写进 action
`commit('xx')`直接调用 store 上的方法
`dispatch('xx')`直接调用 action
`modules` + `namespaced`一个文件一个 `defineStore`
`mapState` / `mapGetters``storeToRefs(store)`
`mapActions`直接解构 action

迁移时最容易漏的是 `commit`:原来的 commit 全部要删掉,同步逻辑直接写在原 mutation 里就成了 action 的一部分。

注意:Pinia 的 store id 必须全局唯一(`defineStore('user', ...)` 的 `'user'`),两个文件写同一个 id 会互相覆盖,调试时很难发现。

小结

  • Pinia 是 Vue 官方推荐的 store 方案,没有 mutations、没有 modules,一个文件一个 store。
  • 注册顺序不能错:`app.use(createPinia())` 要在路由和任何 `useStore()` 之前。
  • 拆 state/getter 用 `storeToRefs`,拆 action 直接解构即可。
  • `$reset` 只有选项式 store 自带,组合式写法要手写 reset。
  • 持久化用 `pinia-plugin-persistedstate`,但别整包存,token 之类按需 `pick`。
本文转载自 Clara轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址:https://www.leleweb.cn/thread-592.html
转载请注明出处,版权归原作者所有。

全部回复 3

ipzh
ipzh 正式会员正式会员认证极客认证极客 1楼 2026-09-25 20:01

Pinia 最容易踩的坑不是语法,是解构丢响应式——凡是 `const { token } = useUserStore()` 这种写法,token 都会变成一次性快照,得用 `storeToRefs` 包一层。

补几个你后面大概率会用到的点:

解构规则:`storeToRefs(store)` 拿 state 和 getters 保持响应式,actions 本身就是函数,直接解构没问题,别一起塞进 storeToRefs,会报警告。

跨 store 调用:在 action 内部再 `const cart = useCartStore()`,不要写在文件顶部。顶部调用会因为模块加载早于 `createPinia()` 触发 "no active Pinia",这个和你第二步提醒的路由守卫是同一类问题。

持久化:`pinia-plugin-persistedstate` 一把梭确实方便,但 token 建议放 cookie(httpOnly 更好)或自己封装 storage,localStorage 里的 token 一旦 XSS 就是裸奔。

从 Vuex 迁过来的硬改点:`mutations` 全删、把 commit 换成直接赋值;`mapState/mapGetters/mapActions` 换成 storeToRefs + 直接调用;`namespaced` 和 `rootState` 都不需要了,直接 import 另一个 store;`dispatch` 带 type 字符串的写法全部改成方法调用。基本是体力活,逻辑不用动。

HMR:store 文件末尾加 `import.meta.hot && import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot))`,改 store 不用整页刷新,调 state 时很省事。

顺带一提,你的 `fetch(` 那里断了,后面如果是封装 axios,建议在 action 里 try/catch 后统一 `throw`,让组件层决定怎么提示,别在 store 里直接弹 toast。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #154 2楼 2026-09-25 20:06
ipzh:Pinia 最容易踩的坑不是语法,是解构丢响应式——凡是 `const { token } = useUserStore()` 这种写法,token 都会变成一…

ipzh 这几条基本把 Pinia 的实战坑点扫干净了,尤其「解构丢响应式」和「跨 store 顶部调用」这两个,比语法错更常见。

顺着补两个细节,都是我踩过的:

storeToRefs 不是所有解构都要用。 只有当你在 `<script setup>` 里解构 state/getters 才需要它;如果在模板里直接写 `store.token`,或者组件里从头到尾都用 `store.xxx` 访问,根本不会丢响应式——很多人一看文档就到处 `storeToRefs`,反而把代码写臃肿了。判断标准就一句:有没有把 state/getters 拆成一个独立变量,拆了就得包。

`$reset()` 有陷阱。 选项式 store 自带 `$reset()`,一键回到初始 state;但如果你用的是 setup(组合式)写法,官方没提供 `$reset()`,得自己写一个。所以我倾向于:需要频繁重置的 store(比如表单、购物车)用选项式,逻辑复杂的用 setup 式,别为了统一风格硬吃。

持久化建议按字段白名单。 `pinia-plugin-persistedstate` 默认是整块 state 落 localStorage,`persist: { paths: ['token', 'cart'] }` 这样只持久化该存的,profile 这类带用户敏感信息的别一起塞进去,跟你说的 token 放 cookie 是一个思路。

至于 `fetch(` 断掉那里,同意你的方案:action 只负责拿数据、抛异常,toast 交给组件层,store 不该知道 UI 长什么样。这样同一个 action 在页面、弹窗、定时任务里都能复用。

zero
zero 见习用户见习用户 #155 3楼 2026-09-25 20:12
一只肉包:ipzh 这几条基本把 Pinia 的实战坑点扫干净了,尤其「解构丢响应式」和「跨 store 顶部调用」这两个,比语法错更常见。 顺着补两个细节,都是我踩过…

同意,「先判断有没有把 state/getters 拆成独立变量」这一条比背 API 有用得多,很多项目里 `storeToRefs` 满天飞就是这么来的。补几个跟你这几条咬得比较紧的点:

setup store 的 `$reset` 别用 `$state` 整体替换去糊。 网上常见的写法是 `store.$state = initialState`,选项式没问题,setup 式里等于把响应式对象整个换掉,内部 ref 的绑定关系容易脱钩,值看着对但更新不触发。稳妥就两条路:显式写一个 `function $reset()` 逐个赋值,或者用 `$patch` 传对象/函数(`store.$patch(s => { s.cart = [] })`),它走的是合并而不是替换。

`storeToRefs` 解出来的 getter 是只读 computed。 你解构 `cartCount` 拿到的是个只读 ref,`cartCount.value++` 会警告,改状态必须回到 action 或直接写 state,这点在从 Vuex 迁移时特别容易混——Vuex 那边 `mapGetters` 也是只读,习惯保住就行。

持久化白名单还有个副作用是防脏数据。 只 `pick` 该存的字段,顺带把「改了接口字段、老 localStorage 里还留着旧结构」这类问题挡在门外。另外注意 `pinia-plugin-persistedstate` v4 之后配置项从 `paths` 改成了 `pick`,照老博客抄可能静默不生效,升级后持久化突然失效先看这个。

一个延伸坑:持久化是异步恢复的,路由守卫里刚 `useUserStore()` 读到的 `token` 可能还是初始空值,导致刷新页面被误踢到登录页。要么用插件的 `afterRestore` 钩子,要么在守卫里等 store 就绪再判断,别把「没 hydrate」当成「没登录」。