你有没有遇到过这种情况:同一个小程序里,用户在首页把购物车里的商品数量改成了3,跳到结算页一看,数量还是1。更气人的是,在个人中心改了昵称,返回首页一看,还顶着旧名字。这种问题十有八九不是后端接口的问题,而是小程序端全局数据共享没有设计好。
做了这么多年小程序开发,我几乎每个项目都会遇到“全局数据到底放哪里”的争论。代码里一会儿getApp().globalData,一会儿wx.setStorageSync,再夸张一点的手动搞一套 EventBus。这些方案单独拎出来都行,但混在一起用,项目到后期基本就是一个行走的定时炸弹。这篇文章想聊聊我踩过那么多坑之后,对微信小程序全局数据共享这件事的完整理解,包括不同方案的边界、适用场景、选型判断标准,以及那些特别容易翻车的细节。
1. 先给“全局数据”下个准确定义:共享之前先分类
1.1 局部数据、页面状态与真正的全局数据
很多开发者一听到“全局数据共享”,下意识想到的就是“把数据从A页面传到B页面”。这其实是个误会。页面之间传参、组件之间通过 properties 通信、跨页面用eventChannel,这些解决的是“数据怎么流动”的问题,跟“全局数据共享”是两个层面的事。
我习惯把小程序里的数据按归属分成三层:
- 页面/组件局部数据:只在一个 Page 或 Component 内部使用,比如表单输入值、列表的展开状态,这类数据直接放在
data里,谁也别想抢。 - 跨组件/跨页面但生命周期短的数据:比如商品详情页选中了某个规格,跳转到订单确认页要用。这种情况直接用页面参数传递就好,或者用
eventChannel回传结果,完全不需要动“全局”这个概念。 - 全局共享数据:多个不相关的页面、Tab、组件都要读,而且数据一改,任何地方都希望看到最新的值。比如登录态、用户资料、购物车内容、消息未读数。
所以“全局数据共享”真正处理的,其实是第三层。而这一层里,又得按照数据的生命周期再往下拆,不然方案很容易选错。
1.2 会话级全局数据与持久级全局数据
同样是全局数据,生命周期完全不一样。
拿登录态来说,用户进入小程序,调wx.login拿到 code,后端换回 token,这个 token 在本次使用期间到处都要用。可是用户明天重新打开小程序,token 还必须有效,那就得存下来,否则每次启动都要重新登录一遍。这就是持久级。
再比如用户正在填写一篇文章的草稿,中途切到其他页面去上传图片,再切回来内容还在。这种草稿如果只存在内存里,小程序一被系统回收就丢了,肯定不行。所以它也是持久级。
那有没有会话级全局数据?有的。比如用户在首页筛选了某个条件,跳到列表页时你希望记住这个筛选条件,但用户退出再进来时,筛选条件重置反而更合理。这种数据可以放globalData,冷启动之后清空完全没问题。
判断一条全局数据到底属于哪一级,我一般就问自己一句话:小程序被冷启动之后,这次打开需不需要恢复它?需要,就是持久级;不需要,就是会话级。
1.3 小程序和网页在全局数据上的本质差异
做 Web 端久了的人切到小程序,很容易把过去那套思维带过来。在网页里,全局变量想怎么挂怎么挂,因为页面生命周期相对简单,刷新一下全局就重组了。可小程序不一样,它的页面栈是同时存在多个页面的,A 页面在onLoad的时候,B 页面可能还挂在栈里存活。这就带来一个 Web 端不常见的麻烦:数据什么时候被读取、什么时候被写入,完全不是线性的。
我见过不少框架:
页面 A 的onLoad读了一次globalData.userInfo,拿到 null,然后页面 B 登录成功把userInfo写进去了,A 页面还傻傻显示着“请先登录”。这种“读早半拍、写晚半拍”的时序问题,是全局数据共享里最隐蔽也最难查的一类 Bug,后面我会单独拿出来说。
2. globalData 的朴素用法,以及它撞墙的三个时刻
2.1 最传统的用法:把整个 App 实例当做一个“全局大背包”
globalData是所有新手最先接触的方案。它的用法简单到不能再简单:
// app.js App({ globalData: { userInfo: null, cartCount: 0 } });在任何页面里,通过getApp()就能拿到整个 App 实例,然后读写上面的globalData:
// pages/index/index.js const app = getApp(); Page({ onLoad() { this.setData({ cartCount: app.globalData.cartCount }); }, addToCart() { app.globalData.cartCount += 1; this.setData({ cartCount: app.globalData.cartCount }); } });这套方案毫无学习成本,读写的直觉跟操作普通 JS 对象完全一样。小程序官方文档里也提到了globalData,所以它是被官方认可的基础方案。对于全局数据不超过两三个、又只在启动时可以一次性确定的值,比如从后端配置拉来的全局支付参数,这个方案完全够用。
2.2 撞墙时刻一:数据改了,页面根本不知道
globalData最大的问题,用一句话概括就是:它是一个“没有广播能力”的仓库。
什么意思?你在一个地方改了app.globalData.cartCount,虽然值确实变了,但正在屏幕上的其他页面并不会收到任何通知。页面里的data.cartCount还是旧值,只有当这个页面下次主动去getApp().globalData再读一次,UI 才会更新。
拿具体的购物车场景来说:
- 结算页里用户点击了“清空购物车”,代码把
app.globalData.cartList = []清空了 - 底部的 TabBar 页面,购物车角标还是当初读到的数量
- 除非用户在 Tab 之间切换触发了
onShow,然后页面再次读取,角标才变回来
这就很尴尬。用户明明已经清空了,角标还挂着“3”,这显得很不专业。为了修复,你只能手动在onShow里重新读一次,或者干脆哪里改了数据就给相关页面发通知。久而久之,全局数据的每个写入点都散落着“通知别人”的代码,改一次数据要同步改好几个地方。
2.3 撞墙时刻二:启动阶段的数据永远在“还没准备好”
App()里的onLaunch适合做一些初始化,但很多人没意识到,onLaunch并不等待异步操作完成。
我经常看到这种写法:
// app.js App({ onLaunch() { wx.login({ success: (res) => { this.globalData.code = res.code; } }); } });看起来没问题,可在某个页面的onLoad里,你直接去读app.globalData.code,大概率拿到undefined。因为页面的onLoad和onLaunch里的异步回调,究竟谁先执行完全取决于网络、基础库版本和当前设备状态。
这意味着,任何在启动阶段异步获取的全局数据,在页面首次渲染时都可能还没就位。不处理这个空窗期,页面就会先渲染一个错误状态,等数据到了再纠错,整个过程肉眼可见地闪烁一下。
2.4 撞墙时刻三:没有任何约束,字段名全靠脑记
项目大了之后,globalData上的字段会越来越多,而且命名规则完全取决于写代码的人当时的心情。有人写userInfo,有人写user,还有人写currentUser。同一个字段在 A 页面叫token,在 B 页面叫authToken,冷不丁就踩雷。
这种问题本质上不是globalData的错,而是缺少一个集中管理入口。当全局数据超过十个字段、读写点遍布十几个文件时,就算不换状态管理库,也应该至少给globalData加一层“统一入口”的外壳,把字段访问收敛到几个函数里,而不是让每个页面直接对对象动手。
3. Storage 才是真正跨会话的全局数据底座
3.1 什么时候必须动用 Storage 而不是 globalData
globalData是纯内存容器,小程序一关,什么都没了。所有需要跨启动保留的全局数据,都必须落到本地存储里,也就是wx.setStorageSync/wx.setStorage这一族 API。
必须使用 Storage 的场景很典型:
- 登录态 token,重新打开小程序要保持登录
- 用户偏好设置,比如深色模式、默认收货地址
- 购物车内容,用户习惯隔天再来付款
- 草稿箱数据
- 数据上报的待发送队列
很多初学者把 Storage 当成“缓存”来看,想清就清。但严格来说,Storage 在小程序里的定位就是本地持久化存储,它的生命周期跟小程序本体绑定,只要用户不删除小程序、不主动调用清理接口,数据就会一直在。所以它完全有资格作为全局数据的持久层,而不只是性能优化的缓存。
3.2 给 Storage 加一层“门面封装”,告别散落各处的 setStorageSync
如果你在项目里直接到处wx.setStorageSync('cart', cartList),很快就会发现几个问题:key 的命名没有规范、存取的字段格式全靠调用方保证、有些地方甚至直接存了JSON.stringify之后的字符串,读出来还要自己JSON.parse。
所以我习惯在项目里封装一个统一的本地数据门面,类似这样:
// utils/data-store.js const PREFIX = 'app_'; function normalizeKey(key) { return PREFIX + key; } function safeSet(key, value) { const finalKey = normalizeKey(key); try { wx.setStorageSync(finalKey, value); return true; } catch (e) { console.error(`[data-store] set failed: ${key}`, e); return false; } } function safeGet(key, defaultValue = null) { const finalKey = normalizeKey(key); try { const value = wx.getStorageSync(finalKey); if (value === '' || value === undefined || value === null) { return defaultValue; } return value; } catch (e) { console.error(`[data-store] get failed: ${key}`, e); return defaultValue; } } function safeRemove(key) { const finalKey = normalizeKey(key); try { wx.removeStorageSync(finalKey); return true; } catch (e) { return false; } } module.exports = { safeSet, safeGet, safeRemove };加统一前缀有几个实际好处:同一个 storage key 空间里,避免和小程序自身或其他模块产生 key 冲突;调试的时候一眼看出哪些是自己的业务数据;以后做数据迁移、批量清理,只需要按前缀扫描就行。
safeGet提供默认值也很关键。实际项目里,首次启动总是会读到空值,如果没有默认值兜底,调用方就得每个地方写一遍判空逻辑,写完还经常漏。
3.3 JSON 序列化陷阱:undefined、NaN、Date 都会被悄悄改写
Storage 底层在做数据持久化的时候,本质上是把对象变成字符串存起来。问题来了,JSON.stringify并不会忠实地保存所有数据类型。
我实测过很多次,下面这段代码的读取结果,经常让新人一脸懵:
const draft = { title: undefined, deadline: new Date('2025-12-31'), count: NaN }; wx.setStorageSync('draft', draft); const loaded = wx.getStorageSync('draft'); console.log(loaded); // 实际输出: // { // deadline: "2025-12-31T00:00:00.000Z", // count: null // }title 整个消失了,NaN 变成了 null,Date 变成了字符串。如果后面的逻辑里直接拿loaded.deadline.getTime()来用,立刻报错。
所以,只要存在 Storage 里的数据结构比较复杂,我强烈建议在存之前做一次显式序列化,或者在读出来之后做一个完整的解析函数,对每个字段都做类型校验。不要心存侥幸,觉得“我这数据没有这些类型”,上线跑两个月再碰到一次,定位的成本比写那个解析函数高十倍。
3.4 同步 API 很香,但大批量写入时照样卡界面
wx.setStorageSync用起来确实爽,不需要回调,写完了立刻能读到。但它是同步阻塞操作,如果一次性写入一条几十 KB 甚至上百 KB 的数据,在小程序的主线程里同步执行,页面就可能出现短暂卡顿。
我自己的经验:写用户资料、订单草稿这类中小型数据,同步 API 完全可以;但如果要写大列表,比如几百条日志、整个历史记录,优先用异步版本wx.setStorage,并且最好做一个“防抖+后写”的封装,让高频写入合并成一次。这个细节在小程序运行到低端安卓机上时会变得特别明显。
4. 状态管理库:把“数据变了页面跟着变”变成约定
4.1 两个流派:像单文件仓库,还是像自动监听器
到了项目中期,项目需求越来越复杂,你会反复面临同一个诉求:数据改了,页面上所有显示它的地方要一起更新。globalData做不到,Storage 更做不到,EventBus 能做到但要自己维护一大堆事件名。
这时候就该引入状态管理了。主流思路无非两个流派。
一种像大文件仓库,所有全局状态树集中在一个 store 对象里,修改数据要经过一个明确的动作函数。这种模式的好处是数据流动路径清晰,适合多人协作,也方便在动作里统一处理副作用、日志、上报。做起来的感觉就像给全局数据加了一套“规章制度”。
另一种像自动监听器,状态对象本身是响应式的,任何字段发生赋值,所有绑定了该字段的页面和组件都会自动重新渲染。这种写法非常爽,写起来像是数据自己在驱动 UI。但有一个副作用:太灵活了,稍不注意就变成“到处都是魔法”。
这两种思路在小程序里都能落地。我更推荐前一种风格,不是因为它更流行,而是因为小程序页面卸载不会像 Vue/React 组件那样有严格的清理时机,显式订阅/取消订阅的事件型结构,反而更好控制生命周期。
4.2 一个几十行的迷你 store,先理解它的核心机制
不用急着上第三方库,我先把一个最简化 store 的核心机制写出来,你看完就明白了它怎么工作:
// stores/mini-store.js class MiniStore { constructor(initialState = {}) { this.state = { ...initialState }; this.watchers = {}; } set(patch) { this.state = { ...this.state, ...patch }; const keys = Object.keys(patch); keys.forEach((key) => { if (this.watchers[key]) { this.watchers[key].forEach((fn) => fn(this.state[key])); } }); } get(key) { if (key === undefined) { return this.state; } return this.state[key]; } watch(key, fn) { if (!this.watchers[key]) { this.watchers[key] = []; } this.watchers[key].push(fn); fn(this.state[key]); } unwatch(key, fn) { if (!this.watchers[key]) { return; } this.watchers[key] = this.watchers[key].filter((item) => item !== fn); } } module.exports = MiniStore;页面里使用起来是这样的:
const { cartStore } = require('../../stores/cart'); Page({ onLoad() { this._onCartChange = (count) => { this.setData({ cartCount: count }); }; cartStore.watch('count', this._onCartChange); }, onUnload() { cartStore.unwatch('count', this._onCartChange); }, addItem() { const count = cartStore.get('count') + 1; cartStore.set({ count }); } });看到没有,核心原理其实就是“订阅-通知”。第三方状态库会把这套机制打磨得更健壮:支持模块切分、异步动作、中间件、批量更新、甚至直接在组件里绑定状态。但理解背后的机制,比会用某个库更重要,因为一旦出了问题,你能顺着数据流去排查,而不是把库当成黑盒。
4.3 引入状态管理前,先问自己三个问题
状态管理不是越早上越好。我见过一个表单类小程序,总共五个页面,非要把登录状态、表单状态、选择项全部塞进 store,结果每个字段改一下都触发全局广播,调试起来反而更乱。
我的判断标准是三个问题:
- 全局数据是不是超过了十个字段?如果没有,
globalData加一个集中管理文件就够了。 - 数据被修改后,是不是有超过两个页面需要同步刷新?如果只有页面内部自产自销,放页面
data里就行。 - 数据流是不是已经复杂到“A 改完 B 要用,B 改完 C 要看”?一旦出现这种链路,事件满天飞,就必须上状态管理了。
引入状态管理最重要的不是工具本身,而是团队约定。没有规范的 store,跟没有规范的globalData一样,照样乱。你得约定清楚:哪些数据进 store、哪些只能被 action 修改、组件里能不能直接改 state。没有这套约束,状态管理反而是多余负担。
5. 三套方案怎么组合,取决于数据纬度和项目体量
5.1 先把三套方案的底层差异看清楚
很多人选择困难,是因为没意识到三个方案根本不在一个维度上。globalData是内存级会话容器,Storage 是持久化容器,状态管理是“让数据变化可以被广播”的机制,它本身不解决持久化问题,它解决的是“同步刷新”问题。
所以,成熟项目的正确姿势通常是把三者结合,而不是三选一:
| 方案 | 数据存放位置 | 能否跨启动 | 数据变动是否自动通知页面 | 适用场景 |
|---|---|---|---|---|
| globalData | 内存 | 否 | 否 | 启动时的临时配置、会话级中间数据 |
| Storage | 本地磁盘 | 是 | 否 | 登录态、草稿、购物车等需要保留的数据 |
| 状态管理 | 内存,可同步绑定Storage | 结合存储 | 是 | 多个页面需要实时同步渲染的复杂数据 |
5.2 常见项目类型的推荐组合
按项目体量去套,基本不会出错:
- 个人工具类小程序,比如计算器、打卡应用:一个
globalData+ 封装好的>// stores/index.js const MiniStore = require('./mini-store'); const dataStore = require('../utils/data-store'); const tokenStore = new MiniStore({ token: dataStore.safeGet('token', '') }); module.exports = { tokenStore };第四步,逐个页面迁移。每个页面从
onLoad里加一遍store.watch,删掉原来的 EventBus 订阅,把app.globalData.xx = value换成store.set({ xx: value })。每迁一个页面,真机上测一遍,确认数据同步正常再迁下一个。第五步,清空历史包袱。全部迁移完成后,删掉 EventBus 相关代码;原来的
globalData只保留真正只读一次的启动配置字段;最后跑一遍全局回归,重点测 Tab 切换、页面栈回退这些容易出时序问题的场景。这套流程我大概给三个项目做过,最长的一个用了一个周末,最短的一个晚上搞定。核心原则只有一个:先存量后增量,小步快跑,别搞一步到位。
6. 全局数据共享最容易翻车的六个细节
6.1 冷启动回填顺序:页面先读,数据后到
这个坑前面提到过,值得再展开一次。问题现象是:首次打开小程序,页面白屏几秒或者显示异常,但热启动时一切正常。
根因基本都在启动阶段的异步数据回填顺序。
App.onLaunch里发请求,页面onLoad同步读数据,读到的永远是初始值。我的处理办法是“启动状态分层”。全局数据里加一个
state字段,取值loading、ready、error,页面绑定数据时同时看状态:// 页面中 const state = appStore.get('userState'); if (state === 'loading') { this.setData({ showSkeleton: true }); }数据到达后再统一广播
ready,所有页面同时从骨架屏切换到真实内容。这样不仅解决了冷启动闪烁,还能把加载失败的错误态统一管理起来。6.2 双重数据源的冲突:页面 data 和 全局数据各自为政
新手最常见的写法,是把全局数据复制一份到页面
data里,然后页面上直接改自己的data。this.setData({ cartCount: this.data.cartCount + 1 }); // 忘了同步 app.globalData.cartCount这样做的结果就是,页面自己很开心,但别的地方读到的
cartCount还是旧值。数据有两个来源,就必然有不同步的一天。解决办法很简单:凡是全局共享的数据,只允许全局仓库作为唯一数据源。页面上显示之前,先问一句:这个数据以后别的地方还会不会用?会,就不要放进页面
data当私有变量,直接在 store 里改,再通过订阅把最新值推给页面。6.3 Storage 的 key 命名冲突
小程序里
wx.setStorageSync的 key 是共享的,不是按业务模块隔离的。A 模块写了个userInfo,B 模块也写了个userInfo,后者直接覆盖前者。命名的正确姿势是带业务前缀加模块前缀,比如
order_draft_v1、user_profile_v1。带版本号也很重要,以后数据结构升级,直接换新 key,旧数据留着做迁移,不用在解析的时候写一堆兼容逻辑。6.4 监听回调忘记注销,产生“幽灵更新”
状态管理用了订阅机制之后,最常见的问题就是没有在页面销毁时取消订阅。后果是页面已经退出栈了,store 里的数据一变,回调仍然执行,调用
setData修改一个已经销毁的页面,控制台就会报错。这个问题的排查链路很典型。你先看到某个不认识的页面报
setData错误;去代码里找watch的调用位置,发现onUnload里漏了unwatch;再往前查,发现当初写的时候只想着页面加载要订阅,忘了销毁。所以,任何在
onLoad或attached里建立的订阅,必须要在对应的生命周期里取消。我给你一个自查口诀:哪里 watch,哪里就 unwatch;谁订阅,谁负责取消。6.5 Storage 写入后立刻读取的“假丢失”
有段时间我们线上用户反馈,页面刷新后购物车少了一部分数据。排查到最后发现,代码在这个页面里先调用了异步
wx.setStorage,紧接着在另一个事件里去wx.getStorageSync,结果读出来的是旧值。原因是同步 API 和异步 API 的完成时机不同,异步写入可能还没落盘,同步读取已经把旧值取走了。这不是丢数据,是读写时序问题。解决办法是同一类数据统一用同一类 API,别一个写异步一个读同步。非要用异步写,就在回调里再读。
6.6 开发者工具和真机的行为差异
开发工具里一切正常,预览/真机就出毛病,这个问题在全局数据上也偶有发生。常见的有几个现象:
- 开发者工具里 storage 读写似乎都是即时生效,真机上偶尔会有延迟
- 开发者工具换个账号后旧数据还在,造成“历史包袱”
- 真机上不同的基础库版本对 storage 序列化的行为有细微差异
所以我养成一个习惯:所有涉及全局数据的逻辑,在开发者工具里跑通之后,一定再用“真机预览 + 本地代码片段”验证一遍。特别是冷启动、断网、切换微信账号这三个场景,真机能暴露很多工具测不出来的时序问题。
最后再分享一个我个人的习惯。我现在做小程序,开局不会直接装一堆状态管理的东西,而是先建立一个全局数据的清单:字段名、生命周期、是否要跨页面同步、持久化要求。清单先理清楚,方案是自然浮出来的。很多看起来很难的架构问题,其实在写代码之前就已经解决了一半。
如果你现在正被某个全局数据不同步的 Bug 折磨,不用急着在网上搜“最好的全局状态管理方案”,先把那堆散落的
globalData和 Storage 调用摊开来看一遍,再决定用哪套机制收敛。数据流清楚了,工具永远只是工具。