在很多 Vue 项目中,我们会习惯性把状态分成两类:
- 组件状态:写在
data或setup里 - 全局状态:放在 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 解决了第三层。
但第二层:
一直是空白。