Pinia Store 平滑迁移:用代理模式实现零风险重构

重构遗留代码最怕什么?改一处崩十处。这篇文章分享一个我在实际项目中用过的方案:用代理模式实现 Pinia Store 的平滑迁移,让几十处旧代码无感升级。

背景:为什么要迁移

项目里有个 useUserStore,最早是用 Options API 写的,随着业务迭代,问题越来越多:

  • 类型定义不完整,到处是 any
  • 命名不规范,setUserInfoActionloginOut 这种命名看着难受
  • 状态结构和后端返回不一致,前端加了很多 hack
  • 没有按业务域组织,所有 Store 都堆在根目录

想重构成 Setup 风格,顺便理清类型和命名。但问题来了:这个 Store 被几十个文件引用,直接改导入路径?风险太大。

方案:代理模式 + 渐进式迁移

核心思路很简单:不动旧路径,让旧文件变成代理

bash 复制代码
旧导入路径                        新 Store
src/store/user.ts  ───代理───►  src/store/core/user.ts
       ▲                               │
       │                               │
   几十处业务代码                     唯一数据源

这样做的好处:

  • ✅ 旧代码一行不改,继续用 ~/store/user 导入
  • ✅ 新代码直接用 ~/store/core 导入
  • ✅ 数据源唯一,不会出现状态不同步
  • ✅ 可以慢慢把旧代码迁移到新路径

实现步骤

Step 1:先写新的 Store

src/store/core/user.ts 创建新的 Setup 风格 Store:

typescript 复制代码
// src/store/core/user.ts
export const useUserStore = defineStore('user', () => {
  // ==================== State ====================
  const userInfo = ref<UserInfo>(getDefaultUserInfo())
  const permissions = ref<Permission[]>([])
  const locale = ref<SupportedLanguage>('zh')
  const isRouterInitialized = ref(false)

  // ==================== Getters ====================
  const isLoggedIn = computed(() => !!userInfo.value.id)
  const nickname = computed(() => userInfo.value.realName || '')

  // ==================== Actions ====================
  async function loadUserInfo(): Promise<void> {
    const res = await userApi.getPermissionsInfo()
    if (res.data) {
      userInfo.value = { ...getDefaultUserInfo(), ...res.data.user }
      permissions.value = res.data.permissions || []
    }
  }

  async function logout(): Promise<void> {
    userInfo.value = getDefaultUserInfo()
    permissions.value = []
    // ... 清理逻辑
  }

  return {
    userInfo, permissions, locale, isRouterInitialized,
    isLoggedIn, nickname,
    loadUserInfo, logout,
  }
})

类型清晰,命名规范,舒服。

Step 2:把旧文件改成代理

重点来了。把原来的 src/store/user.ts 改成代理层:

typescript 复制代码
// src/store/user.ts - 变成代理层
import type { Pinia } from 'pinia'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useUserStore as useCoreUserStore } from './core/user'

/**
 * @deprecated 建议迁移到 useUserStore from '~/store/core'
 */
export function useUserStore(_pinia?: Pinia) {
  // 转发到新 Store
  const coreStore = useCoreUserStore()

  const { userInfo, permissions, locale, isRouterInitialized } = storeToRefs(coreStore)

  // 兼容旧的 getter 命名
  const getLocale = computed(() => locale.value)
  const getPermissions = computed(() => permissions.value)

  // 兼容旧的 action 命名
  async function setUserInfoAction() {
    await coreStore.loadUserInfo()  // 转发
  }

  async function loginOut() {
    await coreStore.logout()  // 转发
  }

  // 兼容 userStore.user.xxx 的直接访问方式
  const userProxy = {
    get user() { return userInfo.value }
  }

  return {
    userInfo, permissions, locale,
    ...userProxy,  // 支持 store.user.readAll 这种访问
    // 旧命名(兼容)
    getLocale, getPermissions,
    setUserInfoAction, loginOut,
    // 新命名(推荐)
    loadUserInfo: coreStore.loadUserInfo,
    logout: coreStore.logout,
  }
}

几个关键点:

1. 接受可选的 pinia 参数

旧代码可能写成 useUserStore(store),新的代理层要兼容这种写法,虽然参数实际不用。

2. 用 getter 代理 user 属性

旧代码直接 userStore.user.readAll 访问,不是 userStore.user.value.readAll。用 getter 可以实现这种"直接访问"的效果:

typescript 复制代码
const userProxy = {
  get user() { return userInfo.value }
}
return { ...userProxy }

3. 新旧命名都暴露

