复杂中后台页面状态治理:我为什么做 vue-page-store

31 阅读8分钟

这不是一篇"我造了个轮子"的文章

这篇文章不是来推销一个开源库的。

我做了 vue-page-store,发在了 npm 上,但这篇文章想讲的不是它有多好用,而是 —— 做它之前的那些判断

为什么在那个时间点、那个项目里,我认为"页面级状态治理"是一个值得抽象的问题?

为什么我没有选 Vuex、没有选 Pinia、没有走 provide / inject 直接撑过去?

为什么我做着做着,把一开始想做的"低代码页面引擎"砍掉了,只留下一个非常窄的"页面作用域"?

因为换一个项目,我不一定还会做 vue-page-store。但我会用同样的方法,在那个项目里找到值得被抽象的东西。

工具是结论。判断什么值得被抽象,才是能力本身。


一、我看到的不是"代码乱",是作用域缺失

先看一段代码。这是一个典型的中后台详情页:

// SomeDetailPage.vue —— 800 多行的样子
export default {
  mixins: [tableMixin, exportMixin, polling],

  data() {
    return {
      // 筛选区
      keyword: '',
      dateRange: [],
      statusFilter: 'all',
      categoryFilter: null,

      // 列表
      page: 1,
      pageSize: 20,
      total: 0,
      listData: [],
      selectedIds: [],

      // 加载状态
      loading: false,
      exportLoading: false,
      detailLoading: false,
      submitLoading: false,

      // 弹窗
      detailDialogVisible: false,
      editDialogVisible: false,
      deleteDialogVisible: false,
      exportDialogVisible: false,

      // 当前操作行
      currentRow: null,
      currentDetail: null,
      editingForm: {},

      // 子组件 ref 用
      filterRef: null,
      tableRef: null,
      chartRef: null,

      // 副作用相关
      pollingTimer: null,
      autoRefreshEnabled: false,
      lastFetchTime: null
      // ... 还有十几个
    }
  },

  watch: {
    keyword() { this.page = 1; this.fetchList() },
    dateRange: { handler() { this.fetchList() }, deep: true },
    statusFilter() { this.fetchList() }
    // ...
  },

  mounted() {
    this.fetchList()
    this.loadDictOptions()
    this.bus.$on('child:refresh', () => this.fetchList())
    if (this.autoRefreshEnabled) {
      this.pollingTimer = setInterval(this.fetchList, 5000)
    }
  },

  activated() {
    this.fetchList()
    // 这里和 mounted 是不是要写一样?每次都搞不清
  },

  beforeDestroy() {
    if (this.pollingTimer) clearInterval(this.pollingTimer)
    this.bus.$off('child:refresh')
    // 还有什么要清的?忘了就泄漏
  }
}

我写过这种文件,你大概也写过。

这种文件最容易被吐槽的话是:"组件太大了,该拆。"

但拆完之后呢?状态还是要共享、loading 还是要互相依赖、子组件还是要触发父级刷新 —— 这些问题不会因为文件变小就消失。它们只是从一个 800 行的文件,被搬到了五个 200 行的文件里。

问题不在"文件",而在一件更根本的事:这个页面里其实有三层不同的关注点,被压扁在了同一层。

该谁管现状
全局层(用户、权限、路由、应用主题)Vuex / Pinia✅ 有人管
页面层(筛选状态、列表数据、loading、轮询、跨子组件联动)没人管❌ 真空
组件层(单个 Dialog 开关、单个 input 值)组件 data✅ 有人管

中间这一层是真空

所以工程师就用最近的方案去填它:Vuex 模块塞页面状态、provide / inject 撑响应式、$bus 跨组件喊话、mixin 堆复用逻辑、严重一点的甚至用 window 全局变量兜底。

每一种填法都不舒服,因为这些方案的设计目标本来不是干这件事的。

复杂中后台页面真正的问题不是"代码写得乱",而是项目里没有一个名字叫"页面"的作用域


二、为什么这个判断在那个项目里特别明显

我面对的是这样一类项目(脱敏描述):某复杂中后台数据分析平台,Vue 2.6 老系统,微前端架构,十来个子应用,每个子应用都有大量动态图表、配置页、详情页。

在这种项目里,以下几件事是反复发生的:

