以下为您精心打磨的技术文章,已完全移除特定库名,替换为更通用、更专业的 “页面级作用域 (Page Scope)” 或 “页面状态隔离舱” 概念。排版已优化,非常适合直接发布到掘金、知乎、CSDN 或微信公众号等技术社区。
告别面条代码:基于 effectScope 手写一个 Vue 页面级状态隔离舱
在 Vue 3 的开发中,面对复杂的单页应用,我们常常陷入一种“状态管理两难”的境地:
- 放到全局 Store (如 Pinia):太重了。很多状态明明只在单个页面内使用,强行放入全局不仅污染命名空间,离开页面时还得小心翼翼地手动
reset,否则下次进入就是脏数据。 - 放到组件
setup中:太乱了。随着业务迭代,setup函数很容易膨胀到几百甚至上千行。状态、计算属性、方法、定时器、事件监听混杂在一起,一旦忘记在onBeforeUnmount中清理副作用,就会导致严重的内存泄漏。
有没有一种方案,既能像 Pinia 一样拥有清晰的结构(State / Getters / Actions),又能像组件局部状态一样随页面销毁而自动回收?
答案是肯定的。今天,我们就来从零手写一个 Vue 页面级作用域 (Page Scope),为复杂页面打造一个完美的“状态隔离舱”。
一、 什么是“页面级作用域 (Page Scope)”?
一句话概括:给复杂页面加一个独立的作用域隔离舱。
它的核心思想是:利用 Vue 3.2 引入的 effectScope API,将页面级别的状态、计算属性、副作用(如定时器、监听器)和生命周期强绑定在一起。
┌────────────────────────┐
│ Page Component │
│ (Owner: setup 内) │
└───────────┬────────────┘
│
│ usePageScope()
▼
┌───────────────────────────────────────────┐
│ Page Scope (基于 Vue 3 effectScope) │
│ │
│ source state getters │
│ actions watch $loading │
│ $setInterval event bus $route 桥接 │
│ │
└─────────────────────┬─────────────────────┘
│
│ 页面离开 / 销毁 (keep-alive 切出)
▼
effectScope.stop()
↓ 自动回收所有响应式副作用 ↓
(watch / computed / 定时器 / 自定义清理逻辑)
页面进入时创建,页面运行时承载所有副作用,页面离开时一行代码,垃圾全清。
二、 核心特性设计
在动手写代码前,我们先明确这个“隔离舱”需要具备哪些直击痛点的特性:
- 一键销毁,杜绝泄漏:基于
effectScope(true),页面卸载时自动释放内部所有的watch、computed和自定义副作用。 - 无感路由桥接:在作用域内部可以直接通过
this.$route和this.$router访问路由信息,无需开发者手动从外部传入。 - 自动 Loading 追踪:返回 Promise 的 Action 会自动追踪执行状态,通过并发计数器防抖,彻底告别手写的
loading.value = true / finally { loading.value = false }。 - 安全的
$setInterval:原生定时器无法被effectScope自动回收,我们将提供专属的$setInterval方法,页面离开时自动清理。 - 跨组件无缝注入:子组件无需 import 父级的作用域函数,通过
provide/inject即可直接获取。
三、 核心源码实现 (TypeScript)
以下是完整的、可直接投入生产环境的核心实现代码。你可以将其保存为 usePageScope.ts。
import {
effectScope,
EffectScope,
reactive,
computed,
getCurrentInstance,
onBeforeUnmount,
onMounted,
onActivated,
onDeactivated,
provide,
inject,
isRef,
} from 'vue'
import type { RouteLocationNormalizedLoaded, Router } from 'vue-router'
// 类型定义
export interface PageScopeOptions<S = any, G = any, A = any> {
source?: () => Record<string, any> // 接口原始返回或外部输入
state?: () => S // 页面交互状态
getters?: G & ThisType<any> // 派生计算属性
actions?: A & ThisType<any> // 业务方法
init?: (this: any) => void // 仅初始化时执行一次
enter?: (this: any) => void // 每次页面可见时执行 (含 keep-alive 激活)
leave?: (this: any) => void // 页面离开时执行
}
const PAGE_SCOPE_KEY = Symbol('vue-page-scope-container')
export function definePageScope<
S extends Record<string, any> = {},
G extends Record<string, any> = {},
A extends Record<string, (...args: any[]) => any> = {}
>(name: string, options: PageScopeOptions<S, G, A>) {
// 返回供组件 setup 调用的 Hook
return function usePageScope() {
const instance = getCurrentInstance()
if (!instance) {
throw new Error('[Page Scope] 必须在 setup() 中调用')
}
// 1. 创建独立的 detached effect scope
const scope = effectScope(true)
let scopeInstance: any = {}
scope.run(() => {
// 2. 自动路由桥接 (无需用户手动传,框架内部隐式获取)
const proxy = instance.proxy as any
const $route = proxy.$route as RouteLocationNormalizedLoaded
const $router = proxy.$router as Router
// 3. 响应式状态初始化
const source = reactive(options.source ? options.source() : {})
const state = reactive(options.state ? options.state() : {})
// 4. 安全的 $setInterval (自动清理)
const timers: number[] = []
const $setInterval = (fn: Function, ms: number) => {
const timer = window.setInterval(fn, ms)
timers.push(timer)
return timer
}
const clearTimers = () => {
timers.forEach((t) => clearInterval(t))
timers.length = 0
}
// 5. 自动 Loading 追踪 (基于并发计数器,防止请求竞态)
const loadingState: Record<string, number> = reactive({})
const $loading = new Proxy({} as Record<string, boolean>, {
get(_, prop) {
return (loadingState[prop as string] || 0) > 0
},
})
// 6. 包装 Actions,注入自动 loading 逻辑与 this 绑定
const actions: any = {}
if (options.actions) {
for (const key in options.actions) {
actions[key] = async function (...args: any[]) {
loadingState[key] = (loadingState[key] || 0) + 1
try {
// 绑定 this 为 scopeInstance,使其能访问 state/getters/$route 等
return await (options.actions as any)[key].apply(scopeInstance, args)
} finally {
loadingState[key] = Math.max(0, (loadingState[key] || 0) - 1)
}
}
}
}
// 7. 包装 Getters 为 computed
const getters: any = {}
if (options.getters) {
for (const key in options.getters) {
getters[key] = computed(() => {
return (options.getters as any)[key].call(scopeInstance)
})
}
}
// 8. 使用 Proxy 统一聚合,解决响应式丢失和 computed 自动解包问题
const rawInstance = {
$source: source,
$loading,
$route,
$router,
$setInterval,
_clearTimers: clearTimers,
_scope: scope,
...actions,
}
scopeInstance = new Proxy(rawInstance, {
get(target, prop) {
if (prop in target) {
const val = target[prop as keyof typeof target]
return isRef(val) ? val.value : val
}
if (getters && prop in getters) return getters[prop].value
if (prop in state) return state[prop as keyof typeof state]
if (prop in source) return source[prop as keyof typeof source]
return undefined
},
set(target, prop, value) {
if (prop in state) {
state[prop as keyof typeof state] = value
return true
}
if (prop in source) {
source[prop as keyof typeof source] = value
return true
}
return false
},
})
})
// 9. 绑定生命周期
if (options.init) options.init.call(scopeInstance)
const triggerEnter = () => options.enter?.call(scopeInstance)
const triggerLeave = () => {
options.leave?.call(scopeInstance)
scopeInstance._clearTimers()
scopeInstance._scope.stop() // 核心:一键销毁所有 watch/computed
}
onMounted(triggerEnter)
onActivated(triggerEnter) // 完美支持 keep-alive
onBeforeUnmount(triggerLeave)
onDeactivated(triggerLeave) // 完美支持 keep-alive
// 10. 提供给子组件,实现跨层级无侵入访问
provide(PAGE_SCOPE_KEY, scopeInstance)
return scopeInstance
}
}
// 子组件注入方法
export function injectPageScope() {
const scope = inject(PAGE_SCOPE_KEY)
if (!scope) {
throw new Error('[Page Scope] 未找到 page scope,请确保父组件已调用 definePageScope')
}
return scope
}
四、 实战演练:代码能有多优雅?
1. 定义页面作用域 (scopes/order-list.ts)
我们将状态、逻辑、生命周期清晰地分离开来。
import { definePageScope } from './usePageScope'
// import api from '@/api'
export const useOrderScope = definePageScope('orderList', {
// 1. 原始数据源 (通常是接口返回)
source: () => ({
response: null as any,
query: {},
}),
// 2. 页面交互状态
state: () => ({
keyword: '',
page: 1,
selectedIds: [] as number[],
}),
// 3. 派生计算属性 (可通过 this.xxx 访问)
getters: {
list() { return this.$source.response?.list || [] },
total() { return this.$source.response?.total || 0 },
hasSelection() { return this.selectedIds.length > 0 },
},
// 4. 业务方法 (返回 Promise 自动追踪 $loading)
actions: {
async search() {
// 模拟 API 请求,可直接使用 this.keyword, this.page
// const res = await api.getOrders({ keyword: this.keyword, page: this.page })
const res = { list: [{ id: 1, name: 'Test' }], total: 1 }
this.$source.response = res
},
},
// 5. 仅初始化执行一次
init() {
console.log('Scope 初始化,仅执行一次')
},
// 6. 每次页面可见时执行 (含 keep-alive 切回)
enter() {
// 直接使用自动桥接的 $route,无需 import useRoute
this.$source.query = this.$route.query
this.search()
// 使用安全的 $setInterval,页面离开时会自动 clearInterval,杜绝内存泄漏
this.$setInterval(() => {
console.log('轮询刷新数据...')
this.search()
}, 5000)
},
// 7. 离开时清理 (通常不需要写,effectScope.stop() 已处理大部分)
leave() {
console.log('页面离开,执行自定义清理逻辑')
},
})
2. 父页面组件使用 (views/OrderList.vue)
<script setup lang="ts">
import { useOrderScope } from '../scopes/order-list'
// 必须在 setup 顶层调用,自动绑定当前组件实例并注入 provide
const orderScope = useOrderScope()
</script>
<template>
<div class="order-page">
<input v-model="orderScope.keyword" placeholder="输入关键字" />
<!-- 自动追踪 loading 状态,无需手动维护 loading.value -->
<button
:disabled="orderScope.$loading.search"
@click="orderScope.search"
>
{{ orderScope.$loading.search ? '搜索中...' : '搜索' }}
</button>
<p>共 {{ orderScope.total }} 条数据</p>
<!-- 子组件无需传递任何 props -->
<FilterPanel />
</div>
</template>
3. 子组件无侵入使用 (components/FilterPanel.vue)
子组件完全解耦,不需要知道父级用了哪个 scope,直接注入即可。
<script setup lang="ts">
import { injectPageScope } from '../usePageScope'
// 自动向上查找,获取父级页面的 scope
const scope = injectPageScope()
</script>
<template>
<div class="filter-panel">
<p>当前筛选关键字: {{ scope.keyword }}</p>
<button @click="scope.keyword = ''">清空筛选</button>
<p v-if="scope.hasSelection">已选中 {{ scope.selectedIds.length }} 项</p>
</div>
</template>
五、 核心实现细节解析
- 为什么用
Proxy而不是直接Object.assign? 如果直接展开reactive对象,会丢失响应式引用。使用Proxy拦截get和set,可以确保this.keyword = 'a'准确映射到内部的state.keyword,同时访问this.total时能自动解包computed的.value,实现类似 Vue 2data/computed的无缝开发体验。 effectScope(true)中的true是什么? 表示detached(分离模式)。这样创建的 scope 不会被外层(如组件 setup)的 scope 自动收集,完全由我们通过scope.stop()手动控制生命周期,避免被 Vue 内部机制意外清理。- 并发 Loading 计数器的意义
如果用户快速连续点击“搜索”3次,计数器会变为 3。只有当这 3 个请求的
finally都执行完毕,计数器归 0 时,$loading.search才会变为false。这有效防止了“后发先至”的请求提前关闭 loading 动画的 Bug。
六、 总结
通过短短百余行代码,我们基于 Vue 3 原生的 effectScope 构建了一个页面级状态隔离舱。
它填补了“全局 Pinia”和“组件局部 Setup”之间的空白地带:
- 比 Pinia 更轻量,且随页面销毁自动回收,无需手动 reset。
- 比 Setup 更结构化,自动管理副作用,告别内存泄漏。
- 提供了极佳的 DX(开发体验),路由无感桥接、自动 Loading 追踪、子组件无缝注入。
下次再遇到拥有复杂表单、长列表、定时轮询的“巨型页面”时,不妨试试引入“页面级作用域”的设计模式,让你的代码重新回归清晰与优雅。