如果你在一个微信小程序项目里用 Pinia 做状态管理,顺手给 store 加上了 pinia-plugin-persistedstate,然后在开发者工具里看到一行localStorage is not defined,先别急着怀疑自己代码写错了。这个问题几乎每个在小程序里用 Pinia 持久化的人都会碰到,原因非常固定:小程序的 JS 运行环境里压根没有浏览器那一套 localStorage,而插件的默认存储实现偏偏指向它。今天这篇就把根因、解决思路和完整配置一次说透,顺便把序列化、容量、跨端兼容这些容易二次踩坑的细节也覆盖到。写这篇内容主要面向微信小程序开发者、用 Vue 3 + Pinia 做跨端项目的同学,以及任何在非浏览器环境下被这个报错卡住的人。
1. 报错现象:一行红字背后的真实情况
先还原现场。假设你的工程里已经正常安装了 pinia、pinia-plugin-persistedstate,入口文件里也按文档注册了插件,某个 store 配置了持久化。打开微信开发者工具,控制台出现类似这样的信息:
ReferenceError: localStorage is not defined at ...有些版本给的不是 ReferenceError,而是Cannot read properties of undefined (reading 'getItem'),或者直接提示storage is not supported。消息文字有差异,但本质都一样:插件在某个环节尝试访问 localStorage 这个“变量”,而当前运行环境里它根本不存在。
这里最容易让人困惑的地方在于:同样一套代码,在浏览器里调试时一切正常,状态能存、能读、能刷新恢复;一旦切到小程序模拟器或真机,就立刻报错。因为浏览器环境有window、有document、有localStorage,而小程序的 JS 运行环境是一个更精简的全局对象,上面只有wx之类的平台 API,既没有 DOM 也没有 BOM,localStorage 自然不存在。
接下来你会尝试各种搜索、各种改法,但只要没意识到“插件默认存储用的是浏览器专属 API”这个前提,换再多样式都是白搭。定位到这一步,问题其实已经解开一半了:不是 Pinia 的问题,不是 Vue 的问题,是插件默认存储选错了宿主环境。
2. 根因解读:插件为什么非要用 localStorage
pinia-plugin-persistedstate 的设计初衷是给 Web 端用的。它的默认存储就是浏览器提供的 localStorage:插件内部维护一套 storage 适配层,在未传自定义 storage 时,直接拿全局的localStorage.getItem、localStorage.setItem来读写。这套逻辑在浏览器里天经地义,但在小程序里就会触发前面说的 ReferenceError。
有个容易忽略的细节是,插件内部通常会用typeof window !== 'undefined'之类的判断来做环境探测,意图是“只有浏览器才有存储,其他环境就不启用”。在部分版本里,这种判断在小程序环境中也能通过第一步,因为有些小程序运行时存在类似global的全局对象,或者编译后的代码做了全局变量垫片,结果走到了访问localStorage的分支上,才真正炸掉。换句话说,不是插件不知道“小程序里没有 localStorage”,而是它的环境判断机制没法精确识别出“当前宿主连 window 都没有”。
小程序平台给开发者提供的替代方案是wx.getStorageSync/wx.setStorageSync这套同步缓存 API,以及对应的异步版本wx.getStorage/wx.setStorage。这套 API 的定位就是小程序版的本地存储:按 key 存字符串、对象、数字等,读取时按 key 取回。它还多了一些平台特性,比如可以通过wx.getStorageInfoSync()查看当前已用空间,通过wx.removeStorageSync(key)删除单条,通过wx.clearStorageSync()清空全部。
明白了这层关系,解决思路就非常直接了:给插件一个“长得像 localStorage 的替身”,把存储行为重定向到 wx 的同步缓存 API 上。插件底层的 storage 接口只认三个方法——getItem、setItem、removeItem,你只要实现这三个就够了。你可以粗略理解成:插件需要一个“兼容 localStorage 接口的后端”,小程序世界里这个后端就是 wx 缓存。
3. 解决核心:给插件一个兼容 wx 的自定义 storage
先看插件对 storage 的要求。以 v3 版本为例,createPersistedState 支持传入一个 storage 配置对象,它只需要符合下面的形状:
const storage = { getItem: (key) => wx.getStorageSync(key), setItem: (key, value) => wx.setStorageSync(key, value), removeItem: (key) => wx.removeStorageSync(key), }这里必须强调一下,要用同步版本 API,而不是异步的wx.getStorage/wx.setStorage。因为插件的持久化逻辑是同步的:store 初始化时同步读取缓存,然后通过 JSON.parse 恢复到 state 里;每次 state 变化时也是同步写入。异步 storage 会让时序变得不可控,还会引入回调地狱,接口也不匹配。
一个更稳妥的写法是把 wx 缓存返回值做一次归一化。wx.getStorageSync在 key 不存在时返回的是空字符串'',而浏览器 localStorage 在 key 不存在时返回null。插件的恢复逻辑通常会用“取到的值是否为真值”来判断要不要做 JSON.parse,空字符串本身是 falsy,一般不会出问题,但为了语义一致、避免某些版本在=== null判断上的细微差异,建议显式把空串转成 null:
// src/utils/storage.js export function createMiniProgramStorage() { return { getItem(key) { const value = wx.getStorageSync(key) return value === '' ? null : value }, setItem(key, value) { wx.setStorageSync(key, value) }, removeItem(key) { wx.removeStorageSync(key) }, } }封装成函数还有一个额外好处:如果你同一份代码需要同时构建 H5 版和小程序版,可以在这里做一次环境分支,让两个平台都能用:
export function createPlatformStorage() { if (typeof wx !== 'undefined' && typeof wx.setStorageSync === 'function') { return { getItem: (key) => { const value = wx.getStorageSync(key) return value === '' ? null : value }, setItem: (key, value) => wx.setStorageSync(key, value), removeItem: (key) => wx.removeStorageSync(key), } } // 浏览器环境回落到本地存储 return { getItem: (key) => localStorage.getItem(key), setItem: (key, value) => localStorage.setItem(key, value), removeItem: (key) => localStorage.removeItem(key), } }这种分支方式在跨端工程里很常见,避免了你为不同端写两套入口。到这里,核心问题已经解决了。接下来把完整配置落一遍。
4. 实操全流程:从依赖安装到持久化生效的配置清单
下面给出一套可以直接照抄的完整配置。先声明一下版本背景:pinia-plugin-persistedstate 的 v2 和 v3 API 变化不小,我会分别给出对应写法。不管你是老项目还是新项目,关键点是先确认自己装的是哪个大版本,再对号入座。
4.1 安装依赖并确认版本
npm install pinia pinia-plugin-persistedstate装完后最好看一眼 package.json 里实际的版本号。我见过不少人照着网上 v3 的写法,实际装的却是 v2,结果报一堆接口不存在的错。区分方法很简单:v3 的导出是createPersistedState,需要以工厂函数的方式注册;v2 的导出是默认的插件函数,直接传给pinia.use。
4.2 v3 推荐做法:注册时统一配置 storage
在入口文件里创建 Pinia 实例,把自定义 storage 作为全局配置传进去。这样后续所有 store 只需要写persist: true,不需要每个 store 重复粘贴 wx 缓存代码。
// main.js / app.js 入口 import { createPinia } from 'pinia' import { createPersistedState } from 'pinia-plugin-persistedstate' import { createMiniProgramStorage } from './utils/storage' const pinia = createPinia() pinia.use(createPersistedState({ storage: createMiniProgramStorage(), key: (id) => `pinia:${id}`, })) app.use(pinia)这里顺手改了一下 key 的生成规则,默认情况下插件用 store 的 id 作为缓存 key,诸如main、user这类短 id 在 wx 缓存里太没有辨识度,加个pinia:前缀便于调试时一眼认出。调用wx.getStorageInfoSync()排查缓存时会舒服很多。
配置好之后,在 store 里就可以这样写:
import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null, theme: 'light', }), persist: true, // 直接使用全局配置,无需再传 storage })这段代码跑起来之后,store 里的 token、userInfo、theme 三个字段会被整体序列化,写入pinia:user这个 key 下。修改任意一个字段,缓存都会同步更新;重新编译或冷启动小程序,页面读取时又会把这个 key 里的数据反序列化回 state。
4.3 v2 兼容写法:在 store 里逐个配置 storage
如果你的项目还是 v2,没有全局配置 storage 的入口,只能在每个需要持久化的 store 上手动指定。写法如下:
import { defineStore } from 'pinia' import { createMiniProgramStorage } from '../utils/storage' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null, theme: 'light', }), persist: { key: 'user-store', storage: createMiniProgramStorage(), }, })同样引入封装好的createMiniProgramStorage()。注意,v2 的persist: true不会自动套用 wx 缓存,它只会走默认的 localStorage,所以那种写法在小程序里必报错。这也是很多老项目明明按文档写了 true,却依然报localStorage is not defined的原因——文档写的是 Web 端示例,小程序里必须显式传 storage。如果你的项目能升级到 v3,我还是建议升上去,全局配置一次,比在每个 store 里复制三行 storage 对象要省心得多。
4.4 验证持久化是否真的生效
配置完之后别急着收工,花两分钟验证一下。
第一,在开发者工具的 Console 里执行:
wx.getStorageInfoSync()正常会在输出里看到一个 keys 数组,里面应该包含类似pinia:user这样的 key。如果有个别 store 没写入,检查那个 store 是否真的写了persist: true或者在 persist 对象里漏传了 storage。
第二,修改 store 里的某个字段,比如把 theme 改成'dark',然后再查一次缓存里的值:
wx.getStorageSync('pinia:user')应该能看到序列化后的 JSON 字符串,里面包含"theme":"dark"。这一步能确认写入链路是通的。
第三,把开发者工具重新编译,或者直接在模拟器里切后台再切回来,看 store 里 theme 是否还是 dark。如果是,说明读取恢复链路也通了。
5. 除了报错,还要注意的五个细节
解决了最初的报错,不代表后面就一劳永逸了。实际项目里,持久化还会在序列化、容量、字段过滤这些环节出幺蛾子。下面几点是我在真实项目里反复踩过的,逐个说清楚。
5.1 序列化:默认 JSON 能满足大部分场景,但有边界
插件的默认序列化器就是 JSON.stringify 和 JSON.parse。这意味着,state 里如果有 Date、Map、Set 这类非纯对象数据结构,序列化后会走样。Date 会被转成 ISO 字符串,恢复时是 string 而不是 Date 对象;Map 和 Set 会被转成空对象{},恢复时数据就丢了。如果你的 state 里有这类结构,要么在存之前做一次手动转换,要么自定义 serializer。
自定义序列化器在 v3 里的写法是:
pinia.use(createPersistedState({ storage: createMiniProgramStorage(), serializer: { serialize: (data) => JSON.stringify(data), deserialize: (raw) => JSON.parse(raw), }, }))默认情况下你不需要写这个配置,但知道它可以被覆盖,遇到特殊结构时就有出路。另外注意,JSON.stringify(undefined)返回的是undefined而不是字符串,如果某个响应式状态的顶层值本身就是 undefined,写入缓存时会出问题。好在小程序里一般不会把整个 state 设成 undefined,但如果你看到setStorageSync 写入失败之类的异常,可以往这个方向查一下。
5.2 小程序缓存空间是有限度的
wx 的同步缓存是有容量上限的,单条 key 能塞的数据量有限,整个小程序的总缓存空间也有限。如果你往 store 里塞了一整棵很大的数据树,或者把图片 base64 直接存在 state 里,很容易撞上上限,写入时直接抛错。这类错误的文案大多是 storage full、setStorage:fail 之类,控制台里能看到明显不是 localStorage 的报错。
这类问题有两个方向的处理:一是用 v3 的 pick / omit 只挑必要字段做持久化,把大体积数据排除在外;二是对确实需要缓存的大数据做分层处理,比如拆成多个 key,或者改成服务端保存、本地只存标识。开发阶段也可以用wx.getStorageInfoSync()盯一下当前占用,别到爆了才回头清理。
5.3 字段级别的选择性持久化
不是所有状态都该落盘。像临时弹窗状态、实时计算值、页面滚动位置这类数据,持久化反而碍事。v3 的写法是 pick 或 omit:
export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null, theme: 'light', tempDialogVisible: false, }), persist: { storage: createMiniProgramStorage(), pick: ['token', 'userInfo'], }, })这样只有 token 和 userInfo 会被写入缓存,theme 改了就改了,重启不还原;tempDialogVisible 更是完全不碰缓存。如果你的项目还在 v2,对应配置是paths: ['token', 'userInfo'],功能和 pick 一致,就是命名不同。这个细节在升级 v3 时最容易踩:把 v2 的 paths 原样搬到 v3,配置会被当成未知字段忽略掉,持久化范围就变成全量了。
5.4 key 命名:换 id 之前先想到旧缓存
默认情况下,插件存储 key 就是 store 的 id。比如useUserStore的 id 是'user',缓存 key 就是'user'。这里有个隐患:某天重构代码,把 store 的 id 从'user'改成'account',旧缓存'user'就成了一堆孤儿数据,新的'account'又会重新初始化。用户会发现“上次设置突然丢了”,虽然代码逻辑完全正确,但体验上就像 bug。
对策有两个。一个是像我前面写的,统一用 key 生成器加上前缀,就算跨 store 也不会互相覆盖;另一个是版本更新时主动写一段兼容逻辑:检测到旧 key 存在,把数据迁移到新 key 后删掉旧 key。小程序里做这种迁移很简单,就是 get 一次、set 一次、remove 一次。
5.5 跨端项目要小心环境分支
如果你的工程最终不只发布微信小程序一个端,storage 适配就不能写死。比如同一套代码可能还要跑在支付宝小程序、百度小程序,或者 H5 上。每个平台的缓存 API 长得大同小异,但前缀不同、行为略有差异,直接写死wx肯定不行。我上面给的createPlatformStorage()就是一种模式:优先检测当前端可用的同步缓存 API,再决定返回哪套实现。
这类跨端项目还有一个共同坑点:部分端在 H5 构建时,typeof localStorage是安全的,但直接访问localStorage.getItem时如果被某个 polyfill 覆盖,会返回 undefined 导致Cannot read properties of undefined。最好在 createPlatformStorage 里对每个方法都做存在性判断,而不是只判断外层变量。
6. 报错速查与排查顺序
最后整理一张速查表。这张表是根据我处理过的真实报错归纳出来的,按高频到低频排列,排错时从上往下查就行。
| 报错或表现 | 直接原因 | 处理办法 |
|---|---|---|
| ReferenceError: localStorage is not defined | 插件默认存储使用浏览器 localStorage,小程序环境没有 | 注册时传入自定义 storage,指向 wx.setStorageSync / getStorageSync |
| Cannot read properties of undefined (reading 'getItem') | storage 适配对象没有正确传入,或传入了 undefined | 检查插件版本是 v2 还是 v3,确认 storage 配置位于 createPersistedState 参数里或 store 的 persist 字段里 |
| 带 persist: true 依然报错 | v2 版本中 persist: true 不识别自定义 storage | v2 要写成 persist 对象并显式传 storage;或升级 v3 |
| setStorage:fail 或 storage full | 单条数据或总缓存超过 wx 限制 | 用 pick/omit 减少持久化字段,清理不再需要的 key |
| SyntaxError: Unexpected token 或 JSON parse 失败 | 缓存里的内容不是合法 JSON,可能被其他代码覆盖写入 | 手动 remove 对应 key,排查是否有别的逻辑往同一 key 写非 JSON 数据 |
| 刷新后状态没恢复 | store id 变更导致新旧 key 不一致 | 检查 key 生成规则,做一次缓存迁移或清理旧 key |
有几个排查时的高频心得再唠叨一下:
- 先在 Console 里用
wx.getStorageSync('你的key')手动查一次缓存,确认数据到底有没有写入。这一步能把问题从“读写环节”和“恢复环节”里快速分离。 - 用
wx.clearStorageSync()清空缓存再跑一次,能排除脏数据导致的解析报错。开发阶段频繁改动代码,缓存里存着旧结构的 JSON 很常见。 - 开发者工具的 Storage 面板可以直接看缓存内容,比在控制台一个个 get 效率高很多。真机调试时这个面板看不到真机数据,就用
wx.getStorageInfoSync()开个小调试菜单打出来。
我自己在实际项目里有一个体会:遇到奇怪的持久化问题,先别急着改业务代码,把 store id、插件版本、缓存里的原始内容这三样东西拉齐,排查时间能缩短一大半。很多所谓玄学报错,最后翻出来都是旧缓存字段不匹配、版本 API 没对齐这类“脏数据”问题。最后的建议是,如果你所在项目还在用 v2,尽早把 pick/omit 的差异和全局 storage 配置的迁移纳入待办,这行代码能帮你省下未来无数个 confirm 式的报错调试。