同一类页面在不同子应用里被重复实现了 N 次。 "复杂详情页"是这类系统里最常见的页面形态 —— 筛选 + 列表 + 图表 + 多个 Dialog + 跨组件联动。每个团队接到这种需求,都要重新发明一遍状态管理方式。有人塞 Vuex,有人用 mixin,有人在 data 里堆 30 个字段,有人甚至直接 window.__pageState = {}

不同人写出的复杂页面风格差得像五套体系。 同一类页面,有的人写得像状态机,有的人写得像意识流。这不是"个人水平"问题 —— 是没有共同的施工规范。每个人都凭自己理解发明轮子,最后系统里飘着五种不同的"页面状态管理风格"。

新人接手要花很长时间才能搞清楚状态从哪来。 一个详情页里,tableLoading 这个字段,可能来自 mixin、可能来自 props、可能来自 Vuex 局部模块、可能来自 $root.$on 监听。新人接手时不得不一个个 grep 才能确认。这种成本是隐形的,但累积起来非常致命。

三类 bug 反复出现:

  • 销毁后异步回写导致脏数据
  • 轮询定时器泄漏,页面切走还在请求
  • keep-alive 切回时,有的状态刷新了,有的没刷新,有的刷新了两次

这些 bug 的共同点是:它们都和"页面这个作用域的生命周期"有关 —— 但项目里没有一个东西在管这个生命周期。

单页面写得乱,可以靠 review 解决。 但一类页面长期没人管,只能靠抽象解决。


三、决定"抽象什么"比"怎么实现"更难

意识到问题后,我没有立刻动手写代码。

我先花了两周时间问自己一个问题:这个东西到底应该做成什么?

下面这几件事,我没有做

没做"通用状态管理库"

这是最容易掉进去的坑 —— 看到状态散落,第一反应是"我做一个比 Vuex 更轻的状态管理"。

但这件事 Pinia 已经做得很好了。我做不出比 Pinia 更轻的全局状态管理。和专业户抢饭吃,赢不了

更重要的是,我要解决的不是"应用状态模块化"这个问题。是另一个问题:页面这一层的作用域真空。

没做"低代码页面引擎"

这是更大的诱惑。

我一开始确实尝试过这个方向。设想是:把"复杂详情页"这一类页面抽象成一个 schema —— 描述清楚有哪些筛选字段、哪些 column、哪些 action、哪些联动,然后引擎自动渲染。我甚至写出来一版 definePageRuntime 的 POC,跑通了两个简单页面。

跑到第三个页面我就停下了。

原因很简单:复杂页面之间的共性,不足以支撑 schema 化的代价

每一个真实业务页面都有它独特的"长尾需求":某个 column 要 customize render、某个 filter 要联动接口、某个 dialog 关闭后要刷新两个不同的子模块。schema 越想兼容这些需求,就越像在重新发明 Vue 模板。

走到那一步,我意识到:这条路再走下去,我做出来的不是引擎,是一层比直接写代码更难用的中间层。

撤了。

没做"组件库"

组件层的活,自定义 UI 库已经在做。我没必要重复造。

没把"事件总线"作为主通信模型

这一条要单独说一下,因为我在 vue-page-store 里其实保留了 $on / $emit / $off,看上去和我"反对 EventBus"的姿态有冲突。

我的判断是:跨组件通信主路径,必须是状态(state / source / actions / getters)。事件总线只能作为低频逃生通道 —— 比如"某弹窗关闭后通知局部区域更新"这种,塞进状态会很别扭,event 是最自然的表达。

所以 vue-page-store 里的 $on 不是"EventBus 复辟",是"绳子拴着的工作犬":作用域被锁死在当前 store 内、跟着页面销毁自动清理、不会污染全局。

它是工具,不是反模式;但它必须有使用边界 —— 主路径不是它

我抽象的:页面这个作用域本身

撤掉上面四个方向后,我手里只剩下一件事:做一个能装下"状态 + 生命周期 + 异步 + loading + 定时器 + 副作用清理"的页面级容器。

不多做,不少做。

抽象的价值不在于"加了什么",在于"你判断了哪些不该加"。 一个工程师的成熟度,体现在他敢说"这件事我不做"的清晰度上。


四、抽象出来的"页面作用域"长什么样

到这里才开始讲技术细节,但讲的不是 API 列表,是设计决策

vue-page-store 最关键的四个决策:

决策一:source 与 state 分层

