前阵子在开发一个生鲜商城的多端应用时(微信小程序+H5+App),购物车状态同步把人折磨得够呛。用户在H5加了商品,切到小程序购物车却是空的;反过来在某些页面改了数量,另一个页面还显示旧数据。一开始我们用了事件总线加全局变量拼凑,可代码一多就成了一团乱麻。直到把状态管理彻底迁移到 Pinia,并配合自动持久化插件,各种诡异问题才消停。
这篇文章就结合这个购物车的实际案例,把如何在 uniapp 中从零集成 Pinia、设计 store、处理跨端持久化差异、以及使用组合式 API 优雅消费状态的全过程完整写出来。所有代码都能在 uniapp 项目中直接跑起来。
为什么用 Pinia 而不是 Vuex?
Vuex 曾是 Vue 生态的标配,但在 Vue 3 的组合式 API 面前,它显得有些笨重:类型推断不友好、模块划分机械、响应式依赖不可预测。Pinia 本来就是为 Vue 3 设计的,写法上和组合式 API 一脉相承,而且它极其轻量(压缩后不到 2KB)。在 uniapp 这种对包体积敏感的环境中,Pinia 几乎是更优选择。更重要的是,Pinia 的 devtools 支持很完善,调试多页面状态直观极了。
第一步:在 uniapp 项目中安装 Pinia
先确保你的 uniapp 项目已经切换到 Vue 3 模式(在 manifest.json 中设置 “vueVersion”: “3”)。然后在项目根目录执行:
npm install pinia
接着在 main.js 中挂载 Pinia:
import { createSSRApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
export function createApp() {
const app = createSSRApp(App);
const pinia = createPinia();
app.use(pinia);
return { app, pinia };
}
注意 uniapp 的入口遵循 createSSRApp 规范,这里必须返回 pinia 实例以保证服务端渲染一致性(尽管小程序没有真正的 SSR,但遵循规范可以避免组件内使用 useStore() 时实例未注入的问题)。
第二步:设计购物车 Store
在项目根目录创建 store/cart.js,我们用组合式 API 的方式定义 store(这也是 Pinia 推荐的方式):
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
// 购物车列表,每一项包含 id, name, price, count, image
const items = ref([]);
// 总价
const totalPrice = computed(() => {
return items.value.reduce((sum, item) => sum + item.price * item.count, 0);
});
// 总数量
const totalCount = computed(() => {
return items.value.reduce((sum, item) => sum + item.count, 0);
});
// 添加商品
function addItem(product) {
const existing = items.value.find(item => item.id === product.id);
if (existing) {
existing.count += product.count || 1;
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
count: product.count || 1,
});
}
}
// 移除商品
function removeItem(productId) {
const index = items.value.findIndex(item => item.id === productId);
if (index > -1) {
items.value.splice(index, 1);
}
}
// 更新商品数量
function updateCount(productId, count) {
const item = items.value.find(item => item.id === productId);
if (item) {
item.count = count;
if (item.count <= 0) {
removeItem(productId);
}
}
}
// 清空购物车
function clearCart() {
items.value = [];
}
return { items, totalPrice, totalCount, addItem, removeItem, updateCount, clearCart };
});
这里利用了 ref 存储数组,computed 派生计价信息,所有修改方法都直接暴露。Pinia 的 store 在调用 useCartStore() 时自动创建单例,多页面共享完全不用担心。
第三步:在页面/组件中使用 Store
在商品详情页添加“加入购物车”按钮:
<template>
<view>
<!-- 商品信息展示 -->
<button @click="addToCart">加入购物车</button>
<!-- 购物车图标显示数量 -->
<view class="cart-badge" v-if="cart.totalCount">{{ cart.totalCount }}</view>
</template>
<script setup>
import { useCartStore } from '@/store/cart';
import { reactive } from 'vue';
const cart = useCartStore();
const product = reactive({
id: 1001,
name: '进口车厘子',
price: 59.9,
image: 'https://xxx.png',
count: 1
});
function addToCart() {
cart.addItem({ ...product });
uni.showToast({ title: '已加入购物车', icon: 'success' });
}
</script>
在购物车页面展示列表和总价:
<template>
<view>
<view v-for="item in cart.items" :key="item.id" class="cart-item">
<image :src="item.image" />
<text>{{ item.name }}</text>
<text>¥{{ item.price }}</text>
<stepper :value="item.count" @change="(val) => cart.updateCount(item.id, val)" />
</view>
<view class="footer">
<text>合计:¥{{ cart.totalPrice }}</text>
<button @click="checkout">去结算</button>
</view>
</view>
</template>
<script setup>
import { useCartStore } from '@/store/cart';
const cart = useCartStore();
function checkout() {
// 跳转结算页
}
</script>
至此,购物车数据在所有页面已经保持同步了。但这里有个关键问题:刷新页面或关闭小程序后重新打开,购物车会被清空。这就要求我们进行状态持久化。
第四步:跨端持久化——核心避坑环节
我们知道在小程序里不能用 localStorage,必须用 uni.setStorageSync。Pinia 有一个非常方便的插件机制,可以编写一个自定义持久化插件来适配 uniapp。
在项目中创建 plugins/piniaPersist.js:
import { watch } from 'vue';
export function createPersistPlugin({ storageKey = 'pinia' } = {}) {
return ({ store, options }) => {
// 只持久化标记了 persist: true 的 store
if (!options.persist) return;
const key = `${storageKey}_${store.$id}`;
// 初始化时从 storage 恢复
try {
const saved = uni.getStorageSync(key);
if (saved) {
store.$patch(JSON.parse(saved));
}
} catch (err) {
console.error('恢复状态失败', err);
}
// 每次状态变化后存入 storage
watch(
() => store.$state,
(state) => {
try {
uni.setStorageSync(key, JSON.stringify(state));
} catch (err) {
console.error('持久化状态失败', err);
}
},
{ deep: true }
);
};
}
在 main.js 中启用插件:
import { createPinia } from 'pinia';
import { createPersistPlugin } from './plugins/piniaPersist';
const pinia = createPinia();
pinia.use(createPersistPlugin({ storageKey: 'my_app' }));
然后修改 store/cart.js,给 store 添加 persist: true 选项:
export const useCartStore = defineStore('cart', () => {
// ... 同前
}, {
persist: true // 启用持久化
});
这样,无论是小程序杀进程后重新打开,还是 H5 刷新页面,购物车状态都会恢复如初。需要特别注意的是,uni.getStorageSync 在各端的存储容量限制不同(小程序一般10MB),如果购物车图片存的是 base64 可能会超标,所以要只存储图片链接,不要存储文件数据。
第五步:处理 H5 和小程序在持久化上的不同步问题
一种常见情况:用户在微信内置浏览器(H5)添加了商品,然后打开小程序,期望购物车已同步。这种“跨端同步”并不能仅仅靠本地持久化完成,它需要后端配合。但是我们的插件架构已经预留了扩展点:可以在恢复状态前向服务器请求一次最新的购物车数据,然后合并。这里我们不过多拓展,但插件可以很容易改造为“先读本地,再读远程,合并后写入本地”的逻辑,保持接口一致。
遇到的坑和应对
- Pinia 实例在 App.vue 之外的组件中未注入:一定要在
createApp函数中返回 pinia,否则在 uni-app 的某些生命周期中可能出现未初始化的情况。 - 插件中 watch 的 deep 选项不要遗漏:购物车数组内的属性变化需要深度监听,否则修改
item.count不会触发持久化。 - 存储对象序列化:
uni.setStorageSync会自动处理序列化,但为了控制存储格式,我们显式用JSON.stringify,防止 undefined 值等异常。 - 在非响应式环境中使用 store:比如在
onLoad等选项中,需要先获取 store,直接用const cart = useCartStore(),store 是单例,不用担心。
性能优化:谨慎使用 getter 和 computed
由于 Pinia 的响应式系统基于 Vue 3 的 reactivity,在计算 totalPrice 时每次都遍历数组,如果购物车条目成百上千可能会有点卡。实际购物车很少超过几十项,所以不成问题。但若担心中,可以用一个 ref 手动维护总价,在每次增删改时更新,避免依赖 computed。
总结
用 Pinia 管理 uniapp 的全局购物车,不仅让代码结构变得清晰,也让多端状态同步有了稳定根基。相比之前的全局变量加事件通信,Pinia 带来了响应式、类型安全和插件生态的三重红利。通过自定义持久化插件,我们轻松解决了小程序和 H5 的存储差异,而组合式 API 又让状态消费宛如直接操作普通对象。
如果你的 uniapp 项目还在纠结状态管理方案,或者正被多页跳转数据丢失折磨,不妨用半天时间把核心状态迁移到 Pinia 上,相信那种“改完一个地方,所有页面自动刷新”的秩序感会让你如释重负。

