Vue 3 项目重构实战:从自定义规则引擎到标准架构模式

前言

在 Vue 3 项目开发中,我们经常会遇到一些"过度设计"的代码——它们功能完善、设计精巧,但维护成本高、学习曲线陡峭。本文将分享一次真实的重构经历:如何将一个 400 行的自定义规则引擎改造为不到 150 行的标准 Vue 3 架构,同时保持功能完全一致。

问题背景

原有架构的痛点

我们的项目中有一个用户认证服务 VerificationCenter,它采用了自定义规则引擎模式:

// 原有架构示意
VerificationCenter.init({ router, store, i18n })
VerificationCenter.register(userTypeAuthRule)
VerificationCenter.register(enterpriseCertRule)
VerificationCenter.run('login')
VerificationCenter.run('routeChange')

这个设计存在以下问题:

  1. 过度抽象:引入了规则注册、拓扑排序、依赖管理等复杂概念
  2. 学习成本高:新人需要理解整个规则引擎的运作机制
  3. 代码量大:核心代码超过 400 行,维护困难
  4. 非标准模式:不符合 Vue 3 社区最佳实践
  5. 模块级状态:使用模块级可变状态,容易产生副作用

重构目标

  • ✅ 使用 Vue 3 标准模式(Router Guard + Pinia Store + Composable)
  • ✅ 代码量减少到 150 行以内
  • ✅ 保持功能完全一致
  • ✅ 提升代码可维护性
  • ✅ 降低学习成本

重构方案

架构对比

重构前:

┌─────────────────────────────────────┐
│      VerificationCenter             │
│  ┌───────────────────────────────┐  │
│  │  规则注册机制                  │  │
│  │  拓扑排序                      │  │
│  │  依赖管理                      │  │
│  │  模块级状态                    │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

重构后:

┌──────────────────────────────────────────┐
│           Vue Router Guard               │
│  (afterEach - 路由切换时自动触发)         │
└──────────────────────────────────────────┘
                  ↓
┌──────────────────────────────────────────┐
│        useAuthPrompt()                   │
│  (Composable - 封装弹窗逻辑)              │
└──────────────────────────────────────────┘
                  ↓
┌──────────────────────────────────────────┐
│         Pinia User Store                 │
│  (响应式状态管理)                         │
└──────────────────────────────────────────┘

核心实现

1. Pinia Store 管理认证状态

首先,在现有的 User Store 中扩展认证相关的状态和计算属性:

// src/store/core/user.ts

// 认证提示类型
export type AuthPromptType = 
  | 'personal_inactive'      // 个人用户未激活
  | 'enterprise_unverified'  // 企业用户未认证
  | 'enterprise_pending'     // 企业用户审核中
  | null

// 状态
const promptShownInSession = ref(false)

// 计算属性:是否需要认证提示
const needsAuthPrompt = computed(() => {
  if (promptShownInSession.value) return false
  const type = userInfo.value.userType
  return type === 45 || type === 40
})

// 计算属性:认证提示类型
const authPromptType = computed((): AuthPromptType => {
  const type = userInfo.value.userType
  if (type === 45) return 'personal_inactive'
  if (type === 40) {
    return userInfo.value.enterpriseCheckFlag 
      ? 'enterprise_unverified' 
      : 'enterprise_pending'
  }
  return null
})

// 操作:标记提示已显示
function markPromptShown() {
  promptShownInSession.value = true
  sessionStorage.setItem('auth_prompt_shown', '1')
}

// 操作:恢复提示状态
function restorePromptState() {
  promptShownInSession.value = 
    sessionStorage.getItem('auth_prompt_shown') === '1'
}

设计亮点:

  • 使用 computed 实现响应式状态计算
  • 利用 sessionStorage 持久化状态,避免重复提示
  • 类型安全的 TypeScript 定义

2. Composable 封装弹窗逻辑

创建一个专门的 Composable 来处理认证提示弹窗:

// src/composables/auth/useAuthPrompt.ts

import { createApp, h } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '~/store/core/user'
import CmcConfirm from '~/components/CmcConfirm'

