# 一段你大概率写过的代码 写的时候挺顺手的,对吧?但三个月后你盯着那堆字段,一个都不敢删——因为根本不知道谁在依赖它们。半年后页面已经重写了新版,这个 store 还稳稳地躺在项目里,唯一的原因是:**你也不确定它是不是真的没人用了**。 这还真不是 Pinia 的锅。这其实是一个结构性的老问题—
小心!Pinia 可能已变成第二个 localStorage
作者:技术拾光
时间:2026-06-02
浏览:299
# 一段你大概率写过的代码
写的时候挺顺手的,对吧?但三个月后你盯着那堆字段,一个都不敢删——因为根本不知道谁在依赖它们。半年后页面已经重写了新版,这个 store 还稳稳地躺在项目里,唯一的原因是:**你也不确定它是不是真的没人用了**。
这还真不是 Pinia 的锅。这其实是一个结构性的老问题——**复杂页面的状态,根本就没有一个明确的归属**。
---
## 前端状态的三层分布
这还真不是 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. 页面组件里使用
共 {{ orderScope.total }} 条
### 3. 子组件不需要 import
子组件**不需要知道父页面用的是哪个 scope 定义**。统一 `injectPageScope()`,零耦合。
---
## 几个能直接看出价值的细节
### Auto Bridge:$route/$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
}
}
搜索
不需要包装器,不需要手写 `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,全部绑定在页面可见性生命周期上。
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**——它可能是你要找的中间层。实用工具推荐
电子包浆
使用 ›绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
图片压缩
使用 ›选择画质和输出格式,预览压缩前后的大小。
相关文章