你项目里的 Pinia,可能已经成了第二个 localStorage

复杂页面状态一旦没有边界,Pinia/Vuex 很容易从状态管理工具变成"临时垃圾场"。 我用 Vue 3 effectScope 做了一个 Page Scope,把页面状态、副作用和生命周期重新关回边界里。

仓库:github.com/weijianjunw... · npm: vue-page-scope


一段你大概率写过的代码

yaml 复制代码
// 某个后台业务页面的 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 的问题。这是一个结构性问题:复杂页面的状态,没有归属。


前端状态的三层分布

sql 复制代码
┌─────────────────────────────────────────────────┐
│  应用级状态                                      │
│  用户信息 / 权限 / 主题 / 路由                  │  ← Pinia
│  生命周期:跟应用一起活,通常不销毁              │
├─────────────────────────────────────────────────┤
│  页面级状态                                      │
│  筛选条件 / 表格分页 / 弹窗 / 轮询 / 草稿        │  ← Page Scope
│  生命周期:跟页面可见性走,离开/销毁时回收        │     ★ 长期被忽视的中间层
├─────────────────────────────────────────────────┤
│  组件级状态                                      │
│  输入框 / UI 局部态 / 私有交互                  │  ← ref / reactive
│  生命周期:跟组件实例走                          │
└─────────────────────────────────────────────────┘

中间这一层是被忽视的。它太大,塞不回组件;又不全局,不该污染 Pinia。

于是它常年没家。要么被塞进 Pinia 变成第二个 localStorage,要么散落在 ref 里靠 provide / inject 互相摸黑握手,要么写一堆 watch + onBeforeUnmount 凑出一个手工版本的"页面作用域"。


Page Scope 是什么

一句话:给复杂页面加一个隔离舱。

scss 复制代码
                  ┌────────────────────────┐
                  │   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

javascript 复制代码
// 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. 页面组件里使用

xml 复制代码
<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

xml 复制代码
<!-- 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: route/route / route/router 不用手动传

Vue 2 时代 vue-page-store 通过 $vm 隐式持有组件实例,scope 内可以 this.$vm.$route.query。Vue 3 没有这个传统,如果硬要保留体验,你会写成:

php 复制代码
// ❌ 用户每个页面都要这么写
const orderScope = useOrderScope({
  $route: useRoute(),
  $router: useRouter(),
})

我考虑过这个方案,然后否决了 ------ 框架的复杂度该由框架吃,不是用户 。所以 vue-page-scope 默认行为是:

kotlin 复制代码
// ✅ 直接这样写
const orderScope = useOrderScope()

// scope 内部 enter / actions / watch 直接用
this.$route.query
this.$router.push('/...')

实现细节:框架内部通过 getCurrentInstance().proxy.$route 桥接,不 import vue-router,装了就用,没装就 noop。如果你用的是微前端 / 自研路由,还能显式覆盖:

perl 复制代码
const orderScope = useOrderScope({
  $route: microAppRoute,    // 显式注入优先级高于 auto bridge
  $router: microAppRouter,
  $user: useUserStore(),    // 也可以注入任意 composables
})

常用门,框架自己开;特殊门,用户再给钥匙。

$loading 自动追踪

csharp 复制代码
actions: {
  async search() {
    const res = await api.getOrders(...)
    this.$source.response = res
  }
}
bash 复制代码
<el-button :loading="scope.$loading.search" @click="scope.search">
  搜索
</el-button>

不需要包装器,不需要手写 loading.value = true / finally { loading.value = false }。返回 Promise 的 action 自动追踪,带并发计数(防先返回的请求提前关 loading)。

一次销毁,全部回收

页面销毁(或路由切走的 keep-alive 解绑)时:

bash 复制代码
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 是为这种"作用域响应式容器"专门设计的:

scss 复制代码
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 是什么关系

不是替代关系,是分层关系。

scss 复制代码
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

scss 复制代码
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-scope0.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 异步安全
sql 复制代码
npm install vue-page-scope
javascript 复制代码
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 都欢迎,但真实业务里踩到坑最值钱 ------ 那是这个库下一个版本的方向。

相关推荐
乘风gg29 分钟前
还在养虾吗?虾王已诞生:微信龙虾 ClawBot
前端·ai编程·claude
小小小小宇1 小时前
LLM 长期记忆构建
前端
lichenyang4531 小时前
从 Express 老项目到 NestJS + Docker:一次车辆管理系统的渐进式重构
前端
Momo__2 小时前
VueUse createReusableTemplate —— 单文件组件内的模板复用神器
前端·vue.js
程序员小富2 小时前
我开源了一个开发者专属的智能 JSON 工具,得到了媳妇高度认可
前端·vue.js·后端
小小小小宇2 小时前
程序员如何给 LLM 装工具以及看懂推理过程
前端
写代码的皮筏艇2 小时前
React中的forwardRef
前端·react.js·面试
槑有老呆2 小时前
花三个月工资请了个 AI 程序员,结果它连青岛啤酒股价都查不了
前端
风骏时光牛马2 小时前
Verilog开发常见问题汇总解析
前端
子兮曰3 小时前
AI Coding Method Map:一张图看懂 AI 编程的完整链路
前端·人工智能·后端