这不是一篇"我造了个轮子"的文章
这篇文章不是来推销一个开源库的。
我做了 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;基于它计算的 currentPage、selectedIds、isAllSelected 放 state。前者是接口给你的事实,后者是用户操作的业务状态。
决策二: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 下的页面有两种节奏:"创建一次"和"可见多次"。mounted 加 activated 复制粘贴,是 Vue 老开发者的通病 —— 你既想 mount 时做的事在 activated 时也做,又怕 activated 比 mount 多跑一次。
把它拆成 init(一次)+ enter(每次可见)+ leave(每次离开)三段,正好对齐心智,消灭一类复制粘贴。
决策三: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 透传或 $bus | inject: ['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:内置 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。
但我会用同样的方法,找到那个项目里真正值得被抽象、被治理、被沉淀的东西。
一个工程师真正的核心能力,不是"写出了什么",而是"在混乱里看见了什么"。
看见之后,工具只是结果。
工具会过时,判断力不会。
开源信息
- GitHub: weijianjunwjj/vue-page-store
- npm:
vue-page-store
当前版本已在小范围复杂页面中验证,仍不建议无脑全项目引入。v0.6 roadmap 公开。
欢迎所有针对设计的争议 —— 比 star 更值钱。