// 模块级变量:当前弹窗实例
let currentDialog: { 
  app: ReturnType<typeof createApp>
  container: HTMLElement 
} | null = null

export function useAuthPrompt() {
  const router = useRouter()
  const userStore = useUserStore()
  const { t } = useI18n()

  // 关闭弹窗
  function closePrompt() {
    if (currentDialog) {
      currentDialog.app.unmount()
      currentDialog.container.remove()
      currentDialog = null
    }
  }

  // 显示弹窗
  function showPrompt() {
    closePrompt()
    
    const promptType = userStore.authPromptType
    if (!promptType) return

    const messages = getPromptMessages(promptType, t)
    if (!messages.content) return

    // 审核中状态:只显示消息提示
    if (promptType === 'enterprise_pending') {
      import('element-plus').then(({ ElMessage }) => {
        ElMessage.info(messages.content)
      })
      userStore.markPromptShown()
      return
    }

    // 创建弹窗
    const container = document.createElement('div')
    document.body.appendChild(container)

    const app = createApp({
      render: () => h(CmcConfirm, {
        type: 'warning',
        title: messages.title,
        content: messages.content,
        modelValue: true,
        buttons: [
          { 
            text: messages.confirmText, 
            type: 'primary', 
            onClick: () => { 
              closePrompt()
              userStore.markPromptShown() 
            } 
          },
          { 
            text: messages.cancelText, 
            type: 'default', 
            onClick: () => { 
              closePrompt()
              userStore.markPromptShown() 
            } 
          },
        ],
        showDefaultButtons: false,
        onClose: () => { 
          closePrompt()
          userStore.markPromptShown() 
        },
      })
    })

    app.mount(container)
    currentDialog = { app, container }
  }

  // 检查并显示(路由守卫调用)
  function checkAndShow(route: any) {
    // blank 布局跳过
    if (route?.meta?.layout === 'blank') {
      closePrompt()
      return
    }

    // 需要提示则显示
    if (userStore.needsAuthPrompt) {
      showPrompt()
    }
  }

  // 监听路由变化
  router.afterEach((to) => {
    if (to.meta?.layout === 'blank') {
      closePrompt()
    }
  })

  return {
    checkAndShow,
    showPrompt,
    closePrompt,
  }
}

// 获取提示消息
function getPromptMessages(
  type: AuthPromptType, 
  t: (key: string) => string
) {
  const title = t('common.confirmTitle')
  const cancelText = t('common.cancel')

  switch (type) {
    case 'personal_inactive':
      return {
        title,
        content: t('system.sys.lock.notRequiredLockTip'),
        confirmText: t('common.ok'),
        cancelText,
      }
    case 'enterprise_unverified':
      return {
        title,
        content: t('system.sys.lock.enterpriseNotVerifyLockTip'),
        confirmText: t('system.sys.lock.goVerify') || '去认证',
        cancelText,
      }
    case 'enterprise_pending':
      return {
        title,
        content: t('system.sys.lock.enterpriseVerifyLockTip'),
        confirmText: '',
        cancelText: '',
      }
    default:
      return { 
        title: '', 
        content: '', 
        confirmText: '', 
        cancelText: '' 
      }
  }
}

设计亮点:

  • 使用 createApp + h 函数动态创建弹窗
  • 模块级变量管理弹窗实例,确保同时只有一个弹窗
  • 自动监听路由变化,切换到登录页时关闭弹窗
  • 国际化支持

3. 路由守卫集成

在路由配置中使用 Composable:

// src/router/index.ts

router.afterEach((to, from) => {
  // ... 其他逻辑 ...

  // 检查认证提示
  import('~/composables/auth/useAuthPrompt').then(({ useAuthPrompt }) => {
    const { checkAndShow } = useAuthPrompt()
    checkAndShow(to)
  })
})

设计亮点:

  • 使用 afterEach 守卫,不阻塞路由导航
  • 动态导入 Composable,优化首屏加载
  • 简洁明了,一目了然

重构成果

代码量对比

