▶_MiniApp Toolkit
· 约 16 分钟微信小程序/支付宝小程序/抖音小程序/Taro/uni-app/MobX/Pinia

小程序状态管理实战指南 2026

状态管理MobXPiniawestoreTarouni-app微信小程序支付宝小程序抖音小程序
目录

小程序状态管理实战指南 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;绑定要清理
Westorestore + diff + 最短路径 setData中高中高强调 Model/View/Store 分层的原生项目架构约束强,团队需接受分层规范
Taro 生态方案跟随 React/Vue 生态视方案而定Taro React/Vue 多端项目需确认小程序运行时与模板支持
PiniaVue 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。

三、选型决策树#

按顺序回答下列问题,避免“项目小用全局变量、项目大再迁移”的被动局面。

code
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;
  • counttotalPrice 是 computed;
  • 商品卡片组件和购物车页面自动同步;
  • 页面卸载时销毁手工绑定。

1. 安装并构建 npm#

code
npm init -y
npm install mobx-miniprogram mobx-miniprogram-bindings

然后在微信开发者工具执行“工具 → 构建 npm”。绑定库官方 README 要求基础库不低于 2.11.0,并依赖 npm 构建。

2. 目录结构#

code
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#

code
{
  "pages": ["pages/cart/index"],
  "window": {
    "navigationBarTitleText": "购物车"
  },
  "usingComponents": {
    "cart-badge": "/components/cart-badge/index"
  },
  "sitemapLocation": "sitemap.json"
}

4. 定义 store:stores/cart.js#

code
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

code
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

code
{
  "component": true
}

index.wxml

code
<view class="badge">
  <text class="count">{{count}}</text>
  <text class="price">¥{{totalPrice}}</text>
</view>

index.wxss

code
.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

code
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

code
{
  "usingComponents": {
    "cart-badge": "/components/cart-badge/index"
  }
}

index.wxml

code
<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

code
.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”,而是三个边界:

  1. 商品展示数据在页面 data,购物车事实在 store;
  2. 所有修改通过 action;
  3. 页面只绑定需要的 fields,避免整包更新。

五、完整实战二:uni-app Vue 3 Pinia 购物车#

1. main.ts 注册#

uni-app 官方示例强调 createApp 返回值中必须带 Pinia:

code
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#

code
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#

code
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#

code
<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 内手工绑定必须 onUnload destroy;
  • 可用开发者工具 Audits、体验评分和 setData 面板观察更新路径。

支付宝小程序#

  • 同样提供 App()/getApp(),支付宝文档强调全局变量修改后的共享语义,引用类型修改会影响所有页面;
  • API 前缀是 my,存储、Toast、支付等能力不要在 store 中硬编码为 wx
  • 使用第三方状态库前要确认其小程序适配与编译方式;
  • 若复用跨端 store,建议把平台 API、支付回调、登录流程都放入 adapter。

抖音小程序#

  • 抖音官方文档说明 app.js 创建全局小程序应用实例,App() 可声明生命周期、全局数据和函数;
  • getApp() 获取全局唯一实例,全部页面共享;
  • API 前缀是 tt,生命周期细节和页面栈行为要按抖音文档验证;
  • 状态库本身可以复用,但登录、支付、存储、分享等能力必须平台适配;
  • 若使用 uni-app/Taro 编译到抖音,优先用框架抽象 API,不要在 store 中写平台分支。

一个可维护的跨平台结构是:

code
stores/           # 纯状态与业务规则
adapters/
  storage.ts
  auth.ts
  payment.ts
platforms/
  wechat.ts
  alipay.ts
  douyin.ts

store 可以调用 adapter,但不要直接判断 typeof wx !== "undefined"。否则单元测试、H5 调试和新增平台都会变得困难。

七、迁移到状态库的六步法#

不要一次性重写项目。按以下顺序迁移:

  1. 列出现有全局变量、事件名、storage key 和写入点;
  2. 给每个状态标注类型、生命周期、读写页面和持久化需求;
  3. 先迁一个低风险业务域,例如主题或城市;
  4. 建立 store + action + adapter,不改 UI 结构;
  5. 用页面级开关同时运行新旧逻辑,对比关键快照;
  6. 删除旧事件与旧全局写入点,补充回归测试。

迁移期间最危险的写法是“新 store 写,旧 globalData 读”。两边都以为自己是真相,问题会在低频路径爆发。

八、10 个高频踩坑#

1. 直接解构 Pinia state/getter#

错误:

code
const { items, totalPrice } = useCartStore();

修正:

code
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.setStorageSyncmy.showToasttt.login 混在 store 里会让多端不可复用。用 adapter 注入,并在单元测试里替换假实现。

九、上线前质量检查清单#

发布前逐项打勾:

  1. 每个状态都标注了类型和归属域;
  2. 页面私有状态没有进入全局 store;
  3. 一次性通信没有伪装成长期状态;
  4. 所有跨页面修改都有 action 或具名方法;
  5. MobX/事件绑定在页面和组件卸载时清理;
  6. Pinia state/getter 解构使用 storeToRefs()
  7. 每个页面只绑定当前视图需要的字段;
  8. setData 路径经过真机或开发者工具检查;
  9. 持久化 key 有版本号和迁移策略;
  10. 登出、切账号、冷启动、热启动状态正确;
  11. 异步请求有 loading/error/empty/重试和取消;
  12. 三平台平台 API 均通过 adapter 隔离;
  13. store 单元测试覆盖 computed、action、失败分支;
  14. 大列表更新经过真机低端机验证;
  15. 敏感 token 不与业务缓存混存。

十、推荐参考#

结语#

状态管理的目标不是引入最复杂的库,而是让每份数据只有一个可信来源、每类修改都有清晰入口、每次视图更新都可解释。小项目可以从显式的 globalData 方法开始;原生中大型项目适合 MobX 或 Westore;Vue 3 多端项目优先 Pinia。无论选择哪条路线,都不要放弃状态分层、生命周期清理和多端适配这三条底线。