或 yarn add pinia / pnpm add pinia

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客
发布于 2026-09-25 19:55 ·81 浏览 ·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 / mapGettersstoreToRefs(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轻量论坛系统,原文地址:https://www.leleweb.cn/thread-592.html
转载请注明出处,版权归原作者所有。
他们都看过 3 人浏览过
ipzhCLARA轻量论坛系统做个坏人啦

全部回复 3

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 ipzh.com域名网 Lv1 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」当成「没登录」。