指标重构前重构后改善
核心代码行数~400 行<150 行减少 62.5%
文件数量3 个2 个减少 1 个
概念复杂度高(规则引擎)低(标准模式)显著降低

架构对比

方面重构前重构后
架构模式自定义规则引擎Vue 3 标准模式
状态管理模块级变量 + sessionStoragePinia Store(响应式)
触发机制手动调用 run()路由守卫自动触发
学习成本需要理解规则引擎标准 Vue 3 知识即可
可维护性
可测试性

功能完整性

✅ 个人用户未激活提示
✅ 企业用户未认证提示
✅ 企业用户审核中提示
✅ 登录页自动关闭弹窗
✅ 会话内不重复提示
✅ 国际化支持

重构经验总结

1. 识别过度设计的信号

  • 代码行数远超实际需求
  • 引入了不必要的抽象层
  • 新人理解困难
  • 不符合社区最佳实践

2. 重构的黄金法则

保持功能一致性:重构不是重写,要确保用户体验不变

渐进式改造

  1. 先实现新架构
  2. 并行运行新旧代码
  3. 验证功能一致性
  4. 删除旧代码

充分测试

  • 单元测试覆盖核心逻辑
  • 集成测试验证完整流程
  • 手动测试确保用户体验

3. Vue 3 最佳实践

状态管理

  • 使用 Pinia Store 管理全局状态
  • 利用 computed 实现响应式计算
  • 避免模块级可变状态

逻辑复用

  • 使用 Composable 封装可复用逻辑
  • 遵循单一职责原则
  • 保持函数纯净

路由集成

  • 使用路由守卫处理导航逻辑
  • beforeEach 用于权限检查(阻塞)
  • afterEach 用于副作用操作(非阻塞)

4. 代码质量提升技巧

类型安全

// ✅ 好的做法:明确的类型定义
export type AuthPromptType = 
  | 'personal_inactive'
  | 'enterprise_unverified'
  | 'enterprise_pending'
  | null

// ❌ 避免:使用 any 或 string
type AuthPromptType = string | null

错误处理

// ✅ 好的做法:静默失败,不阻塞用户
function checkAndShow(route: any) {
  try {
    // ... 逻辑 ...
  } catch (error) {
    console.warn('[AuthPrompt] 检查失败:', error)
    // 不抛出错误,不阻塞路由
  }
}

资源清理

// ✅ 好的做法:及时清理资源
function closePrompt() {
  if (currentDialog) {
    currentDialog.app.unmount()  // 卸载 Vue 实例
    currentDialog.container.remove()  // 移除 DOM 节点
    currentDialog = null  // 释放引用
  }
}

可复用的重构模式

模式 1:规则引擎 → 路由守卫

适用场景:需要在路由切换时执行检查或操作

重构步骤

  1. 识别触发时机(login、routeChange 等)
  2. 将逻辑迁移到对应的路由守卫
  3. 使用 Composable 封装复杂逻辑

模式 2:模块级状态 → Pinia Store

适用场景:需要管理全局状态

重构步骤

  1. 创建或扩展 Pinia Store
  2. 使用 ref 定义响应式状态
  3. 使用 computed 定义计算属性
  4. 使用 action 定义状态变更方法

模式 3:命令式调用 → 声明式响应

适用场景:需要根据状态自动触发操作

重构步骤

  1. 将状态提升到 Store
  2. 使用 watchwatchEffect 监听状态变化
  3. 在路由守卫中检查状态并执行操作

总结

这次重构让我们深刻体会到:好的架构不是最复杂的,而是最合适的。通过拥抱 Vue 3 的标准模式,我们不仅减少了代码量,更重要的是提升了代码的可维护性和可读性。

重构的核心价值在于:

  • 降低认知负担
  • 提升开发效率
  • 减少维护成本
  • 符合社区规范

希望这次重构经验能为你的项目提供参考。记住:简单,往往更强大

参考资源


如果这篇文章对你有帮助,欢迎点赞、收藏、分享!有任何问题欢迎在评论区讨论。