让业务代码可以渐进式迁移,setUserInfoActionloadUserInfo 同时可用。

Step 3:加上 @deprecated 标记

给代理层加上 JSDoc 的 @deprecated 标记,IDE 会给出提示,方便后续清理:

typescript 复制代码
/** @deprecated 使用 coreStore.loadUserInfo 代替 */
async function setUserInfoAction() {
  await coreStore.loadUserInfo()
}

测试验证

迁移最怕的是"看起来没问题,上线才出事"。这里给一套验证方案。

快速冒烟测试

在浏览器控制台跑一下:

typescript 复制代码
// 旧路径
import { useUserStore } from '~/store/user'
// 新路径
import { useUserStore as useCoreUserStore } from '~/store/core'

const oldStore = useUserStore()
const newStore = useCoreUserStore()

// 验证数据源唯一
console.log('引用相同:', oldStore.userInfo === newStore.userInfo)  // true

// 验证状态同步
newStore.setLocale('en')
console.log('状态同步:', oldStore.locale.value === 'en')  // true

关键路径验证

场景 操作 预期
登录 正常登录 用户名正确显示
权限 访问受限页面 权限判断正常
语言 切换中英文 全局切换,刷新后保持
登出 点击登出 状态清除,跳转登录页
刷新 F5 刷新页面 状态正确恢复

单元测试

写一个兼容性测试,确保新旧 API 行为一致:

typescript 复制代码
describe('User Store 兼容性', () => {
  it('新旧 Store 应指向同一数据源', () => {
    const oldStore = useUserStore()
    const coreStore = useCoreUserStore()
    expect(oldStore.userInfo).toBe(coreStore.userInfo)
  })

  it('setUserInfoAction 应等价于 loadUserInfo', async () => {
    const store = useUserStore()
    await store.setUserInfoAction()
    expect(store.userInfo.value.id).toBeTruthy()
  })
})

渐进式迁移

代理层搞定后,业务代码可以慢慢迁移:

typescript 复制代码
// ============ 旧写法(继续可用) ============
import { useUserStore } from '~/store/user'

const store = useUserStore(pinia)
console.log(store.user.readAll)
await store.setUserInfoAction()

// ============ 新写法(推荐) ============
import { useUserStore } from '~/store/core'

const store = useUserStore()
console.log(store.userInfo.readAll)
await store.loadUserInfo()

没有 deadline 压力的话,可以每次改业务功能的时候顺手把导入路径改掉,几个月后旧路径的引用自然就清零了。


兼容点速查表

旧用法 兼容方式
useUserStore(store) 接受可选参数 _pinia?: Pinia
userStore.user.readAll 使用 getter 代理直接访问
userStore.setUserInfoAction() 转发到 loadUserInfo()
userStore.loginOut() 转发到 logout()
userStore.isSetRouters 别名到 isRouterInitialized
userStore.getLocale computed 包装 locale.value

总结

这套方案的核心就三点:

  1. 数据源唯一:新旧路径最终都指向同一个 Store 实例
  2. API 兼容:代理层转发所有旧的方法调用
  3. 渐进迁移:新旧写法并存,没有硬性切换时间点

适用场景:

  • Store 被大量文件引用,不敢直接改路径
  • 想重构但又怕出问题
  • 团队习惯渐进式改进,不喜欢大爆炸式重构

不适用场景:

  • Store 只有几处引用,直接全局替换更快
  • 重构涉及 Store ID 变更(会影响持久化)

希望这个方案对你有帮助。有问题欢迎讨论 👋

相关推荐
老前端的功夫1 小时前
前端水印技术深度解析:从基础实现到防破解方案
开发语言·前端·javascript·前端框架
wshzd1 小时前
LLM之Agent(三十八)|AI Agents(七):Multi-Agent架构
人工智能·架构
颜颜yan_1 小时前
跨越x86与ARM:openEuler全架构算力实战评测
java·arm开发·架构
霍格沃兹测试学院-小舟畅学1 小时前
性能测试入门:使用 Playwright 测量关键 Web 性能指标
开发语言·前端·php
tangbin5830851 小时前
iOS Swift 工具类:数据转换工具 ParseDataTool
前端
潜水豆1 小时前
AI 时代的前端究竟还能积累什么
前端
www_stdio1 小时前
手写 instanceof:深入理解 JavaScript 原型与继承机制
前端·javascript·html
boombb1 小时前
国际化方案:多环境、多语言、动态加载的完整实践
前端
狗哥哥1 小时前
我是如何治理一个混乱的 Pinia 状态管理系统的
前端·vue.js·架构