小程序状态管理实战指南 2026
目录
小程序状态管理实战指南 2026#
状态管理不是“把所有数据放进一个全局对象”。在小程序里,它至少要同时回答四个问题:状态放在哪个生命周期、谁来修改、修改后怎样触发 setData /响应式更新、多端 API 差异由谁适配。一个设计不当的全局 store,会放大包体积、造成页面脏更新,还会让登录态、购物车、表单草稿这类本应边界清晰的数据纠缠在一起。
本指南按“状态分层 → 方案对比 → 选型决策树 → 两套完整代码 → 三平台差异 → 踩坑与检查清单”的顺序展开。代码示例分别覆盖原生微信小程序的 MobX 方案,以及 uni-app Vue 3 的 Pinia 方案;Taro 项目可按同样的分层原则映射到 Redux、Zustand、MobX 或 Pinia。
一、先把状态分层:90% 的选型争论来自边界不清#
在选择库之前,先把状态拆成七类。不要把所有“页面外数据”都称为全局状态。
| 状态类型 | 典型例子 | 合适的位置 | 是否持久化 | 备注 |
|---|---|---|---|---|
| 页面 UI 状态 | 弹窗开关、Tab 当前项、筛选器展开态 | data / 组件状态 | 通常否 | 路由返回后是否保留要有明确预期 |
| 跨页面业务状态 | 购物车、未提交订单、多步表单 | domain store | 视业务而定 | 需要 action 与恢复策略 |
| 登录/身份状态 | token、openid、会员等级 | session store | 谨慎 | token 优先走安全存储与服务端校验 |
| 服务端数据缓存 | 商品详情、配置、首页楼层 | query/cache store | 可缓存 | 必须处理过期、失效和请求去重 |
| 环境与配置 | 主题、语言、渠道、实验分组 | config store | 常需要 | 启动早期读取,避免首屏闪烁 |
| 临时通信状态 | 页面回传值、一次性弹窗参数 | 路由参数/事件 | 否 | 不要沉淀成全局状态 |
| 本地草稿 | 长文本、上传队列 | storage + store | 是 | 需要版本迁移和清理 |
分层后有一条重要规则:库只管理共享和更新,不替代业务规则。addItem(product) 里可以校验库存、合并 SKU、计算选中数量;但库存上限、价格计算、优惠规则不应该散落在多个页面的 tap 事件里。
二、六种方案横向对比#
| 方案 | 响应式能力 | 学习成本 | 包体积/复杂度 | 适合场景 | 主要代价 |
|---|---|---|---|---|---|
getApp().globalData | 无,需手动 setData | 极低 | 极低 | 少量只读配置、启动参数、低频共享数据 | 无订阅、无修改约束、难以追踪 |
| 自定义事件总线 | 手动发布/订阅 | 低 | 低 | 页面回传、一次性通知、跨组件解耦 | 生命周期不清理会泄漏,重复触发难排查 |
| MobX 小程序版 | observable/computed/action | 中 | 中 | 原生小程序中大型项目、细粒度绑定 | 需理解响应式与异步 action;绑定要清理 |
| Westore | store + diff + 最短路径 setData | 中高 | 中高 | 强调 Model/View/Store 分层的原生项目 | 架构约束强,团队需接受分层规范 |
| Taro 生态方案 | 跟随 React/Vue 生态 | 中 | 视方案而定 | Taro React/Vue 多端项目 | 需确认小程序运行时与模板支持 |
| Pinia | Vue 3 响应式 | 中 | 中 | uni-app/Taro Vue 3 | 解构丢失响应式;多端持久化要封装 |
1. globalData:只适合低频、简单、可追踪的数据#
微信、支付宝、抖音都提供应用级 App() 与 getApp()。微信官方明确 getApp() 返回全局唯一的 App 实例;抖音官方也说明整个小程序只有一个 App 实例,全部页面共享。因此它天然可以放共享数据。
问题是:修改 globalData 后,页面 data 不会自动变化。你仍然需要手动 setData,或者在 onShow 里重新读取。这会让“数据已变但界面未变”成为高频问题。
推荐用法:
- 启动时一次性写入的渠道、主题配置;
- 不直接驱动视图的低频对象;
- 配合显式方法修改,而不是任意页面直接赋值。
不推荐用法:
- 购物车、订单、IM 消息这类需要多个页面同步渲染的状态;
- 大对象频繁整包
setData; - 页面之间的一切通信都塞进
globalData。
2. 事件总线:解决通知,不解决状态真相#
事件总线适合“发生了一件事”,例如支付完成、登录态刷新、城市选择完成。它不适合承载“当前状态是什么”。事件没有当前值语义,后来订阅的页面拿不到历史事件;事件触发顺序也依赖注册顺序。
一个稳妥的折中是:事件只作为信号,状态仍放在 store。页面收到 cart:changed 后读取 store 的当前快照,而不是从事件参数里拼业务状态。
3. MobX 小程序版:原生项目的细粒度方案#
mobx-miniprogram 提供 observable、computed、action;mobx-miniprogram-bindings 把 store 字段映射到页面/组件 data。微信小程序官方 GitHub 组织维护的绑定库说明其需要基础库不低于 2.11.0,并依赖开发者工具 npm 构建。
它的优势是:
fields只绑定当前视图需要的字段;- computed 可以复用派生逻辑;
- action 收敛修改入口;
- 绑定库会批量延迟更新,减少
setData次数。
代价是必须掌握生命周期:页面手工绑定要在 onUnload 销毁;组件可用 behavior;部分子字段更新可能不触发视图,需要替换整个 observable 对象或使用适配 API。
4. Westore:为架构分层而生#
Westore 来自 Tencent,强调 Model、Store、View 分层:Model 承载业务逻辑,Store 作为桥接器,View 保持被动。其 README 说明内部通过 deepClone 与 dataDiff 生成最短路径 setData,开发者修改 this.data 后调用 update()。
它适合愿意投入架构规范的团队,尤其是不想把业务逻辑堆在 Page/Component 里的中大型原生项目。但如果团队只想要一个响应式变量,Westore 的抽象会显得过重。
5. Taro 系:跟随 React/Vue 生态#
Taro 官方文档分别提供 React Redux、Vue 2 Vuex、Vue 3 Pinia 的接入说明。React 项目还可以按生态选择 Zustand、MobX 等方案,但必须验证目标小程序平台、Taro 版本和编译模板。
Taro 项目的状态边界建议:
- React:优先组件局部状态 + 按域拆分的 store,服务端数据可用请求缓存层;
- Vue 3:Pinia 是官方文档明确支持的路径;
- 复杂多端:状态层尽量不直接调用
wx/my/tt,通过适配器注入。
6. Pinia:uni-app Vue 3 的默认候选#
uni-app 官方文档说明 Vue 3 项目内置 Pinia,Vue 2 不支持;CLI 项目的版本要求与 HBuilderX 版本相关。Pinia 提供 state、getters、actions、插件和 TypeScript 推断,官方文档也明确从 store 解构 state/getter 时需要 storeToRefs() 保持响应式。
它适合:
- uni-app Vue 3 多端项目;
- 多个页面共享业务域;
- 需要组合式函数与按域拆分;
- 需要在 action 中封装异步请求。
需要额外处理:
- 小程序没有浏览器 localStorage,持久化要封装
uni.setStorage/getStorage; - 首屏恢复是异步的,要处理闪烁和竞态;
- H5 与小程序 storage API 差异不要散落在 store。
三、选型决策树#
按顺序回答下列问题,避免“项目小用全局变量、项目大再迁移”的被动局面。
1. 状态是否只属于一个页面/组件?
├─ 是:data / ref / useState,不进全局 store
└─ 否:进入 2
2. 是否只是一次性通知或页面回传?
├─ 是:路由参数、EventChannel 或一次性事件
└─ 否:进入 3
3. 是否只有少量低频配置,且不直接驱动复杂视图?
├─ 是:App/globalData + 显式修改方法 + onShow 同步
└─ 否:进入 4
4. 项目使用 uni-app/Taro 且以 Vue 3 为主?
├─ 是:Pinia + 按业务域拆分 + storage 适配器
└─ 否:进入 5
5. 原生小程序团队是否愿意采用强 Model/View/Store 分层?
├─ 是:评估 Westore
└─ 否:进入 6
6. 需要跨页面/组件细粒度更新与可追踪修改?
├─ 是:MobX + action + fields 精确绑定
└─ 否:重构状态边界,而不是继续加全局变量
7. 是否包含服务端数据?
├─ 是:单独 query/cache 层,处理 key、过期、去重、错误
└─ 否:保持 domain/session/config 边界
经验上:
- 1 个页面使用:局部状态;
- 2–3 个页面低频共享:globalData 可能够用,但修改必须集中;
- 3 个以上页面高频同步:引入响应式 store;
- 10 人以上长期维护:宁可初期多一点分层,也不要让页面互相
getCurrentPages()改数据。
四、完整实战一:原生微信小程序 MobX 购物车#
示例目标:
- 购物车数据只存在一个 store;
- 加入/删除/改数量全部走 action;
count、totalPrice是 computed;- 商品卡片组件和购物车页面自动同步;
- 页面卸载时销毁手工绑定。
1. 安装并构建 npm#
npm init -y
npm install mobx-miniprogram mobx-miniprogram-bindings
然后在微信开发者工具执行“工具 → 构建 npm”。绑定库官方 README 要求基础库不低于 2.11.0,并依赖 npm 构建。
2. 目录结构#
miniprogram/
├── app.json
├── stores/
│ └── cart.js
├── components/
│ └── cart-badge/
│ ├── index.js
│ ├── index.json
│ ├── index.wxml
│ └── index.wxss
└── pages/
└── cart/
├── index.js
├── index.json
├── index.wxml
└── index.wxss
3. app.json#
{
"pages": ["pages/cart/index"],
"window": {
"navigationBarTitleText": "购物车"
},
"usingComponents": {
"cart-badge": "/components/cart-badge/index"
},
"sitemapLocation": "sitemap.json"
}
4. 定义 store:stores/cart.js#
import { observable, action } from "mobx-miniprogram";
const STORAGE_KEY = "cart:v1";
function normalizeProduct(product) {
return {
id: String(product.id),
title: product.title,
price: Number(product.price),
image: product.image || "",
quantity: Number(product.quantity || 1),
selected: product.selected !== false,
};
}
function readLocalCart() {
try {
const raw = wx.getStorageSync(STORAGE_KEY);
if (!raw) return [];
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return Array.isArray(parsed) ? parsed.map(normalizeProduct) : [];
} catch (error) {
console.warn("restore cart failed", error);
return [];
}
}
export const cartStore = observable({
items: readLocalCart(),
syncing: false,
get count() {
return this.items.reduce((sum, item) => sum + item.quantity, 0);
},
get selectedItems() {
return this.items.filter((item) => item.selected);
},
get selectedCount() {
return this.selectedItems.reduce((sum, item) => sum + item.quantity, 0);
},
get totalPrice() {
return this.selectedItems.reduce((sum, item) => {
return sum + item.price * item.quantity;
}, 0);
},
persist: action(function () {
wx.setStorageSync(STORAGE_KEY, JSON.stringify(this.items));
}),
addItem: action(function (product) {
const incoming = normalizeProduct(product);
if (!incoming.id || !Number.isFinite(incoming.price) || incoming.price < 0) {
throw new Error("invalid product");
}
const index = this.items.findIndex((item) => item.id === incoming.id);
if (index === -1) {
this.items = [...this.items, incoming];
} else {
const current = this.items[index];
this.items = this.items.map((item, i) =>
i === index
? { ...item, quantity: item.quantity + incoming.quantity }
: item
);
void current;
}
this.persist();
}),
changeQuantity: action(function (id, delta) {
this.items = this.items
.map((item) =>
item.id === String(id)
? { ...item, quantity: Math.max(0, item.quantity + delta) }
: item
)
.filter((item) => item.quantity > 0);
this.persist();
}),
toggleSelected: action(function (id) {
this.items = this.items.map((item) =>
item.id === String(id) ? { ...item, selected: !item.selected } : item
);
}),
removeSelected: action(function () {
this.items = this.items.filter((item) => !item.selected);
this.persist();
}),
clear: action(function () {
this.items = [];
this.persist();
}),
});
这里刻意使用“替换数组/对象”的写法,避免只改子字段导致绑定视图不更新的问题。
5. 组件绑定:components/cart-badge#
index.js:
import { storeBindingsBehavior } from "mobx-miniprogram-bindings";
import { cartStore } from "../../stores/cart";
Component({
behaviors: [storeBindingsBehavior],
storeBindings: {
store: cartStore,
fields: {
count: "count",
totalPrice: "totalPrice",
},
actions: {},
},
});
index.json:
{
"component": true
}
index.wxml:
<view class="badge">
<text class="count">{{count}}</text>
<text class="price">¥{{totalPrice}}</text>
</view>
index.wxss:
.badge {
display: flex;
align-items: center;
gap: 12rpx;
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: #111827;
color: #fff;
font-size: 26rpx;
}
.count {
min-width: 36rpx;
text-align: center;
}
6. 页面手工绑定:pages/cart#
官方绑定库说明 Page 构造器内需要使用手工绑定,并在 onUnload 调用清理函数,否则会内存泄漏。
index.js:
import { createStoreBindings } from "mobx-miniprogram-bindings";
import { cartStore } from "../../stores/cart";
Page({
data: {
products: [
{
id: "sku-1",
title: "便携蓝牙键盘",
price: 199,
image: "/assets/keyboard.png",
},
{
id: "sku-2",
title: "Type-C 扩展坞",
price: 269,
image: "/assets/dock.png",
},
],
},
onLoad() {
this.cartBindings = createStoreBindings(this, {
store: cartStore,
fields: {
items: "items",
count: "count",
selectedCount: "selectedCount",
totalPrice: "totalPrice",
},
actions: {
addItem: "addItem",
changeQuantity: "changeQuantity",
toggleSelected: "toggleSelected",
},
});
},
onUnload() {
if (this.cartBindings) {
this.cartBindings.destroyStoreBindings();
}
},
onAdd(event) {
const product = this.data.products.find(
(item) => item.id === event.currentTarget.dataset.id
);
if (!product) return;
try {
this.addItem({ ...product, quantity: 1 });
} catch (error) {
wx.showToast({ title: "商品信息无效", icon: "none" });
}
},
onQuantity(event) {
const { id, delta } = event.currentTarget.dataset;
this.changeQuantity(id, Number(delta));
},
onSelect(event) {
this.toggleSelected(event.currentTarget.dataset.id);
},
});
index.json:
{
"usingComponents": {
"cart-badge": "/components/cart-badge/index"
}
}
index.wxml:
<view class="page">
<cart-badge />
<view class="section">
<view class="section-title">推荐商品</view>
<view class="product" wx:for="{{products}}" wx:key="id">
<view class="product-info">
<text>{{item.title}}</text>
<text class="price">¥{{item.price}}</text>
</view>
<button size="mini" data-id="{{item.id}}" bindtap="onAdd">加入</button>
</view>
</view>
<view class="section">
<view class="section-title">购物车({{count}})</view>
<view wx:if="{{items.length === 0}}" class="empty">暂无商品</view>
<view class="cart-item" wx:for="{{items}}" wx:key="id">
<checkbox
checked="{{item.selected}}"
data-id="{{item.id}}"
bindtap="onSelect"
/>
<view class="item-info">
<text>{{item.title}}</text>
<text>¥{{item.price}} × {{item.quantity}}</text>
</view>
<view class="qty">
<button size="mini" data-id="{{item.id}}" data-delta="-1" bindtap="onQuantity">-</button>
<button size="mini" data-id="{{item.id}}" data-delta="1" bindtap="onQuantity">+</button>
</view>
</view>
</view>
<view class="footer">
<text>已选 {{selectedCount}} 件</text>
<text>合计 ¥{{totalPrice}}</text>
</view>
</view>
index.wxss:
.page {
min-height: 100vh;
padding: 24rpx;
box-sizing: border-box;
background: #f5f6f8;
}
.section {
margin-top: 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background: #fff;
}
.section-title {
margin-bottom: 20rpx;
font-size: 30rpx;
font-weight: 600;
}
.product,
.cart-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 0;
border-bottom: 1rpx solid #eef0f3;
}
.price {
color: #d93026;
}
.empty {
padding: 40rpx 0;
color: #7b8087;
text-align: center;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -6rpx 24rpx rgba(15, 23, 42, 0.08);
}
这份代码的关键不是“用了 MobX”,而是三个边界:
- 商品展示数据在页面
data,购物车事实在 store; - 所有修改通过 action;
- 页面只绑定需要的 fields,避免整包更新。
五、完整实战二:uni-app Vue 3 Pinia 购物车#
1. main.ts 注册#
uni-app 官方示例强调 createApp 返回值中必须带 Pinia:
import App from "./App.vue";
import { createSSRApp } from "vue";
import * as Pinia from "pinia";
export function createApp() {
const app = createSSRApp(App);
const pinia = Pinia.createPinia();
app.use(pinia);
return {
app,
Pinia: pinia,
};
}
2. 持久化适配器:utils/storage.ts#
type StorageLike = {
get<T>(key: string): T | null;
set<T>(key: string, value: T): void;
remove(key: string): void;
};
const storage: StorageLike = {
get<T>(key: string): T | null {
try {
const value = uni.getStorageSync(key);
return value ? (value as T) : null;
} catch {
return null;
}
},
set<T>(key: string, value: T): void {
try {
uni.setStorageSync(key, value);
} catch (error) {
console.warn("storage write failed", error);
}
},
remove(key: string): void {
try {
uni.removeStorageSync(key);
} catch {
// 忽略删除失败,后续写入会覆盖
}
},
};
export function readJson<T>(key: string, fallback: T): T {
const value = storage.get<T>(key);
return value === null ? fallback : value;
}
export function writeJson<T>(key: string, value: T): void {
storage.set(key, value);
}
export function removeKey(key: string): void {
storage.remove(key);
}
3. Store:stores/cart.ts#
import { computed, ref } from "vue";
import { defineStore } from "pinia";
import { readJson, writeJson } from "@/utils/storage";
export interface CartProduct {
id: string;
title: string;
price: number;
image: string;
quantity: number;
selected: boolean;
}
const STORAGE_KEY = "cart:v1";
function normalize(product: CartProduct): CartProduct {
return {
id: String(product.id),
title: product.title,
price: Number(product.price),
image: product.image ?? "",
quantity: Math.max(1, Number(product.quantity || 1)),
selected: product.selected !== false,
};
}
function restore(): CartProduct[] {
const saved = readJson<CartProduct[]>(STORAGE_KEY, []);
return Array.isArray(saved) ? saved.map(normalize) : [];
}
export const useCartStore = defineStore("cart", () => {
const items = ref<CartProduct[]>(restore());
const syncing = ref(false);
const selectedItems = computed(() => items.value.filter((item) => item.selected));
const count = computed(() => items.value.reduce((sum, item) => sum + item.quantity, 0));
const selectedCount = computed(() =>
selectedItems.value.reduce((sum, item) => sum + item.quantity, 0)
);
const totalPrice = computed(() =>
selectedItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
function persist() {
writeJson(STORAGE_KEY, items.value);
}
function addItem(product: Omit<CartProduct, "selected">) {
const incoming = normalize({ ...product, selected: true });
if (!incoming.id || !Number.isFinite(incoming.price) || incoming.price < 0) {
throw new Error("invalid product");
}
const index = items.value.findIndex((item) => item.id === incoming.id);
if (index === -1) {
items.value = [...items.value, incoming];
} else {
items.value = items.value.map((item, i) =>
i === index ? { ...item, quantity: item.quantity + incoming.quantity } : item
);
}
persist();
}
function changeQuantity(id: string, delta: number) {
items.value = items.value
.map((item) =>
item.id === id ? { ...item, quantity: item.quantity + delta } : item
)
.filter((item) => item.quantity > 0);
persist();
}
function toggleSelected(id: string) {
items.value = items.value.map((item) =>
item.id === id ? { ...item, selected: !item.selected } : item
);
}
function removeSelected() {
items.value = items.value.filter((item) => !item.selected);
persist();
}
async function refreshStock(serverCount: Record<string, number>) {
syncing.value = true;
try {
items.value = items.value.map((item) => ({
...item,
quantity: Math.min(item.quantity, serverCount[item.id] ?? item.quantity),
}));
persist();
} finally {
syncing.value = false;
}
}
return {
items,
syncing,
selectedItems,
count,
selectedCount,
totalPrice,
addItem,
changeQuantity,
toggleSelected,
removeSelected,
refreshStock,
};
});
4. 页面:pages/cart/index.vue#
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { useCartStore, type CartProduct } from "@/stores/cart";
const cart = useCartStore();
const { items, count, selectedCount, totalPrice } = storeToRefs(cart);
const products: CartProduct[] = [
{ id: "sku-1", title: "便携蓝牙键盘", price: 199, image: "", quantity: 1, selected: true },
{ id: "sku-2", title: "Type-C 扩展坞", price: 269, image: "", quantity: 1, selected: true },
];
function add(product: CartProduct) {
try {
cart.addItem(product);
} catch {
uni.showToast({ title: "商品信息无效", icon: "none" });
}
}
function change(id: string, delta: number) {
cart.changeQuantity(id, delta);
}
function toggle(id: string) {
cart.toggleSelected(id);
}
</script>
<template>
<view class="page">
<view class="summary">
<text>总数 {{ count }}</text>
<text>已选 {{ selectedCount }}</text>
<text>合计 ¥{{ totalPrice }}</text>
</view>
<view class="section">
<view class="title">推荐商品</view>
<view v-for="product in products" :key="product.id" class="row">
<view>
<text>{{ product.title }}</text>
<text class="price">¥{{ product.price }}</text>
</view>
<button size="mini" @tap="add(product)">加入</button>
</view>
</view>
<view class="section">
<view class="title">购物车</view>
<view v-if="items.length === 0" class="empty">暂无商品</view>
<view v-for="item in items" :key="item.id" class="row">
<checkbox :checked="item.selected" @tap="toggle(item.id)" />
<view class="info">
<text>{{ item.title }}</text>
<text>¥{{ item.price }} × {{ item.quantity }}</text>
</view>
<view class="qty">
<button size="mini" @tap="change(item.id, -1)">-</button>
<button size="mini" @tap="change(item.id, 1)">+</button>
</view>
</view>
</view>
</view>
</template>
<style scoped>
.page {
min-height: 100vh;
padding: 24rpx;
background: #f5f6f8;
}
.summary,
.section {
margin-bottom: 24rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
}
.summary {
display: flex;
justify-content: space-between;
}
.title {
margin-bottom: 20rpx;
font-weight: 600;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18rpx 0;
border-bottom: 1rpx solid #eef0f3;
}
.price {
color: #d93026;
}
.empty {
color: #7b8087;
text-align: center;
}
.qty {
display: flex;
gap: 12rpx;
}
</style>
注意 storeToRefs():Pinia 官方文档明确直接解构 state/getter 会丢失响应式,action 则可以直接解构。
六、微信、支付宝、抖音三平台差异#
微信小程序#
getApp()返回全局唯一 App 实例,官方提醒不要在 App 定义函数内或调用 App 前使用,也不要自行调用其生命周期函数;- MobX 绑定库依赖 npm 构建,官方 README 标注基础库要求 ≥ 2.11.0;
- 绑定更新默认延迟到下一个 tick,可在需要时调用更新方法;
- Page 内手工绑定必须
onUnloaddestroy; - 可用开发者工具 Audits、体验评分和 setData 面板观察更新路径。
支付宝小程序#
- 同样提供
App()/getApp(),支付宝文档强调全局变量修改后的共享语义,引用类型修改会影响所有页面; - API 前缀是
my,存储、Toast、支付等能力不要在 store 中硬编码为wx; - 使用第三方状态库前要确认其小程序适配与编译方式;
- 若复用跨端 store,建议把平台 API、支付回调、登录流程都放入 adapter。
抖音小程序#
- 抖音官方文档说明
app.js创建全局小程序应用实例,App()可声明生命周期、全局数据和函数; getApp()获取全局唯一实例,全部页面共享;- API 前缀是
tt,生命周期细节和页面栈行为要按抖音文档验证; - 状态库本身可以复用,但登录、支付、存储、分享等能力必须平台适配;
- 若使用 uni-app/Taro 编译到抖音,优先用框架抽象 API,不要在 store 中写平台分支。
一个可维护的跨平台结构是:
stores/ # 纯状态与业务规则
adapters/
storage.ts
auth.ts
payment.ts
platforms/
wechat.ts
alipay.ts
douyin.ts
store 可以调用 adapter,但不要直接判断 typeof wx !== "undefined"。否则单元测试、H5 调试和新增平台都会变得困难。
七、迁移到状态库的六步法#
不要一次性重写项目。按以下顺序迁移:
- 列出现有全局变量、事件名、storage key 和写入点;
- 给每个状态标注类型、生命周期、读写页面和持久化需求;
- 先迁一个低风险业务域,例如主题或城市;
- 建立 store + action + adapter,不改 UI 结构;
- 用页面级开关同时运行新旧逻辑,对比关键快照;
- 删除旧事件与旧全局写入点,补充回归测试。
迁移期间最危险的写法是“新 store 写,旧 globalData 读”。两边都以为自己是真相,问题会在低频路径爆发。
八、10 个高频踩坑#
1. 直接解构 Pinia state/getter#
错误:
const { items, totalPrice } = useCartStore();
修正:
const cart = useCartStore();
const { items, totalPrice } = storeToRefs(cart);
2. 修改 MobX 对象子字段后视图不更新#
绑定库官方 README 提醒部分子字段更新不会引发界面变化。稳妥做法是替换整个对象,或使用库支持的响应式更新方式。
3. Page 手工绑定不销毁#
createStoreBindings 返回的对象必须在页面 onUnload 或组件 detached 清理,否则官方文档明确会导致内存泄漏。
4. 把大对象整包绑定到页面#
页面只应绑定当前渲染字段。商品列表、筛选条件、分页信息、日志不要全部塞进一个 rootState 再整体绑定。
5. 在 action 外到处修改状态#
MobX 可配置修改约束,Pinia 也建议 action 承载业务与异步修改。任意组件直接改 state 会让竞态和回滚无从排查。
6. 把服务端数据当本地状态覆盖#
用户本地修改与服务端刷新同时发生时,需要版本号、请求取消或最后写入策略。不要在请求返回后无条件覆盖用户正在编辑的数据。
7. storage 同步恢复造成首屏闪烁#
小程序 storage 读取虽然常见同步 API,但初始化时序仍可能晚于首屏渲染。至少提供骨架态,避免“空购物车 → 恢复商品”的跳变。
8. token 与业务状态一起持久化#
token、敏感资料、购物车、UI 偏好应有不同 key、过期策略和清理时机。登出时不要只清一个 state 对象。
9. 事件总线监听器不清理#
页面卸载、组件 detached、登录切换都要移除监听。重复订阅的典型表现是一次点击触发多个请求。
10. 跨平台 API 直接写进 store#
wx.setStorageSync、my.showToast、tt.login 混在 store 里会让多端不可复用。用 adapter 注入,并在单元测试里替换假实现。
九、上线前质量检查清单#
发布前逐项打勾:
- 每个状态都标注了类型和归属域;
- 页面私有状态没有进入全局 store;
- 一次性通信没有伪装成长期状态;
- 所有跨页面修改都有 action 或具名方法;
- MobX/事件绑定在页面和组件卸载时清理;
- Pinia state/getter 解构使用
storeToRefs(); - 每个页面只绑定当前视图需要的字段;
setData路径经过真机或开发者工具检查;- 持久化 key 有版本号和迁移策略;
- 登出、切账号、冷启动、热启动状态正确;
- 异步请求有 loading/error/empty/重试和取消;
- 三平台平台 API 均通过 adapter 隔离;
- store 单元测试覆盖 computed、action、失败分支;
- 大列表更新经过真机低端机验证;
- 敏感 token 不与业务缓存混存。
十、推荐参考#
- 微信开放文档:
getApp - 微信小程序 MobX 绑定库:mobx-miniprogram-bindings
- Tencent Westore:GitHub README
- Taro 官方文档:Vue3 Pinia
- uni-app 官方文档:状态管理 Pinia
- Pinia 官方文档:定义 Store / 从 Store 解构
- 支付宝文档中心:
getApp方法 - 抖音开放平台:App 与 getApp
结语#
状态管理的目标不是引入最复杂的库,而是让每份数据只有一个可信来源、每类修改都有清晰入口、每次视图更新都可解释。小项目可以从显式的 globalData 方法开始;原生中大型项目适合 MobX 或 Westore;Vue 3 多端项目优先 Pinia。无论选择哪条路线,都不要放弃状态分层、生命周期清理和多端适配这三条底线。