状态管理这事儿,在纯web应用里已经被讨论得太多了,Vuex和Pinia怎么选、模块怎么拆、要不要用持久化插件,网上随便一搜都是一大把教程。可一旦把战场挪到uniapp里,情况就微妙起来了。你会发现同样一个用户登录状态,在H5上可能需要同步给所有打开的标签页,在小程序里却要老老实实走一遍本地缓存校验,到了App端又得考虑离线场景和原生插件的数据回传。刚开始我还硬着头皮想用一套代码全搞定,结果各种条件编译把自己绕晕了不说,测试的时候还频频翻车。
后来干脆静下心来,重新梳理了一下跨端状态管理真正的难点到底在哪里。结论是:共享的部分其实很薄,真正麻烦的是那些平台独有的行为。与其试图抹平差异,不如大大方方承认差异,然后用一套灵活的结构把它们收纳好。而Pinia的Setup语法配合uniapp的条件编译,恰好提供了一个挺舒服的切入点。
一、为什么不是Vuex,而是Pinia
这个问题其实老生常谈了。Vuex对Vue2的支持确实很完善,但在uniapp的Vue3版本里,Pinia的优势是压倒性的。首先是TypeScript支持,虽然uniapp项目里不一定都用TS,但类型推导带来的编辑器提示实在是香。更关键的一点是,Pinia可以写Setup风格的store,这让我们在组织跨端差异化逻辑时,能够直接用组合式API里那套响应式变量和函数,不用再去记mutation和action的分离。
举个例子,在Vuex里想实现一个根据平台不同返回不同数据的getter,写法大概是下面这样,嵌套在state、mutations、getters里,读起来有些绕:
// Vuex典型写法
const store = {
state: { platform: '' },
mutations: { SET_PLATFORM(state, val) { state.platform = val; } },
getters: {
welcomeText: (state) => {
// #ifdef H5
return '欢迎访问网页版';
// #endif
// #ifdef MP-WEIXIN
return '欢迎使用小程序';
// #endif
}
}
};
换成Pinia的Setup写法,感官上干净很多:
// Pinia Setup写法
export const useAppStore = defineStore('app', () => {
const platform = ref('');
const welcomeText = computed(() => {
// #ifdef H5
return '欢迎访问网页版';
// #endif
// #ifdef MP-WEIXIN
return '欢迎使用小程序';
// #endif
return '欢迎使用应用';
});
return { platform, welcomeText };
});
别小看这点书写上的差异,在一个需要频繁按平台调整逻辑的项目里,把状态、计算值、操作方法都平铺在setup函数里,可读性提升不止一点半点。
二、用Pinia Setup模式搭建可扩展的Store
先从一个最常见的场景切入:用户登录状态管理。这个store需要在不同端做以下几件不同的事情:
- H5端:登录成功后需要把token存到localStorage,并且监听storage事件,当其他标签页修改了token时,及时同步当前页面的登录状态。
- 微信小程序端:token存在wx.setStorageSync里,没有跨标签页问题,但要处理wx.login和wx.getUserProfile(已废弃,但依然有旧项目在用)的逻辑。
- App端:token存在plus.storage里,并且要考虑离线时使用缓存的用户信息,同时配合原生的第三方登录SDK。
面对这么一摊子需求,如果没有一个清晰的store框架,很容易在页面里散落无数条件判断。我的做法是定义一个统一的useAuthStore,内部利用条件编译分拆平台实现,但对外暴露同样的方法和响应式属性。
第一步:定义平台无关的Store接口
无论哪个端,外部页面都应该用同样的方式调用store。我们约定好store暴露以下内容:
token:当前登录凭证,响应式字符串。userInfo:用户信息对象。isLoggedIn:计算属性,根据token是否有效判断。login(credentials):登录方法,各端实现不同。logout():退出登录。checkSession():检查登录状态是否有效,App和小程序端需要这个。
第二步:实现H5端的Store逻辑
H5端的实现相对直接,利用localStorage和storage事件。Pinia的Setup函数里可以直接写事件监听。
// stores/auth.js (H5部分)
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('token') || '');
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'));
const isLoggedIn = computed(() => !!token.value);
// H5特有的:监听其他标签页的storage变化
// #ifdef H5
window.addEventListener('storage', (event) => {
if (event.key === 'token') {
token.value = event.newValue || '';
}
if (event.key === 'userInfo') {
userInfo.value = event.newValue ? JSON.parse(event.newValue) : null;
}
});
// #endif
function login(credentials) {
// 模拟登录请求
return new Promise((resolve) => {
setTimeout(() => {
const fakeToken = 'token_' + Date.now();
const fakeUser = { name: '用户', avatar: '' };
token.value = fakeToken;
userInfo.value = fakeUser;
// H5持久化到localStorage
// #ifdef H5
localStorage.setItem('token', fakeToken);
localStorage.setItem('userInfo', JSON.stringify(fakeUser));
// #endif
resolve({ token: fakeToken, user: fakeUser });
}, 600);
});
}
function logout() {
token.value = '';
userInfo.value = null;
// #ifdef H5
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
// #endif
}
function checkSession() {
// H5不需要主动检查,直接返回token是否存在
return !!token.value;
}
return { token, userInfo, isLoggedIn, login, logout, checkSession };
});
上面这段代码,H5特有的逻辑被条件编译包裹,当编译为小程序或App时,这些代码会被自动剔除。接下来补齐小程序和App的逻辑,同样用条件编译区分。
第三步:分别实现小程序和App的差异部分
微信小程序没有localStorage,用的是wx.setStorageSync,而且登录流程往往需要和wx.login结合获取code再交换后端token。App端则可能用到plus.storage以及第三方SDK。这些差异可以全部塞进同一个store里,通过条件编译让它们共存。
// 继续在 useAuthStore 的 setup 函数中编写
function login(credentials) {
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
wx.login({
success: (res) => {
// 用code去后端换取token(此处简化处理)
const fakeToken = 'mp_token_' + res.code;
token.value = fakeToken;
userInfo.value = { name: '微信用户', avatar: '' };
wx.setStorageSync('token', fakeToken);
wx.setStorageSync('userInfo', JSON.stringify(userInfo.value));
resolve({ token: fakeToken, user: userInfo.value });
},
fail: reject
});
});
// #endif
// #ifdef APP-PLUS
return new Promise((resolve) => {
// 示例:使用plus.storage
const fakeToken = 'app_token_' + Date.now();
token.value = fakeToken;
userInfo.value = { name: 'App用户', avatar: '' };
plus.storage.setItem('token', fakeToken);
plus.storage.setItem('userInfo', JSON.stringify(userInfo.value));
resolve({ token: fakeToken, user: userInfo.value });
});
// #endif
}
function checkSession() {
// #ifdef MP-WEIXIN
// 小程序检查本地存储的token是否存在
const savedToken = wx.getStorageSync('token');
if (savedToken) {
token.value = savedToken;
const savedUser = wx.getStorageSync('userInfo');
if (savedUser) userInfo.value = JSON.parse(savedUser);
return true;
}
return false;
// #endif
// #ifdef APP-PLUS
const savedToken = plus.storage.getItem('token');
if (savedToken) {
token.value = savedToken;
const savedUser = plus.storage.getItem('userInfo');
if (savedUser) userInfo.value = JSON.parse(savedUser);
return true;
}
return false;
// #endif
}
注意这里并没有把所有端的方法全部写死在一个函数里,而是用条件编译让每个端的实现互不干扰。编译时只会保留当前平台的代码,其它部分就像注释一样不存在。这样最终打包出的各端代码体积最小,逻辑也最清晰。
三、页面中的使用与注意细节
在页面里使用这个store就非常统一了,不必再关心底层到底走了哪个分支。
<template>
<view class="page">
<template v-if="auth.isLoggedIn">
<text>欢迎回来,{{ auth.userInfo.name }}</text>
<button @tap="handleLogout">退出登录</button>
</template>
<template v-else>
<button @tap="handleLogin">点击登录</button>
</template>
</view>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const handleLogin = async () => {
try {
await auth.login({ username: 'test', password: '123' });
} catch (e) {
console.error('登录失败', e);
}
};
const handleLogout = () => {
auth.logout();
};
</script>
这里有个容易被忽略的细节:在小程序端,checkSession方法需要在onLaunch或首页的onShow里主动调用一次,用来恢复本地存储的登录状态。H5端因为初始化时直接从localStorage读取了token,通常不需要额外调用。这个差异可以封装在store内部的一个初始化方法里,用条件编译决定是否执行。
// 在useAuthStore里增加一个init方法
function init() {
// #ifdef MP-WEIXIN || APP-PLUS
checkSession();
// #endif
}
// 返回时增加init
return { token, userInfo, isLoggedIn, login, logout, checkSession, init };
然后在App.vue的onLaunch里调用auth.init()就好了。H5端这个方法什么也不做,但接口统一。
四、跨Tab页同步状态——H5端的额外挑战
上文提到H5端用storage事件同步标签页,这个方案在绝大多数情况下工作良好,但有一个边界情况:如果用户在当前标签页登录,storage事件在自己这里不会触发,只有其它标签页会收到。这意味着同一个标签页内部的响应式同步靠的就是直接修改token.value,这没问题。但如果你在另一个标签页里退出登录,当前标签页的storage监听器才会把token清掉。
还有一点,storage事件只在其他标签页修改存储时触发,同源页面之间的消息广播也可以考虑用Broadcast Channel API,不过兼容性在小程序和App端就不用想了。H5里如果追求更实时的同步,可以在store里结合BroadcastChannel来补充。但我们这个场景,storage事件已经够用了,没必要过度设计。
// 可选:H5中同时使用BroadcastChannel增强同步
// #ifdef H5
if ('BroadcastChannel' in window) {
const channel = new BroadcastChannel('auth-sync');
channel.onmessage = (event) => {
if (event.data.type === 'logout') {
token.value = '';
userInfo.value = null;
}
};
// 在logout里发送消息
const originalLogout = logout;
logout = () => {
originalLogout();
channel.postMessage({ type: 'logout' });
};
}
// #endif
这种增强是可选的,不实现也完全不影响使用。
五、总结:跨端状态管理的核心思路
走完这一整套流程,会发现跨端状态管理其实没有那么玄乎。关键在于两条原则:
- 接口统一,实现隔离:对外部页面而言,store提供的方法和属性在各端完全相同。内部用条件编译把平台相关的实现藏起来,编译后的代码干净不臃肿。
- 差异化逻辑尽早收敛:不要等到页面里用一堆
if (platform === 'xxx')去判断,把所有差异都收口在store里,页面只做最简单的调用和展示。
Pinia的Setup语法让这个过程变得很顺手。每次需要针对某个平台写特殊逻辑时,直接用条件编译包裹几行代码就行,不用去改整体架构。这种灵活性,恰好踩中了跨端开发里最需要的那块地——既不让差异扩散到整个项目,也不因为过度抽象而增加理解成本。
下次再碰到那种“在这个端要这样,在那个端要那样”的需求,不妨先停下来想一下,这块逻辑能不能塞进store的条件编译块里。很多时候,把差异关进笼子,剩下的代码就自然清爽了。这套心法,比记住任何具体API都更有用。

