Vue3 复杂页面状态怎么管?别什么都往 Pinia 里塞了!

9 阅读4分钟

以下为您精心打磨的技术文章,已完全移除特定库名,替换为更通用、更专业的 “页面级作用域 (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 / 定时器 / 自定义清理逻辑)

页面进入时创建,页面运行时承载所有副作用,页面离开时一行代码,垃圾全清


二、 核心特性设计

在动手写代码前,我们先明确这个“隔离舱”需要具备哪些直击痛点的特性:

  1. 一键销毁,杜绝泄漏:基于 effectScope(true),页面卸载时自动释放内部所有的 watchcomputed 和自定义副作用。
  2. 无感路由桥接:在作用域内部可以直接通过 this.$routethis.$router 访问路由信息,无需开发者手动从外部传入。
  3. 自动 Loading 追踪:返回 Promise 的 Action 会自动追踪执行状态,通过并发计数器防抖,彻底告别手写的 loading.value = true / finally { loading.value = false }
  4. 安全的 $setInterval:原生定时器无法被 effectScope 自动回收,我们将提供专属的 $setInterval 方法,页面离开时自动清理。
  5. 跨组件无缝注入:子组件无需 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>

五、 核心实现细节解析

  1. 为什么用 Proxy 而不是直接 Object.assign 如果直接展开 reactive 对象,会丢失响应式引用。使用 Proxy 拦截 getset,可以确保 this.keyword = 'a' 准确映射到内部的 state.keyword,同时访问 this.total 时能自动解包 computed.value,实现类似 Vue 2 data/computed 的无缝开发体验。
  2. effectScope(true) 中的 true 是什么? 表示 detached(分离模式)。这样创建的 scope 不会被外层(如组件 setup)的 scope 自动收集,完全由我们通过 scope.stop() 手动控制生命周期,避免被 Vue 内部机制意外清理。
  3. 并发 Loading 计数器的意义 如果用户快速连续点击“搜索”3次,计数器会变为 3。只有当这 3 个请求的 finally 都执行完毕,计数器归 0 时,$loading.search 才会变为 false。这有效防止了“后发先至”的请求提前关闭 loading 动画的 Bug。

六、 总结

通过短短百余行代码,我们基于 Vue 3 原生的 effectScope 构建了一个页面级状态隔离舱

它填补了“全局 Pinia”和“组件局部 Setup”之间的空白地带:

  • 比 Pinia 更轻量,且随页面销毁自动回收,无需手动 reset。
  • 比 Setup 更结构化,自动管理副作用,告别内存泄漏。
  • 提供了极佳的 DX(开发体验),路由无感桥接、自动 Loading 追踪、子组件无缝注入。

下次再遇到拥有复杂表单、长列表、定时轮询的“巨型页面”时,不妨试试引入“页面级作用域”的设计模式,让你的代码重新回归清晰与优雅。