definePageStore('orderList', {
  // 接口原始返回 / 外部输入 / 路由 query,放这里
  source: () => ({
    response: null,
    query: {}
  }),

  // 业务状态,放这里
  state: () => ({
    keyword: '',
    page: 1,
    selectedIds: [],
    deleteDialogVisible: false
  })
})

为什么分层?

如果不分,接口原始返回和业务状态混在一起,$reset 就没法清晰定义 —— 你到底是把接口数据清成 null,还是把业务状态清成初始值?分开之后,语义自动浮现:$reset() 同时重置两者,各自回到工厂函数的初始值。

边界示例:接口返回 {list: [...], total: 100}$source.response;基于它计算的 currentPageselectedIdsisAllSelectedstate前者是接口给你的事实,后者是用户操作的业务状态。

决策二:init / enter / leave 三段式生命周期

{
  // 一次性:store 创建后调用一次,$vm 已可用,DOM 未就绪
  init() {
    this.loadDictOptions()             // 拉字典
    this.$on('child:refresh', () => this.search())   // 注册事件
  },

  // 每次页面可见:包括 keep-alive 切回
  enter() {
    this.$source.query = this.$vm.$route.query
    this.search()
    this.$setInterval(() => this.search(), 5000)
  },

  // 每次离开:interval 自动清,通常不用写
  leave() {}
}

为什么这么分?

keep-alive 下的页面有两种节奏:"创建一次"和"可见多次"。mountedactivated 复制粘贴,是 Vue 老开发者的通病 —— 你既想 mount 时做的事在 activated 时也做,又怕 activated 比 mount 多跑一次。

把它拆成 init(一次)+ enter(每次可见)+ leave(每次离开)三段,正好对齐心智,消灭一类复制粘贴

决策三:loading/loading / setInterval 收回容器

<!-- 模板里直接用,不需要手动 set loading -->
<el-button :loading="pageStore.$loading.search" @click="pageStore.search">
  搜索
</el-button>
// 定时器交给容器,leave 自动清
this.$setInterval(() => this.search(), 5000)

为什么?

loading 和定时器是页面副作用,不该让业务代码自己管生灭。容器代管之后,业务代码只剩"写业务",不再操心"清理"。

关于并发,一个说明:$loading.xxx 只追踪最后一次 action 的 true/false,不是引用计数。同一个 action 短时间内被连点两次,UI 层应通过 :disabled="$loading.xxx" 自行去重,框架不强行防抖。

决策四:destroyed write guard ——销毁后回写兜底

actions: {
  async fetchData() {
    const data = await api.getData()
    // 即使页面已销毁,这里也不会报错,框架自动忽略写入
    this.$source.response = data
  }
}

为什么?

"页面销毁后异步回写"是 Vue 老系统里的高频脏 bug。让每个业务代码都加 if (this._isDestroyed) return 是反人类的。框架层兜一次,业务代码就干净了。

范围声明: 它只解决"页面离开 / 销毁后的脏写"这一类问题。不解决所有请求乱序问题 —— 真正的请求乱序(请求 A 先发后返回、请求 B 后发先返回,A 覆盖 B)仍然建议在 action 内用 requestId 或 AbortController 处理。

注意:dev 环境会打 warning 提示有 stale write,生产环境静默兜底。这不是"掩盖 bug",是"减少业务代码噪声 + 给开发者保留可见信号"。


每一个决策背后,都不是"我觉得这样设计很优雅",而是"我在那个项目里被这件事坑过具体几次"。

好的抽象不是脑子里想出来的,是被业务逼出来的。


五、接入前后,一个真实页面发生了什么

不贴完整代码,贴对比表:

维度接入前接入后
状态字段位置散落 data / mixin / Vuex 局部模块集中在 state / source
字段数量30+8–12
异步请求触发子组件各自发actions 收口
loading 管理手动 this.loading = true / false$loading.xxx 自动
轮询定时器裸 setInterval + beforeDestroy 清理$setInterval,leave 自动清
keep-alive 切回activated 写一遍,容易和 mounted 重复enter 写一次
销毁后异步回写偶发脏数据,需手动 isDestroyed 检查destroyed write guard 兜底
子组件取状态props 透传或 $businject: ['pageStore']
行数800+400 左右

接入前是"一堆零件凑出一个页面",接入后是"一个页面用零件搭出来"。

