在 Vue 2.6 微前端架构中,我们为什么放弃了 Vuex 管理页面状态
背景:一个越来越"重"的页面
我们团队用 single-spa 搭了一套微前端架构,主技术栈是 Vue 2.6 + Element UI。系统里有不少复杂页面------转化漏斗分析详情、行为数据分析仪表盘、事件流程分析......这类页面的共同特点是:
- 组件层级深,一个页面拆成 10+ 子组件很常见
- 组件间通信频繁,筛选条件变了、Tab 切了、日期选了,好几个组件要同步响应
- 状态生命周期跟页面走,进来要初始化,离开要清干净
一开始我们用 Vuex 管这些状态,很快就发现不对劲。
Vuex 管页面状态,哪里不对?
第一个问题:状态残留。 用户从漏斗详情页跳到事件分析页,再跳回来,Vuex 里上一次的筛选条件还在。你说用 beforeDestroy 里手动 reset?可以,但每个页面都要写一遍,写漏了就是 bug。
第二个问题:命名空间膨胀。 每个复杂页面一个 Vuex module,funnelDetail/setFilters、eventAnalysis/setFilters、behaviorDashboard/setFilters......全局 store 越来越臃肿,而这些 module 99% 的时间都不需要存在。
第三个问题:Vuex 的仪式感太重。 改一个状态要经过 commit → mutation → state,对于页面内部的交互状态来说,这个链路完全多余。筛选条件变了就该直接改,不需要走 mutation 审计。
我们试过的其他方案
provide / inject------只能传数据,不能传事件。组件 A 想通知组件 B "筛选变了,你该刷新了",provide/inject 做不到。
全局 EventBus($micRootBus) ------我们微前端里有一个全局事件总线。但拿它做页面内通信,三个致命问题:
kotlin
// 1. 命名冲突:漏斗详情和事件分析都有 filter:change
this.$micRootBus.$emit('filter:change', filters) // 谁的 filter?
// 2. 内存泄漏:每个 $on 都要手动 $off,页面销毁时漏一个就泄漏
beforeDestroy() {
this.$micRootBus.$off('funnelDetail:filter:change', this.handler1)
this.$micRootBus.$off('funnelDetail:tab:change', this.handler2)
this.$micRootBus.$off('funnelDetail:date:change', this.handler3)
// ... 8 个地方全要清,漏一个就寄
}
// 3. 边界模糊:事件扩散到全局,debug 时不知道谁在监听
组件 data + props 层层传递------5 层组件传一个筛选条件,中间 3 层只是当传话筒。经典的 props drilling 地狱。
每个方案都差点意思。我们需要的是一个页面级别的运行时上下文------状态、通信、副作用,全部限定在当前页面的作用域里,页面销毁时一键回收。
于是我们造了 vue-page-store
核心思路很简单:用一个隐藏的 Vue 实例承载响应式 state + computed getters,再加一个闭包隔离的事件总线,生命周期绑定在一起。
npm install vue-page-store
定义一个页面级 Store
javascript
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,用过 Pinia 的人零学习成本。
组件中使用
scss
const store = useFunnelStore()
// 直接读
store.filters
store.isReady
// 直接改
store.filters = newFilters
// 调 action
store.fetchData()
// 批量更新
store.$patch({ loading: true, filters: newFilters })
没有 commit,没有 mutation,没有 mapState。直接属性访问,直接赋值。
页面内通信:作用域隔离的事件
这是 vue-page-store 和 Pinia 最大的区别。我们内置了一个页面作用域级的事件总线:
javascript
// 组件 A ------ 发射事件
store.$emit('filter:change', newFilters)
// 组件 B ------ 监听事件
const off = store.$on('filter:change', (filters) => {
this.applyFilters(filters)
})
重点来了: _listeners 是闭包内的私有变量,每个 store 实例独立一份。 漏斗详情的 filter:change 和事件分析的 filter:change 完全隔离,互不干扰。
为什么不拆成独立的 EventBus?因为生命周期要跟 store 绑定。$destroy 的时候自动清空所有 listeners:
scss
store.$destroy = () => {
// 清空事件 ------ 不会泄漏
Object.keys(_listeners).forEach(key => delete _listeners[key])
// 销毁 Vue 实例 ------ 回收 watchers
vm.$destroy()
// 移除注册 ------ 下次进来是全新的
storeRegistry.delete(id)
}
调用方(子组件)只需要注入 store 就能通信,不需要感知全局 Bus,不需要手动 $off,不需要加命名前缀。
页面销毁:一行代码全部回收
scss
// 页面根组件
beforeDestroy() {
useFunnelStore().$destroy()
}
state、getters、watchers、事件监听------全部清干净。下次进这个页面,又是一个全新的 store。
它不是 Pinia 的替代品
这一点必须说清楚。vue-page-store 解决的是 Vuex / Pinia 覆盖不到的那个中间地带:
| Vuex | Pinia | vue-page-store | |
|---|---|---|---|
| 作用域 | 全局 | 全局 | 页面级 |
| 生命周期 | 应用级 | 应用级 | 页面级($destroy 回收) |
| 事件通信 | 无 | 无 | 内置 <math xmlns="http://www.w3.org/1998/Math/MathML"> e m i t / emit/ </math>emit/on(作用域隔离) |
| Vue 2.6 支持 | ✅ | ⚠️ 需 @vue/composition-api | ✅ 原生支持 |
| 适合管什么 | 用户信息、权限、全局配置 | 同左 | 复杂页面内部状态 |
推荐组合:Vuex 管全局,vue-page-store 管页面。 各管各的,互不干扰。
声明式 watch:页面级副作用的自动管理
除了状态和事件,页面里还有一类东西需要管理------副作用。比如"查询时间范围变了,自动判断是否按小时查询":
javascript
export const useFunnelStore = definePageStore('funnelDetail', {
state: () => ({ /* ... */ }),
getters: {
isQueryByHour() {
const range = this.filters?.dateRange
return (new Date(range[1]) - new Date(range[0])) / 3600000 <= 24
},
},
watch: {
'isQueryByHour'(val) {
if (!val) this.tabTime = 'hour'
},
},
})
声明式写法,定义的时候绑上去,$destroy 的时候跟着 Vue 实例一起销毁。不需要手动 $watch 再手动 unwatch。
实现原理:100 行代码
核心实现非常简单,整个库不到 200 行,核心逻辑 100 行出头:
new Vue({ data: { $$state }, computed })------ 一个隐藏的 Vue 实例,承载响应式和 computedObject.defineProperty代理 ------ 把 state 和 getters 暴露到 store 对象上- 闭包内的
_listeners对象 ------ 作用域隔离的事件总线 storeRegistryMap ------ 保证同一个 id 只有一个实例
没有黑魔法,没有额外依赖,gzip 后不到 3KB。
最后
如果你也在用 Vue 2.6 + 微前端架构,遇到了页面级状态管理的痛点,可以试试:
npm install vue-page-store
Vue 3 项目推荐用 Pinia,这个库专为 Vue 2.6 场景设计。
如果对你有帮助,欢迎 star ⭐️,有问题直接提 issue。