用惯了 Vuex 的开发者切换到 uniapp 的 Vue3 模式后,大概率会犹豫:到底继续用 Vuex 还是换成 Pinia。我的建议很明确——如果项目还在早期,直接上 Pinia,它更轻、类型推断更友好,而且社区已经给出了可靠的持久化插件。但 uniapp 跨端特性会让存储介质变得复杂:H5 用 localStorage,小程序用 wx.setStorage,App 端还可能用 plus.storage。这篇文章会从搭建环境到完成一个完整的购物车功能,把 Pinia 在实践中可能踩的坑都过一遍。
一、项目初始化与 Pinia 安装
先用 HBuilder X 或 CLI 创建一个默认的 Vue3 模板项目。然后在项目根目录执行:
npm install pinia pinia-plugin-persistedstate
这里 pinia-plugin-persistedstate 是一个官方推荐的持久化插件,能自动把 store 数据写入本地存储,省去手动序列化的麻烦。官方文档提到它兼容 localStorage、sessionStorage,也可以通过自定义 serializer 适配其他存储。
在 main.js 里注册 Pinia:
import App from './App'
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPersistedstate from 'pinia-plugin-persistedstate'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
pinia.use(piniaPersistedstate)
app.use(pinia)
return {
app,
pinia
}
}
注意 uniapp 的入口文件不是直接创建 Vue 应用,而是通过 createSSRApp 暴露一个工厂函数。这个差异会导致一些“直接在 main.js 里挂载全局属性”的教程无法直接用,但 Pinia 的注册方式不受影响。
二、定义一个带持久化的购物车 Store
在项目根目录创建目录 uni_modules/cart/stores(我习惯把业务模块放到 uni_modules 里,方便复用),新建 cart.js:
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [], // 商品列表 { id, title, price, quantity, selected }
lastSyncTime: null,
}),
getters: {
totalCount: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
totalPrice: (state) => state.items
.filter(item => item.selected)
.reduce((sum, item) => sum + item.price * item.quantity, 0)
.toFixed(2),
selectedItems: (state) => state.items.filter(item => item.selected),
},
actions: {
addItem(product) {
const existing = this.items.find(item => item.id === product.id)
if (existing) {
existing.quantity++
} else {
this.items.push({
id: product.id,
title: product.title,
price: product.price,
image: product.image,
quantity: 1,
selected: true,
})
}
this.lastSyncTime = Date.now()
},
removeItem(id) {
const index = this.items.findIndex(item => item.id === id)
if (index > -1) {
this.items.splice(index, 1)
this.lastSyncTime = Date.now()
}
},
updateQuantity(id, quantity) {
const item = this.items.find(item => item.id === id)
if (item && quantity > 0) {
item.quantity = quantity
this.lastSyncTime = Date.now()
}
},
toggleSelect(id) {
const item = this.items.find(item => item.id === id)
if (item) {
item.selected = !item.selected
}
},
toggleAll(selected) {
this.items.forEach(item => { item.selected = selected })
},
clearCart() {
this.items = []
this.lastSyncTime = null
}
},
// 持久化配置
persist: {
storage: {
getItem: (key) => {
// 多端兼容读取
if (typeof uni !== 'undefined') {
return uni.getStorageSync(key)
}
return localStorage.getItem(key)
},
setItem: (key, value) => {
if (typeof uni !== 'undefined') {
uni.setStorageSync(key, value)
} else {
localStorage.setItem(key, value)
}
},
},
serializer: {
serialize: JSON.stringify,
deserialize: JSON.parse,
},
// 只持久化 items,不存时间戳(减少存储大小)
paths: ['items'],
}
})
这个 store 的 persist 配置是整个方案的核心。通过自定义 storage 对象,我们统一了 H5、小程序和 App 的存储入口:在 uniapp 环境中调用 uni.setStorageSync 和 uni.getStorageSync,否则降级到 localStorage。这样写的好处是即使是纯 Vue 项目里也可以复用同一套 store 代码。
三、页面中使用购物车
在 pages/cart/cart.vue 中引用 store:
<template>
<view class="cart">
<view v-if="cartStore.items.length === 0" class="empty">购物车是空的</view>
<view v-else>
<view class="cart-header">
<checkbox :checked="allSelected" @tap="toggleAll(!allSelected)">全选</checkbox>
<text class="total">合计:¥{{ cartStore.totalPrice }}</text>
<button size="mini" @tap="clearCart">清空</button>
</view>
<view class="item" v-for="item in cartStore.items" :key="item.id">
<checkbox :checked="item.selected" @tap="cartStore.toggleSelect(item.id)"></checkbox>
<image :src="item.image" mode="aspectFill"></image>
<view class="info">
<text>{{ item.title }}</text>
<text>¥{{ item.price }}</text>
<view class="quantity">
<button size="mini" @tap="cartStore.updateQuantity(item.id, item.quantity - 1)">-</button>
<text>{{ item.quantity }}</text>
<button size="mini" @tap="cartStore.updateQuantity(item.id, item.quantity + 1)">+</button>
</view>
</view>
<button size="mini" @tap="cartStore.removeItem(item.id)">删除</button>
</view>
</view>
</view>
</template>
<script setup>
import { useCartStore } from '@/uni_modules/cart/stores/cart'
import { computed } from 'vue'
const cartStore = useCartStore()
const allSelected = computed(() => {
return cartStore.items.length > 0 && cartStore.items.every(item => item.selected)
})
const toggleAll = (selected) => {
cartStore.toggleAll(selected)
}
const clearCart = () => {
uni.showModal({
title: '提示',
content: '确定清空购物车吗?',
success: (res) => {
if (res.confirm) cartStore.clearCart()
}
})
}
</script>
这里没有任何手动调用 save 或 load,因为持久化插件会在状态改变后自动写入存储,应用启动时自动读取。开发者只需要像操作普通对象一样修改 state。
四、解决跨端持久化的兼容性陷阱
- 小程序存储容量限制:单条数据不能超过 1MB,总存储不能超过 10MB。如果购物车数据量很大,建议只持久化必要字段(上面的
paths: ['items']已经做了裁剪),并把商品图片等大体积资源存到云端。 - App 端 plus.storage:uni 的
setStorageSync在 App 端内部会映射到plus.storage,完全兼容。如果没有 uni 对象,我们的自定义存储会回退到localStorage,这在本地开发 H5 时也能正常运行。 - 还原时类型丢失:JSON 序列化不能还原 Date 对象,所以
lastSyncTime我存的是时间戳数字,而非 Date 实例。在 getter 或使用时再转成 Date。 - 多标签页同步:H5 端如果打开了多个标签页,localStorage 的修改不会实时同步到其它标签页的 Pinia 实例。如果需要强同步,可以监听
storage事件,在事件回调中调用 store 的$patch方法更新状态。这部分不是每个项目都需要,根据实际场景决定。
五、从 Vuex 迁移到 Pinia 的实操对比
如果你的项目还在用 Vuex,迁移成本并不高。以购物车为例,原来的 Vuex module 可能长这样:
// Vuex 版本(旧)
const state = { items: [] }
const mutations = {
ADD_ITEM(state, product) { /* ... */ },
REMOVE_ITEM(state, id) { /* ... */ },
}
const actions = {
addToCart({ commit }, product) { commit('ADD_ITEM', product) },
}
在 Pinia 中,mutation 和 action 合并为 actions,直接通过 this 修改 state,不需要 commit。把 Vuex 的 state、getters、actions 复制到 Pinia 的对应位置,然后去掉 context 参数,改掉 state.xxx 为 this.xxx,一个模块就移完了。单文件量比原来少 30% 左右,而且 TypeScript 支持更好。
Vuex 的持久化插件 vuex-persistedstate 与 Pinia 插件用法类似,但后者允许在 store 内部配置 persist,不需要在入口文件统一配置,颗粒度更细。
六、性能与调试建议
- 避免频繁写存储:持久化插件默认在每次 state 变化时都序列化整个 store 写入存储。如果某个 store 更新频率很高(比如陀螺仪数据),应当将
persist设为false,或者只持久化一个计算后的摘要值。 - 利用 uni 的调试工具:在 HBuilder X 中可以在内置浏览器控制台直接查看 Pinia store 的状态,或者使用
uni.getStorageInfoSync()检查已存储的键和大小。 - 插件执行顺序:如果多个 store 互相引用,注意持久化插件只会在当前 store 完成修改后触发存储,不会导致死循环。但避免在 getter 里触发 action,可能引起堆积。
七、总结
Pinia 在 uniapp 项目里可以说完全替代了 Vuex,而且跨端持久化的定制方案很灵活。本文的购物车 store 代码可以直接用到实际项目中,通过自定义 storage 抹平了多端差异。当下一个 uniapp 新项目启动时,建议从一开始就选择 Pinia,它会让你在管理全局状态时少写不少模板代码,同时持久化这件事也变成了一种声明式配置,顺手得不像话。

