1. 问题现场还原:一个看似简单的报错,卡住了整个小程序
先说结论:这个报错十有八九不是 pinia-plugin-persistedstate 本身写错了,而是小程序环境和浏览器环境的存储机制有差异导致的。说白了,这个插件默认是为浏览器里的 localStorage 设计的,而微信小程序里根本没有 localStorage 这个全局对象,自然一运行就报错。
我最初接手某跨平台系统的小程序端改造时,也踩过这个坑。当时场景是这样的:项目原本是 H5 应用,用的是 Vue 3 + Pinia,为了省事,团队直接在小程序端复用了 H5 那套状态管理代码,其中就包括 pinia-plugin-persistedstate。结果小程序一启动,控制台就抛错,白屏,报错信息直指 localStorage。一开始我还以为是插件版本问题,或者某个 polyfill 没装,排查了半天,最后才发现是环境差异这个根本原因。
这个问题的本质在于:微信小程序运行在一个阉割过的 JS 运行时里,它的全局对象是wx,不是window;存储方案是wx.getStorageSync和wx.setStorageSync,不是 localStorage;而且整个运行环境没有 DOM、没有 BOM,很多浏览器里理所当然的 API 在小程序里压根不存在。pinia-plugin-persistedstate 这个插件底层写死了localStorage这个全局变量,在小程序运行时里一执行,自然直接报错。
如果你正好也遇到了这个问题,别慌,这属于“环境适配”问题,不是“逻辑错误”问题,解决思路很清晰:
- 搞清楚插件的持久化原理和配置方式。
- 给插件提供小程序环境下的 storage adapter。
- 或者干脆换一个支持多端的小程序专用持久化方案。
接下来我把完整思路、实操代码和踩坑记录都整理出来,按顺序看完基本就能解决。
2. 理解核心:pinia-plugin-persistedstate 到底是怎么工作的
2.1 插件原理拆解
pinia-plugin-persistedstate 的核心理念非常简单:在你每次修改 Pinia store 状态后,自动把整个 state(或者你指定的部分 state)序列化成 JSON,然后写入某个 storage 里;下次应用启动的时候,再从这个 storage 里读出来,反序列化并合并回 store 的初始 state。
它的默认 storage 就是window.localStorage。如果你不传任何参数,它内部会执行类似这样的逻辑:
storage.getItem(key) storage.setItem(key, value)而在微信小程序环境里,window不存在,localStorage自然也不存在。你可以自己试一下,在小程序代码里直接打印typeof window,大概率是undefined,打印typeof localStorage,直接报错。这个插件找不到 localStorage,就抛出了你看到的那条错误。
2.2 为什么小程序不能直接用 localStorage
微信小程序从诞生起,它的数据存储方案就和浏览器不一样,主要原因有两点:一是小程序运行在宿主 App 提供的 WebView 或自研渲染引擎里,没有完整的浏览器对象模型;二是微信需要保证存储的可控性、安全性和容量管理,所以自己封装了一套同步和异步的存储 API。
具体来说,小程序里常用的几个存储 API 是:
| API | 类型 | 说明 |
|---|---|---|
wx.setStorageSync(key, value) | 同步 | 同步写入,适合小数据量,简单粗暴 |
wx.setStorage(key, value) | 异步 | 异步写入,适合大一些的数据量 |
wx.getStorageSync(key) | 同步 | 同步读取,常用 |
wx.getStorage(key) | 异步 | 异步读取 |
wx.removeStorageSync(key) | 同步 | 同步删除 |
每个键值对的大小上限是 1MB,整个小程序本地存储的总上限是 10MB(不同版本可能略有差异)。而 localStorage 的浏览器标准实现,通常是每个域名 5MB 左右。虽然容量上限差不多,但 API 形态天差地别,所以 pinia-plugin-persistedstate 这种浏览器生态里的插件,到了小程序里就必须做一层适配。
2.3 关键结论:这个报错其实是“预期内”的
理解了原理之后,你会发现这个报错其实是“预期内”的,任何直接依赖浏览器全局对象的库,在小程序里都不会凭空工作。所以不要指望改个配置项就能让它自动兼容,你需要主动提供适配层,或者换一条路。
3. 三种主流解决方案实测对比
我实际试过好几种方案,各有优劣,下面按推荐程度排序给你分析。
3.1 方案一:给插件配置自定义 storage(最优解)
pinia-plugin-persistedstate 的设计者其实已经想到了扩展性问题,插件的配置项里支持自定义storage对象。你只需要传入一个具有getItem、setItem、removeItem方法的对象,让它内部调用你的小程序存储 API 即可。
这个方案的优势是:代码改动最小,保留 pinia-plugin-persistedstate 的所有能力,比如按 store 单独配置、路径选择、序列化自定义等。实现方式如下:
首先,新建一个storage.ts文件,做一个适配器:
// utils/storage.ts export const miniProgramStorage = { getItem: (key: string) => { const value = wx.getStorageSync(key) // 小程序存储的值本身就是任何类型,但插件期望字符串或 null return value === '' ? null : value }, setItem: (key: string, value: string) => { wx.setStorageSync(key, value) }, removeItem: (key: string) => { wx.removeStorageSync(key) } }然后在创建 Pinia 实例时传入:
// store/index.ts import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import { miniProgramStorage } from '../utils/storage' const pinia = createPinia() pinia.use(piniaPluginPersistedstate) // 全局默认配置 piniaPluginPersistedstate.defaultOptions = { storage: miniProgramStorage }如果你只想某几个 store 使用,可以在具体 store 里配置:
// store/user.ts export const useUserStore = defineStore('user', { state: () => ({ token: '', nickname: '' }), persist: { key: 'user-store', storage: miniProgramStorage, pick: ['token'] } })这里的核心技巧是:适配器里的getItem必须处理好''和null的区分。小程序wx.getStorageSync在键不存在时会返回空字符串'',但插件的内部逻辑可能是把null当作“没有缓存”来处理,如果你直接原样返回'',可能会导致插件把空字符串也当作有效值,进而出现合并状态的奇怪问题。实测下来,把空串转成null是最稳的。
3.2 方案二:自己手写一个简单的持久化插件(轻量灵活)
如果你不想引入任何额外的依赖,或者项目本身只用了一两个 store,也可以自己写一个极简插件。这个方案的好处是完全可控,坏处是轮子得自己维护,如果以后要支持复杂配置,工作量会变大。
实现思路是:在 Pinia 插件里,监听 store 的$subscribe事件,状态变化时同步写入 storage;初始化时读取 storage 并覆盖 state。
// plugins/persist.ts export function persistPlugin(options: { key: string; storage: any }) { return ({ store }: { store: any }) => { // 读取缓存 const cached = options.storage.getItem(options.key) if (cached) { try { store.$patch(JSON.parse(cached)) } catch (e) { // 如果 JSON 解析失败,忽略,用默认 state } } // 订阅状态变化 store.$subscribe((_mutation: any, state: any) => { options.storage.setItem(options.key, JSON.stringify(state)) }) } }然后使用:
import { createPinia } from 'pinia' import { persistPlugin } from '../plugins/persist' import { miniProgramStorage } from '../utils/storage' const pinia = createPinia() pinia.use(persistPlugin({ key: 'app-state', storage: miniProgramStorage }))但这里有几点要注意:$subscribe的触发时机、防抖策略、状态覆盖顺序,都需要你自己考虑清楚。比如不要在初始化读取完成之前触发写入,否则可能把你刚刚恢复的状态又被默认值覆盖回去。还有一个坑是:$subscribe默认监听所有 mutation,如果你在读取缓存后立即$patch,也会触发一次订阅,这时如果不加判断,又会把刚恢复的数据原样写回去,虽然结果一致,但多了一次无意义的写操作。我建议在恢复状态时,用一个标志位跳过下次写入。
3.3 方案三:改用支持多端的持久化方案(未来扩展性好)
如果你的项目不只做小程序,以后可能还要做 App 端,那直接换一个更通用的持久化库也可能是更好的选择。业界有一些专门为多端设计的存储库,底层自己封了各端的适配逻辑。
这里我不具体推荐某个库,因为技术迭代很快,而且不同项目的诉求差异很大,我更建议你花十分钟做一次选型评估。评估维度包括:
| 评估维度 | 说明 |
|---|---|
| 多端支持 | 是否内置了小程序、App、H5 的 storage 适配 |
| 体积大小 | 是否会影响小程序包体积 |
| 维护活跃度 | 是否有持续的社区维护和版本更新 |
| 与 Pinia 的集成方式 | 是插件形式还是需要手动接入 |
| 序列化能力 | 能否优雅处理嵌套对象、Date、Map 等特殊类型 |
这个方案更适合从零起步的新项目,或者有明确多端规划的项目。如果只是老项目修复,我建议优先用方案一。
4. 实操全过程:一步步解决并验证
4.1 步骤一:定位报错来源
当你看到报错信息时,第一件事不是急着改代码,而是先确定报错是从哪个文件抛出来的。打开开发者工具的 Console 面板,展开报错堆栈,找到第一行指向的代码位置。
我当时定位到的堆栈指向了插件源码里的createPersistedState函数,它内部有一行:
const storage = options.storage || window.localStorage问题就出在这个window.localStorage上。因为小程序环境没有window,所以执行到这里直接抛错。看到这一行,你就知道这个插件的默认策略是“没有自定义 storage 就回退到 window.localStorage”,所以在小程序环境里必须显式传入 storage 配置,绕不开。
4.2 步骤二:检查项目里是否还有其它浏览器环境依赖
在修复插件之前,先全局搜索一下项目里有没有别的浏览器对象依赖。比如:
window全局搜索document全局搜索localStorage全局搜索navigator全局搜索
我当时搜出来不少,主要分布在几个第三方库里。这类问题在小程序里很常见,你修复了一个报错,可能紧接着又会冒出来另一个,所以提前做一次“环境兼容性体检”很有必要。
4.3 步骤三:代码改造并验证
按照上面的方案一改造后,我在开发者工具里重新编译,报错消失,状态也能正常持久化。下图是核心改造点的检查清单:
| 检查点 | 预期结果 |
|---|---|
| 编译是否通过 | 无红色报错 |
| Console 是否干净 | 无 localStorage 相关报错 |
| Store 是否初始化成功 | 页面正常渲染 |
| 状态是否恢复 | 修改状态后重编译,能读到上一次的值 |
| 存储面板是否有数据 | 开发者工具 Storage 面板能看到对应 key |
这里特别提醒一点:请务必在真机上验证一次。开发者工具里的运行环境和真机有细微差异,比如基础库版本、WebView 内核行为、storage 同步策略等。有些环境差异只有在真机上才会暴露出来。因为小程序的存储 API 在工具和真机上的行为基本一致,但第三方库内部的某些兼容性判断可能不同,上真机验证才是最终标准。
4.4 步骤四:针对特殊场景的补充处理
如果状态里存的是复杂对象(比如数组嵌套、对象里带 Date 类型),还需要关注序列化问题。pinia-plugin-persistedstate 默认用的是JSON.stringify和JSON.parse,这两个方法会丢掉 Date 对象的类型信息,恢复出来的会变成字符串。如果你需要完整保留类型,可以在 store 配置里自定义serializer:
persist: { key: 'user-store', storage: miniProgramStorage, serializer: { serialize: (value: any) => JSON.stringify(value), deserialize: (value: string) => JSON.parse(value) } }不过坦白说,绝大多数业务场景下,JSON.stringify已经够用。只有当你状态里有Date、Map、Set这类特殊类型时,才需要额外处理。
5. 常见问题与排查技巧实录
我把这段时间陆续遇到的典型问题整理成速查表,如果你后面也遇到类似的情况,可以直接对照排查:
| 问题现象 | 可能原因 | 解决建议 |
|---|---|---|
| 报错指向 window.localStorage | 未配置自定义 storage | 按方案一配置 miniProgramStorage |
| 状态恢复不了,页面是初始状态 | storage 的 getItem 返回了空串而不是 null | 把空串''转成null |
| 状态写入了,但页面刷一下就没了 | 初始化时马上被默认 state 覆盖 | 检查 store 定义顺序,确保读取逻辑先于写入逻辑 |
| 有些 store 希望持久化,有些不想 | 全局配置了 persist 默认值 | 使用 store 级别的persist: false或按需配置 |
| 偶尔出现 JSON 解析错误 | 存储值被截断或污染 | 在 deserialize 外包一层 try/catch |
| 编译后 Warning 提示 storage 不存在 | 插件底层检测到环境异常 | 忽略或确认自定义 storage 已全局生效 |
5.1 一个容易被忽视的细节:同步 API 和异步 API 的选择
我在适配器里用的是同步 APIwx.getStorageSync和wx.setStorageSync,原因是插件的 setItem 是同步调用链,如果你在里面用了异步 APIwx.setStorage,可能导致数据还没有写入完成就看不懂了。虽然异步 API 在数据量大的时候性能更友好,但在这种场景下,同步 API 的“确定性”更值钱。
5.2 性能小优化:避免频繁全量写入
如果你的 store 状态特别大,每次变化都全量写入其实是浪费的。pinia-plugin-persistedstate 支持配置paths,只持久化你指定的字段:
persist: { key: 'user-store', storage: miniProgramStorage, paths: ['token', 'userInfo.nickname'] }这种“按需持久化”的方式,尤其适合那些 state 里有临时 UI 状态(比如弹窗开关、滚动位置)但不想落地的场景。跳过节流和防抖的问题,这个方案简单有效。
5.3 排查时的一个独家技巧
如果你在排查过程中发现状态“偶尔能恢复,偶尔不能”,先别怀疑插件,先去开发者工具的 Storage 面板里看看实际落库的值。我遇到过一次诡异的现象,一个 store 怎么都恢复不了,看了半天代码也没问题,最后发现是同一个 key 被两个 store 同时使用,后一个初始化的 store 把前一个覆盖了。这种问题靠看代码很难发现,直接看存储面板反而一目了然。
6. 围绕这个小问题的扩展思考
解决了这个报错,我还想多说几句。这件事看起来只是一个很小的技术坑,但它背后反映出的问题值得每个前端开发者警惕:“你写的代码,跑在哪个环境里,你真的清楚吗?”
在 H5 环境里,我们习惯了浏览器提供的一切便利,window、document、localStorage、navigator随手就能用。但到了小程序,或者更极端的 Node.js 环境,这些约定俗成的全局对象全部不存在。很多开源库在浏览器环境里测试得很好,但换一个环境就“原形毕露”。
所以我的习惯是:在项目初期就明确运行环境清单,是纯 H5?还是 H5 + 小程序?还是 H5 + 小程序 + App?如果确定有多端需求,那么一开始就要选择支持多端的技术方案,而不是等代码写完了再去做环境适配。这种“环境适配成本”并不只是改几行代码那么简单,它可能牵扯到存储方案选型、UI 组件库选择、路由模式、构建配置,甚至影响到后端接口设计。
另外我还想补充一点:不要把第三方库当作黑盒。遇到报错的时候,花点时间去读一读插件的源码,理解它的设计思路和默认假设,往往能帮你更快定位问题。就像 pinia-plugin-persistedstate,它默认用window.localStorage,这是它的“合理默认值”,但到了小程序里就是致命伤。理解了这一层,你就不会纠结“为什么报错”,而是直接知道“我该怎么适配”。
最后分享一个实操习惯:每接入一个新的第三方库,我都会花 30 分钟浏览它的文档和源码,重点看三点:依赖了哪些全局对象、有没有环境判断逻辑、配置项都有哪些扩展点。这个习惯帮我避掉了大量“跑通了但某一天突然炸掉”的坑。
如果你也在做小程序 + Pinia 的技术改造,希望这篇整理能帮你省下一些排查时间。核心思路就一句话:别让浏览器专属 API 在小程序里裸奔,给它一层适配,世界就清净了。