后台能配置活动,不代表小程序端就能优雅消费配置。
我用
vue-page-scope把活动 query、配置加载、表单状态、loading、异步副作用和小程序生命周期,收进一个页面级 Scope。仓库:github.com/weijianjunwjj/activity-config-miniapp
npm:
vue-page-scope
一段你大概率写过的小程序页面
typescript
const activityId = ref('')
const activityConfig = ref(null)
const formData = reactive({
name: '',
phone: '',
city: '',
remark: '',
})
const loading = ref(false)
const submitting = ref(false)
const counter = ref(0)
let timer: any = null
onLoad((query) => {
activityId.value = query.activityId || ''
loadActivity()
})
onShow(() => {
timer = setInterval(() => {
counter.value++
}, 1000)
})
onHide(() => {
clearInterval(timer)
})
onUnload(() => {
clearInterval(timer)
activityConfig.value = null
})
写的时候没问题。
一个活动报名页而已。
拿 query,拉配置,填表单,提交,loading,定时器,页面 hide 时停一下,unload 时清一下。
看起来很合理。
直到这个页面开始长大。
后台字段越来越多,报名规则越来越细,页面状态越来越散。今天加一个城市字段,明天加一个奖品配置,后天再加一个报名限制。最后你会发现:
后台是"配置中心"。
小程序活动页成了"杂物间"。
这不是 uni-app 的问题。
也不是 ref/reactive 的问题。
这是一个结构性问题:
配置化活动页到了小程序端,缺一个页面级边界。
后台配置完,不代表活动真的跑起来了
很多配置化系统,第一眼都挺美。
后台能建活动。
运营能填表。
字段能配置。
奖品能配置。
规则能配置。
甚至还有审批、预览、发布。
到这里,大家通常会觉得:闭环了。
但其实只闭了一半。
后台只是把活动"生产"出来了。
小程序端还要把它"运行"起来。
这中间有一堆状态要接:
arduino
┌──────────────────────────────────────┐
│ 后台配置 │
│ 活动信息 / 字段 / 奖品 / 规则 │
│ 负责:生产配置 │
└───────────────────┬──────────────────┘
│ activityId / config
▼
┌──────────────────────────────────────┐
│ 小程序活动页 │
│ query / config / formData / loading │
│ timer / request / show-hide / unload │
│ 负责:消费配置并稳定运行 │
└──────────────────────────────────────┘
很多项目的问题不在后台。
后台已经很强了。
真正乱的是小程序端。
因为小程序活动页被迫接住太多东西:
activityId从哪里来;activityConfig什么时候加载;formData放哪;loading/submitting谁管;- 页面 hide 后定时器停不停;
- state 要不要保留;
- unload 后资源谁清;
- 下次再进页面,会不会复用到旧实例。
这些东西单独看,都不难。
但它们堆在一个页面里,就会变成一间没人敢收拾的杂物间。
小程序活动页最乱的地方,不是生命周期,是状态没有边界
很多 Vue3 开发者写 uni-app 小程序,第一反应是:
onLoad / onShow / onHide / onUnload 好烦。
又要重新切一套小程序心智。
但真正写到复杂页面后,你会发现:
生命周期只是门口那块垫脚石。
真正烦的是,页面里所有东西都没有归属。
sql
┌──────────────────────────────────────┐
│ 应用级状态 │
│ 用户信息 / 权限 / 主题 / 全局配置 │ ← Pinia
│ 生命周期:跟应用一起活 │
├──────────────────────────────────────┤
│ 页面级状态 │
│ query / 活动配置 / 表单 / loading │ ← Page Scope
│ timer / 请求 / show-hide / destroy │ ★ 小程序活动页最缺这一层
├──────────────────────────────────────┤
│ 组件级状态 │
│ 输入框局部态 / 私有 UI 交互 │ ← ref / reactive
│ 生命周期:跟组件实例走 │
└──────────────────────────────────────┘
活动报名页这种状态,很尴尬。
它太大,塞不回组件。
它又不全局,不该污染 Pinia。
于是它常年没家。
要么散在页面的一堆 ref/reactive 里。
要么塞进 Pinia,最后变成一个不会自动销毁的临时垃圾场。
要么写一堆 watch + onUnload + clearInterval,手工凑出一个"页面作用域"。
所以这次我想要的不是另一个全局 Store。
我想要的是:
给小程序活动页加一个隔离舱。
Page Scope 在小程序里是什么
一句话:
把一个活动页从进入、加载、交互、暂停、恢复、销毁,全部关进同一个页面作用域。
bash
┌────────────────────────┐
│ Apply Page │
│ uni-app 小程序页面 │
└───────────┬────────────┘
│
│ useApplyScopeInMiniapp()
▼
┌───────────────────────────────────────────┐
│ PageScope │
│ │
│ context source state │
│ activityId config formData │
│ loading actions interval │
│ │
│ $init $enter $leave │
│ $destroy effectScope.stop │
└─────────────────────┬─────────────────────┘
│
│ onUnload
▼
统一销毁 / 统一回收
它不是替你换几个生命周期名字。
而是把这些东西放回自己的格子里:
context接住 query;source放活动配置;state放表单状态;actions放业务动作;$loading跟踪异步;enter启动副作用;leave暂停副作用但保留 state;destroy彻底释放资源。
这才是最值钱的部分。
小程序页面不用再自己收拾一地状态。
真实代码长什么样
这是我在 activity-config-miniapp 里接入后的 adapter 核心。
删掉业务细节后,核心就这些:
typescript
import { shallowRef } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { definePageScope } from 'vue-page-scope'
export function useApplyScopeInMiniapp() {
// 路由参数先存这里,onLoad 触发前是空 {}
const pageQuery = shallowRef<Record<string, string>>({})
const scope = definePageScope('apply', {
lifecycle: 'manual', // 关键①:库不注册任何 Vue 钩子,也不自动 init
context: () => pageQuery.value, // 关键②:路由参数走 context 通道,惰性读取
source: () => ({ activityId: '', activityConfig: null }),
state: () => ({ formData: {}, submitting: false }),
// getters / actions ...
init() {
// init 触发那刻,从 context 读 query 喂进 source
const query = this.$getContext() || {}
this.$source.activityId = query.activityId || ''
},
enter() { this.loadActivity() },
})()
// 这就是全部的"翻译":四个宿主钩子 → 四个 scope 方法
onLoad((query) => { pageQuery.value = query || {}; scope.$init() })
onShow(() => scope.$enter())
onHide(() => scope.$leave())
onUnload(() => scope.$destroy())
return scope
}
最后四行,就是整个适配层:
bash
onLoad → $init
onShow → $enter
onHide → $leave
onUnload → $destroy
外部页面只需要:
ini
const scope = useApplyScopeInMiniapp()
然后继续写业务。
不需要在页面里散落一堆生命周期。
不需要每个活动页都重新想一遍 query、loading、timer、destroy 怎么组织。
这层 adapter 像一个翻译官。
小程序说:onLoad / onShow / onHide / onUnload。
Page Scope 听到的是:$init / $enter / $leave / $destroy。
(见图 1)
几个能直接看出价值的细节
context:解决 setup 阶段拿不到 query
uni-app 小程序里,setup 阶段还拿不到 query。
query 要等 onLoad 才有。
所以不能在 scope 创建时急着读路由参数。
我用 context 通道解决这个问题:
javascript
context: () => pageQuery.value, // getter,调用时才求值
// ...
init() {
const query = this.$getContext() || {} // init 触发那刻才读,此时 query 已就位
this.$source.activityId = query.activityId || ''
}
scope.$getContext() 是惰性读取。
不缓存。
Ref/getter 始终反映最新值。
这意味着:scope 可以先创建,query 可以后进入。
等 $init() 真正执行时,再读取 query。
这一步很关键。
否则你很容易在 setup 阶段读到一个空 {},然后开始怀疑人生。
(见图 3)
source / state:把配置和交互状态分开
活动页里有两类状态。
一类是页面资源来源:
yaml
source: () => ({
activityId: '',
activityConfig: null
})
一类是用户交互状态:
yaml
state: () => ({
formData: {},
submitting: false
})
前者是这个页面消费后台配置的入口。
后者是用户在当前页面里的操作。
都属于页面。
但它们不是一回事。
分开以后,页面结构会清很多。
不是所有东西都往一个 reactive({}) 里塞。
enter / leave:副作用要停,state 不能丢
小程序页面 hide,不等于页面死了。
用户可能只是切到后台。
也可能只是跳到另一个页面。
这时定时器、轮询、监听应该停。
但表单状态不能丢。
enter / leave 就是用来切这个边界的。
enter() 里加载配置、启动副作用。
leave() 里暂停副作用,但保留 state。
这和 destroy 不一样。
destroy 才是彻底释放。
暂停和销毁,是两种动作。
很多小程序页面乱,就是因为这两件事没分开。
$destroy:不是卸载,是收尸
页面真正 onUnload 时,才进入 $destroy。
这里不只是清一个定时器。
它要做的是:
arduino
如果当前还在 entered,先补一次 $leave
清理 interval
effectScope.stop
标记 disposed
从 registry 里移走当前实例
这才是页面级 Scope 的意义。
页面死了,相关状态和副作用一起退场。
不要留魂在 registry 里。
不要让下次进页面时,复用到一个 disposed 的死实例。
为了这四行 adapter,库做了什么
第一版 adapter 不是这么薄。
我一开始也想在 adapter 里维护状态机。
比如:
initState = pending | done;onShow早于onLoad时先排队;- 自己模拟 leave;
- 自己兜底 destroy。
写着写着就不对劲了。
adapter 是翻译官。
不是垃圾桶。
生命周期的幂等和时序,应该是库的职责。
所以我把这部分能力补进了 vue-page-scope。
主要是四件事:
- 公开
$init / $enter / $leave / $destroy; - 增加
lifecycle: 'auto' | 'manual'; - 把生命周期形式化成状态机;
- 增加
context通道,替代一开始设想里的$initWithRoute。
状态机很简单:
css
created → inited → entered ⇄ left → destroyed
关键规则也很简单:
$init只执行一次;$enter已 entered 时忽略,destroyed 后无效;$leave只在 entered 状态触发;$destroy只执行一次;- 如果
$destroy时还在 entered,自动先补一次$leave再effectScope.stop。
(见图 2)
我没有给库加 $initWithRoute。
因为不需要。
manual + context + 状态机 已经能拼出来,就别再往库里塞新 API。
库不是许愿池。
每多一个概念,用户就多背一个包袱。
真机里最值得记的几个坑
mock 跑通,不等于真机跑通。
这次我上了微信真机。
环境是:微信基础库 3.16.1 / iOS 17.2.1 / LazyCodeLoading: false。
我只挑几个最值得记的说。
navigateBack 不触发 onHide,直接 onUnload
我原本以为返回页面时是:
onHide → onUnload
真机日志告诉我,不是。
navigateBack 时只触发 onUnload。
也就是说,页面可能在 entered 状态下直接销毁。
幸好 $destroy 兜住了。
它发现当前还在 entered,会自动先补一次 $leave,再 stop。
这个坑说明一件事:
destroy 不能假设前面一定走过 leave。
onHide 后定时器要停,但 state 不能丢
apply 页里我起了一个 interval。
切微信到后台时,触发 onHide → $leave。
我需要的是:
定时器停。
表单状态和 counter 不丢。
真机结果是:后台前 counter=5,切后台停 12 秒,回来后从 5 平滑续增,5→6→7。
没有补偿跳变。
这说明 $leave 清了 interval,$enter 重建了 interval,但 state 还在。
一句话:
副作用可以暂停,页面状态不能乱死。
disposed 的 scope 不能被 registry 复用
scopeRegistry 按 id 缓存 scope。
这带来一个真 bug:
同 id 的 scope 已经 $destroy 了,下次 definePageScope(id) 命中缓存,可能复用到一个 disposed 的死 scope。
修复方式放在库侧:
命中缓存时,如果 cached.$disposed 为真,先 delete 再重建。
$destroy 后也会 self-evict。
关键守卫是:
ini
const cached = registry.get(id)
if (cached === scope) registry.delete(id)
只删自己。
不误伤已经被新实例顶替的同 id scope。
pnpm link 可能搞出双 Vue
这个也很阴。
库用 pnpm link / junction 接进项目时,可能解析到自己那份 Vue。
项目一份 Vue,库一份 Vue。
reactive / inject 跨实例就会失效。
验证脚本里,我用 esbuild 强制 bundle 成单一 Vue 副本。
正式项目里加:
less
resolve.dedupe: ['vue']
这种坑最烦。
不一定报错。
但响应式就是开始"装死"。
一个我没修的 limitation
这里有一个点,我没有修。
onShow 早于 onLoad 时,库不排队。
也就是说,created 状态下调用 $enter 会被直接放行,此时 $getContext() 拿到的还是空 query。
这是个静默错误。
但我没有为了它加 pending-flush。
原因很简单:
标准 uni-app page 生命周期里,onLoad 保证先于首次 onShow。
当前场景里,这个逆序不发生。
为了一个当前不存在的路径,引入 pending 队列和 flush 机制,是过度设计。
所以我把它标成 limitation。
不假装完美。
库没有某个能力,不等于必须马上补上。
有些能力应该等真实场景把它逼出来。
现在不修,不是偷懒。
是克制。
我怎么验证,但不展开成测试报告
适配这种东西,不能只靠"看起来能跑"。
我做了两层验证。
一层是 mock。
手动触发 load/show/hide/unload,验证生命周期映射、状态流转、销毁清理。
另一层是真机。
微信开发者工具导入 + 真机预览,看 Console 和页面探针。
我重点看几件事:
- query 能不能正确进入 scope;
- hide 后 interval 会不会停;
- state 会不会保留;
- unload 时能不能彻底销毁;
- 再次进入会不会复用 disposed scope;
$loading是否正常;- navigateBack 是否被
$destroy兜住。
最终 mock 和真机都跑通。
这就够了。
读者不需要看一份测试报告。
但我自己需要知道:这不是脑测。
它适合什么场景
这个库不是给所有页面用的。
如果你的页面只是几个输入框,一个提交按钮,没有复杂副作用,ref/reactive 完全够用。
别上来就加抽象。
但如果你的小程序页面已经开始出现这些迹象:
- query、配置、表单、loading 混在一起;
- 页面 hide/show 有副作用要处理;
- 定时器、轮询、请求需要统一清理;
- Pinia 里塞了一堆页面临时状态;
- 页面销毁后,你不确定状态有没有干净退出;
- Web 端和小程序端想复用同一种页面状态心智;
那 Page Scope 就有价值。
它不是让你多学一个库。
它是让你少收拾一间杂物间。
写在最后
这次适配后,我对 vue-page-scope 的定位更清楚了。
它不是另一个全局 Store。
也不是生命周期语法糖。
它真正做的事,是给页面级状态划边界。
尤其是配置化活动页这种场景:
后台配置出来的东西,只是活动被生产出来了。
小程序端能稳定拿 query、拉配置、维护表单、处理 loading、暂停副作用、销毁资源,才算这个活动真的运行起来。
以前我更关心:
这个页面怎么写出来。
现在我更关心:
这个页面复杂起来以后,怎么不烂掉。
所以我希望 vue-page-scope 做的事很简单:
把小程序活动页这间杂物间,收拾成一张清楚的工作台。
perl
context → query
source → 活动配置
state → 表单状态
enter → 启动副作用
leave → 暂停副作用,保留 state
destroy → 彻底释放资源
如果你 Web 端已经在用 vue-page-scope,小程序端可以复用同一套页面心智。
如果你还没用,它至少提供了一个思路:
页面有生命周期,页面状态也应该有边界。
仓库:github.com/weijianjunwjj/activity-config-miniapp
库:npmjs.com/package/vue-page-scope
图 1:生命周期映射

图 2:生命周期状态机

图 3:context 惰性求值时序