但要说在前面:行数减少不是核心收益

核心收益是 —— 状态边界变清楚了,副作用有归属了,新人接手时知道去哪里找东西了。

行数下降只是这些事的副产物。别把副产物当主菜


六、我知道你会问这些问题

技术博客最容易被诟病的是"作者只讲优点"。所以这一节,我自己来挑刺。

Q1:为什么不用 Pinia 的 setup store?

Pinia 是更成熟的通用状态管理方案,我不认为 vue-page-store 应该替代 Pinia。

我当时没有选 Pinia,核心原因不是"Pinia 不行",而是项目处在 Vue 2.6 + Options API + 老页面渐进治理 + keep-alive 高频使用的环境里。我需要的不是一个新的全局状态库,而是一套页面级约束:enter / leave、page-scoped timer、loading 收口、source / state 分层、销毁后回写兜底。

这些约束 Pinia setup store 可以做到,但需要团队自己搭一遍 ——onScopeDispose、keep-alive hook、定时器封装、loading 工具,每个都要单独抽。而我的项目没有那个迁移预算。

如果你的项目已经是 Vue 3 + Pinia 稳定落地,我不建议引入 vue-page-store。 更合理的做法是借鉴"页面作用域"的思想,在 Pinia / composable 上实现类似约束。

Q2:页面级作用域是不是伪需求?

对简单页面是,对复杂页面不是。

我给一个明确的使用门槛 —— 至少满足下面 3 条中的 2 条:

  • 3 个以上核心子模块(筛选 / 列表 / 图表 / 详情等)
  • 多个请求联动(列表刷新触发图表刷新等)
  • 多副作用(轮询、定时器、跨组件状态共享、keep-alive)

简单 CRUD 不需要它,普通表单页也不需要。

我不推荐把它作为全项目标配。 简单页面用组件 data 就够了,不要为了"用上新工具"硬接入。

Q3:内置 on/on / emit 不就是 EventBus 复辟吗?

有这个风险,我承认。

所以在 vue-page-store 里,event 是低频逃生通道,不是主路径。

主路径是:状态共享用 state / source / getters,业务动作用 actions,状态派生用 getters / watch。只有少数横切通知才用 event(比如某弹窗关闭后通知一个局部区域更新)。

scoped event 解决的是污染范围,不天然解决隐式耦合 —— 这点必须自律。我建议团队接入时配套规则:event 名必须枚举化、禁止 event 承载长期状态、禁止跨页面 event。

Q4:singleton 模型怎么处理多实例页面?

当前已知限制。

同路由多 tab、动态路由参数变化、同组件多实例化(比如表格里嵌套同款 Dialog)—— 这三种场景下当前的 id → singleton 模型会出问题。

v0.6 roadmap 已规划 storeId + scopeKey → instance 模型来解决这件事。

当前版本不推荐用于多实例场景。这是诚实问题,不藏着。

Q5:单人项目能用在生产吗?

诚实回答:适合在复杂页面小范围试点,不适合"团队全面引入"

它是治理工具,不是基础设施。如果你的团队想引入,我的建议是:挑 1–2 个最混乱的复杂页面试点,验证 2–4 周,有效果再考虑扩散。出问题随时回退,不要把它放在系统主路径上。

一个工程师对自己作品的诚实程度,决定了这个作品能走多远。


七、为什么我把这件事写出来

vue-page-store 不是我想卖给所有项目的工具。

它是我在复杂业务里做过的一次工程判断样本

样本里包含的不只是代码,还有:

  • 看出"页面这个作用域是真空的"那一刻
  • 决定不做低代码引擎那一刻
  • 决定保留 event 但不让它当主路径那一刻
  • 决定销毁后回写静默兜底而不是抛错那一刻
  • 决定坦白 singleton 是限制而不是藏起来那一刻

换一个项目,我不一定还会做 vue-page-store。

但我会用同样的方法,找到那个项目里真正值得被抽象、被治理、被沉淀的东西。

一个工程师真正的核心能力,不是"写出了什么",而是"在混乱里看见了什么"。

看见之后,工具只是结果。


工具会过时,判断力不会。


开源信息

当前版本已在小范围复杂页面中验证,仍不建议无脑全项目引入。v0.6 roadmap 公开。

欢迎所有针对设计的争议 —— 比 star 更值钱。