Vue 项目里被忽视的一层:Page Runtime Context

28 阅读3分钟

在很多 Vue 项目中,我们会习惯性把状态分成两类:

  • 组件状态:写在 datasetup
  • 全局状态:放在 Vuex / Pinia 里

但当系统规模变大之后,你会发现有一类状态越来越难管理:

页面状态(Page State)。


一个常见但难受的场景

我们团队在做一个 Vue 2.6 的微前端系统(single-spa 架构)。

系统里有不少复杂页面,例如:

  • 转化漏斗分析
  • 行为数据仪表盘
  • 事件流程分析

这些页面都有一些共同特点:

  • 组件层级很深(10+ 子组件很常见)
  • 组件间通信频繁
  • 状态生命周期跟页面走

举个典型例子:

一个筛选条件变化,可能需要:

  • 图表刷新
  • 表格刷新
  • 指标刷新
  • Tab 切换

也就是说:

多个组件共享一组页面状态。


我们一开始用 Vuex

最直觉的方案当然是:

把这些状态放进 Vuex。

但很快就出现问题。

问题一:状态残留

用户:

漏斗详情页
→ 事件分析页
→ 再回到漏斗详情

Vuex 里的筛选条件还在。

于是你只能:

beforeDestroy() {
  resetVuexModule()
}

但每个页面都要写一遍 reset。

写漏一次就是 bug。


问题二:store module 膨胀

每个复杂页面一个 module:

funnelDetail
eventAnalysis
behaviorDashboard

Vuex store 很快就变成:

store/
  funnelDetail
  eventAnalysis
  behaviorDashboard
  ...

问题是:

这些状态 99% 时间根本不需要存在。

但 Vuex 是 应用级生命周期

它们会一直留在 store 里。


问题三:Vuex 的仪式感太重

Vuex 的修改链路:

commit
↓
mutation
↓
state

对于页面内部状态来说,这个流程太重了。

很多时候只是:

筛选条件改了
↓
刷新数据

不需要 mutation 审计。


我们也试过其他方案

provide / inject

优点:

  • 可以避免 props drilling

问题:

  • 只能传数据
  • 不能传事件

组件 A 想通知组件 B:

筛选条件变了

provide / inject 做不到。


全局 EventBus

在微前端里我们本来就有一个:

$micRootBus

但如果用它做页面通信,会出现三个问题。

1 命名冲突

this.$micRootBus.$emit('filter:change')

漏斗页面和事件页面都有 filter。

谁的?


2 内存泄漏

beforeDestroy() {
  $off(...)
  $off(...)
  $off(...)
}

页面里 8 个事件监听。

漏一个就是泄漏。


3 调试困难

事件扩散到全局:

谁在 emit?
谁在监听?

调试非常痛苦。


props drilling

组件层级深的时候会变成:

Page
↓
Container
↓
Panel
↓
Chart
↓
Table

中间几层只是传话筒。

代码可读性非常差。


问题的本质

折腾一圈之后我们发现:

问题其实很简单。

Vue 应用里其实缺了一层:

Component State????Global State

中间缺的那一层就是:

Page Runtime Context

也就是:

页面级运行时上下文

它应该具备几个特点:

  • 作用域:页面级
  • 生命周期:跟页面走
  • 能管理状态
  • 能做组件通信
  • 页面销毁时自动回收

我们做了一个实验:vue-page-store

于是我们写了一个很小的库:

vue-page-store

核心思路很简单:

用一个隐藏的 Vue 实例承载 state + computed,再加一个作用域隔离的事件总线。


定义一个页面 Store

import { definePageStore } from 'vue-page-store'

export const useFunnelStore = definePageStore('funnelDetail', {
  state: () => ({
    filters: { dateRange: [], platform: '' },
    loading: false,
    funnelSteps: [],
  }),

  getters: {
    isReady() {
      return !this.loading && this.funnelSteps.length > 0
    },
  },

  actions: {
    async fetchData() {
      this.loading = true
      try {
        this.funnelSteps = await api.getFunnelSteps(this.filters)
      } finally {
        this.loading = false
      }
    },
  },
})

API 风格基本对齐 Pinia:

state
getters
actions

在组件中使用

const store = useFunnelStore()

store.filters

store.filters = newFilters

store.fetchData()

store.$patch({
  loading: true,
  filters: newFilters
})

没有:

commit
mutation
mapState

直接读写。


页面作用域事件通信

这是 vue-page-store 和 Pinia 最大的区别。

我们内置了一个 页面作用域事件总线

store.$emit('filter:change', filters)

store.$on('filter:change', (filters) => {
  this.refreshChart(filters)
})

关键点是:

每个 store 实例
都有独立事件空间

不会污染全局。


页面销毁时自动回收

页面根组件:

beforeDestroy() {
  useFunnelStore().$destroy()
}

会自动清理:

  • state
  • computed
  • watchers
  • 事件监听

下次进入页面:

重新创建一个 store。


它不是 Pinia 的替代品

vue-page-store 解决的是另一层问题:

方案作用域生命周期
Vuex全局应用级
Pinia全局应用级
vue-page-store页面页面级

推荐组合:

Vuex / Pinia → 管全局
vue-page-store → 管页面

实现原理其实很简单

核心只有几件事:

隐藏 Vue 实例
↓
响应式 state
↓
computed getters
↓
闭包事件总线
↓
store registry

整个库:

  • 核心代码约 100 行
  • gzip 后 < 3KB

最后

如果你也在做:

Vue 2.6
微前端
复杂页面

可以试试:

npm install vue-page-store

GitHub
github.com/weijianjunw…

npm
www.npmjs.com/package/vue…


一句话总结

Vue 应用其实有三层状态:

组件状态
↓
页面状态
↓
全局状态

Vuex / Pinia 解决了第三层。

但第二层:

一直是空白。