你的小程序活动页,可能已经成了后台配置的杂物间

后台能配置活动,不代表小程序端就能优雅消费配置。

我用 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

主要是四件事:

  1. 公开 $init / $enter / $leave / $destroy
  2. 增加 lifecycle: 'auto' | 'manual'
  3. 把生命周期形式化成状态机;
  4. 增加 context 通道,替代一开始设想里的 $initWithRoute

状态机很简单:

css 复制代码
created → inited → entered ⇄ left → destroyed

关键规则也很简单:

  • $init 只执行一次;
  • $enter 已 entered 时忽略,destroyed 后无效;
  • $leave 只在 entered 状态触发;
  • $destroy 只执行一次;
  • 如果 $destroy 时还在 entered,自动先补一次 $leaveeffectScope.stop

(见图 2)

我没有给库加 $initWithRoute

因为不需要。

manual + context + 状态机 已经能拼出来,就别再往库里塞新 API。

库不是许愿池。

每多一个概念,用户就多背一个包袱。


真机里最值得记的几个坑

mock 跑通,不等于真机跑通。

这次我上了微信真机。

环境是:微信基础库 3.16.1 / iOS 17.2.1 / LazyCodeLoading: false

我只挑几个最值得记的说。

我原本以为返回页面时是:

复制代码
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 / 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 惰性求值时序

相关推荐
梦想是准点下班1 小时前
androidStudio打包,我又又又忘了
前端
槑有老呆1 小时前
栈队列链表,三个故事就懂了
前端
ViavaCos1 小时前
pnpm v11 的安全策略,让我踩了个坑
前端
To_OC1 小时前
从一段定时器代码,重新捋清 JS 同步、异步与 Promise
前端·javascript·代码规范
持敬chijing1 小时前
Web渗透之前后端漏洞-XSS漏洞原理攻击防御全流程
前端·安全·web安全·网络安全·网络攻击模型·安全威胁分析·xss
程序员黑豆1 小时前
AI全栈开发 - Java:注释
前端·后端·ai编程
痕忆丶2 小时前
Typora 的替代marktext,marktext切换中文
前端
羊羊小栈2 小时前
Uplift营销供应链协同决策系统(基于Uplift因果推断与运筹优化算法)
前端·人工智能·算法·毕业设计·大作业
阿猫的故乡2 小时前
Vue组合式函数(Composables)从入门到实战:鼠标跟踪、请求封装、本地存储……全案例拆解
前端·vue.js·计算机外设