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

0 阅读4分钟

复杂页面状态一旦没有边界,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:route/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
  }
}
<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-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 异步安全
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 都欢迎,但真实业务里踩到坑最值钱 —— 那是这个库下一个版本的方向。