复杂页面状态一旦没有边界,Pinia/Vuex 很容易从状态管理工具变成"临时垃圾场"。 我用 Vue 3
effectScope做了一个 Page Scope,把页面状态、副作用和生命周期重新关回边界里。仓库:github.com/weijianjunw… · npm:
vue-page-scope
一段你大概率写过的代码
// 某个后台业务页面的 Pinia store
export const useOrderListStore = defineStore('orderList', {
state: () => ({
keyword: '',
page: 1,
pageSize: 20,
selectedIds: [],
deleteDialogVisible: false,
deleteConfirmLoading: false,
detailDrawerVisible: false,
currentDetailId: null,
columnsConfig: [],
tempEditDraft: null,
pollTimer: null, // ← 这条尤其刺眼
lastFetchedAt: null,
activeTab: 'basic',
}),
})
写的时候没问题。三个月后你不敢删任何一个字段,因为不知道谁在用。半年后页面已经重写过一版,但这个 store 还在,因为你也不确定它是不是真的没人用了。
这不是 Pinia 的问题。这是一个结构性问题:复杂页面的状态,没有归属。
前端状态的三层分布
┌─────────────────────────────────────────────────┐
│ 应用级状态 │
│ 用户信息 / 权限 / 主题 / 路由 │ ← Pinia
│ 生命周期:跟应用一起活,通常不销毁 │
├─────────────────────────────────────────────────┤
│ 页面级状态 │
│ 筛选条件 / 表格分页 / 弹窗 / 轮询 / 草稿 │ ← Page Scope
│ 生命周期:跟页面可见性走,离开/销毁时回收 │ ★ 长期被忽视的中间层
├─────────────────────────────────────────────────┤
│ 组件级状态 │
│ 输入框 / UI 局部态 / 私有交互 │ ← ref / reactive
│ 生命周期:跟组件实例走 │
└─────────────────────────────────────────────────┘
中间这一层是被忽视的。它太大,塞不回组件;又不全局,不该污染 Pinia。
于是它常年没家。要么被塞进 Pinia 变成第二个 localStorage,要么散落在 ref 里靠 provide / inject 互相摸黑握手,要么写一堆 watch + onBeforeUnmount 凑出一个手工版本的"页面作用域"。
Page Scope 是什么
一句话:给复杂页面加一个隔离舱。
┌────────────────────────┐
│ Page Component │
│ (Owner: setup 内) │
└───────────┬────────────┘
│
│ useOrderScope()
▼
┌───────────────────────────────────────────┐
│ PageScope (基于 Vue 3 effectScope) │
│ │
│ source state getters │
│ actions watch $loading │
│ $setInterval event bus $route 桥接 │
│ plugins (任意外部扩展) │
│ │
└─────────────────────┬─────────────────────┘
│
│ 页面离开 / 销毁
▼
effectScope.stop()
↓ 自动回收所有响应式副作用 ↓
(watch / computed / $setInterval / plugin watchers)
页面进入时创建,页面运行时承载所有副作用,页面离开时一次性回收。stop() 一行,垃圾全清。
真实代码长什么样
1. 定义一个 Page Scope
// scopes/order-list.ts
import { definePageScope } from 'vue-page-scope'
export const useOrderScope = definePageScope('orderList', {
// 页面输入 / 接口原始返回
source: () => ({
response: null,
query: {},
}),
// 业务状态
state: () => ({
keyword: '',
page: 1,
selectedIds: [],
deleteDialogVisible: false,
}),
// 派生计算
getters: {
list() { return this.$source.response?.list || [] },
total() { return this.$source.response?.total || 0 },
hasSelection() { return this.selectedIds.length > 0 },
},
// 业务方法 —— 返回 Promise 的 action 自动追踪 $loading
actions: {
async search() {
const res = await api.getOrders({
keyword: this.keyword,
page: this.page,
})
this.$source.response = res
},
},
// 一次性初始化(拉字典、注册监听等)
init() {
this.loadDictOptions()
},
// 每次页面可见时执行(keep-alive 切回也会)
enter() {
this.$source.query = this.$route.query // ← 直接用 $route,见下文 auto bridge
this.search()
this.$setInterval(() => this.search(), 5000) // ← 页面级定时器
},
// 离开时 $setInterval 自动清,通常不需要写
leave() {},
})
2. 页面组件里使用
<script setup>
import { useOrderScope } from '../scopes/order-list'
// 必须在 setup 内调用,该组件成为 scope 的 owner
// 不需要传 $route / $router —— 框架自动桥接
const orderScope = useOrderScope()
</script>
<template>
<input v-model="orderScope.keyword" />
<button
:loading="orderScope.$loading.search"
@click="orderScope.search"
>
搜索
</button>
<p>共 {{ orderScope.total }} 条</p>
</template>
3. 子组件不需要 import
<!-- FilterPanel.vue -->
<script setup>
import { injectPageScope } from 'vue-page-scope'
const scope = injectPageScope() // ← 自动拿到父级页面的 scope
</script>
<template>
<input v-model="scope.keyword" />
</template>
子组件不需要知道父页面用的是哪个 scope 定义。统一 injectPageScope(),零耦合。
几个能直接看出价值的细节
Auto Bridge:router 不用手动传
Vue 2 时代 vue-page-store 通过 $vm 隐式持有组件实例,scope 内可以 this.$vm.$route.query。Vue 3 没有这个传统,如果硬要保留体验,你会写成:
// ❌ 用户每个页面都要这么写
const orderScope = useOrderScope({
$route: useRoute(),
$router: useRouter(),
})
我考虑过这个方案,然后否决了 —— 框架的复杂度该由框架吃,不是用户。所以 vue-page-scope 默认行为是:
// ✅ 直接这样写
const orderScope = useOrderScope()
// scope 内部 enter / actions / watch 直接用
this.$route.query
this.$router.push('/...')
实现细节:框架内部通过 getCurrentInstance().proxy.$route 桥接,不 import vue-router,装了就用,没装就 noop。如果你用的是微前端 / 自研路由,还能显式覆盖:
const orderScope = useOrderScope({
$route: microAppRoute, // 显式注入优先级高于 auto bridge
$router: microAppRouter,
$user: useUserStore(), // 也可以注入任意 composables
})
常用门,框架自己开;特殊门,用户再给钥匙。
$loading 自动追踪
actions: {
async search() {
const res = await api.getOrders(...)
this.$source.response = res
}
}
<el-button :loading="scope.$loading.search" @click="scope.search">
搜索
</el-button>
不需要包装器,不需要手写 loading.value = true / finally { loading.value = false }。返回 Promise 的 action 自动追踪,带并发计数(防先返回的请求提前关 loading)。
一次销毁,全部回收
页面销毁(或路由切走的 keep-alive 解绑)时:
effectScope.stop()
↓
所有 watch / computed 释放
$setInterval 清理
plugin destroy 钩子触发
事件总线清空
这才是最值钱的部分。不需要在 onBeforeUnmount 里手动 clearInterval / 手动 stopWatch() / 手动 unsubscribe(),你只要把副作用注册进 scope,scope 销毁就替你一并清理。
异步安全也是默认的:scope 销毁后 async action 还在 pending,await 完赋值不会崩溃,也不会触发任何渲染(因为 watcher 已经全部 stop)。
为什么用 effectScope
Vue 3.2 加的 effectScope 是为这种"作用域响应式容器"专门设计的:
const scope = effectScope(true) // detached,不被父 scope 收编
scope.run(() => {
const state = reactive({ keyword: '' })
watch(() => state.keyword, () => { /* ... */ })
})
scope.stop() // 所有 watch / computed 一行释放
Pinia 内部用的也是 effectScope,只是它的 scope 跟 Pinia 实例走,不跟页面走。
vue-page-scope 做的事很简单:把这个能力提升到页面维度。每个 page scope 是一个独立的 effectScope(true),在 setup 内创建,通过 onBeforeUnmount 统一释放。
它和 Pinia 是什么关系
不是替代关系,是分层关系。
Pinia → 应用级(用户、权限、主题、跨页面共享)
Page Scope → 页面级(筛选、表格、弹窗、轮询、草稿)
ref / reactive → 组件级(输入框、局部 UI 态)
复杂后台项目里,把这三层混在一起是常见状况;把它们分清楚是治理的开始。
演进:从 vue-page-store 到 vue-page-scope
vue-page-scope 不是凭空冒出来的。它的前身是我 2025 年开始写、迭代到 v0.5 的 vue-page-store(Vue 2)。
写到 v0.5 的时候才意识到:这个库一直在做的事情并不是"管理状态",而是在管理一个完整的页面作用域 —— state / source / getters / actions / watch / 生命周期 / 定时器 / 事件总线 / plugin,全部绑定在页面可见性生命周期上。
"store" 这个壳已经装不下它了。
Vue 3 重新实现的时候,把这个发现显性化,起了新名字:vue-page-scope。
vue-page-store (Vue 2) vue-page-scope (Vue 3)
────────────────────── ───────────────────────
"页面级 Store" "页面级 Scope"
hidden Vue instance effectScope(true)
hook:mounted 黑魔法 setup lifecycle hooks
bindTo(vm) 显式绑定 owner 模型自动绑定
$vm 逃生口 auto bridge + injection
叙事同步,版本不同步。根接上,身份证重新办。所以 vue-page-scope 从 0.1.0 起步,不续编 vue-page-store 的 v0.5。
当前进展
vue-page-scope@0.1.0 已发布,包含:
- ✅
definePageScope/useXxxScope/injectPageScope完整核心 API - ✅ Auto bridge + explicit injection 两层路由集成
- ✅ Plugin 协议(跨 Vue 2 / Vue 3 一致)
- ✅ TypeScript 类型(支持注入字段精确类型推导)
- ✅ ESM + CJS 双产物
- ✅ keep-alive 双响炮去重、init 抛错自毁、stale write 异步安全
npm install vue-page-scope
import { definePageScope, injectPageScope } from 'vue-page-scope'
写在最后
这个库不大。如果你的项目里没有复杂后台、没有 keep-alive、没有 5+ 弹窗共存、没有多 tab 配置页 —— 你不需要它。Pinia + 组件 state 完全够用。
但如果你正在维护一个字段越塞越多、不敢删任何东西、页面已经销毁但定时器还在 console 里刷的 Pinia store —— 它可能是你要找的中间层。
以前我更关心"这个页面怎么写出来"。 现在我更关心:这个页面复杂起来以后,怎么不烂掉。
仓库:github.com/weijianjunw…
npm:vue-page-scope
Star / PR / Issue 都欢迎,但真实业务里踩到坑最值钱 —— 那是这个库下一个版本的方向。