背景与问题
业务场景
在企业级 SaaS 应用中,我们需要在用户登录后进行多种认证检查:
- 用户类型认证:个人用户需激活、企业用户需完成认证
- 密码过期检查:强制用户定期更新密码
- 权限验证:不同用户类型访问不同功能模块
最初的实现采用了"认证服务中心"(VerificationCenter)的设计模式,通过规则引擎统一管理所有认证逻辑。
遇到的问题
// ❌ 错误示例:在路由守卫中直接调用 Composition API router.afterEach((to, from) => { const { checkAndShowAuthDialog } = useUserTypeAuth() checkAndShowAuthDialog() })报错信息:
SyntaxError: Must be called at the top of a `setup` function at useI18n (vue-i18n.js:314:17) at useConfirm (useConfirm.ts:165:17) at useUserTypeAuth (useUserTypeAuth.ts:72:23)问题根源
Vue 3 的 Composition API(如useI18n、useRouter、useRoute)必须在 Vue 组件的setup函数顶层调用,而路由守卫运行在 Vue 组件上下文之外,直接调用会触发运行时错误。
核心问题分析
1. Composition API 的上下文限制
// Vue 3 内部实现(简化版) let currentInstance: ComponentInternalInstance | null = null export function getCurrentInstance(): ComponentInternalInstance | null { return currentInstance } export function useRouter(): Router { const instance = getCurrentInstance() if (!instance) { throw new Error('Must be called at the top of a setup function') } return instance.appContext.config.globalProperties.$router }关键点:
- Composition API 依赖
currentInstance获取 Vue 实例上下文 - 路由守卫执行时
currentInstance为null - 直接调用会抛出异常
2. 架构过度设计的反思
原有的 VerificationCenter 架构:
// 规则引擎模式 interface VerificationRule { id: string when: ('login' | 'appReady' | 'routeChange')[] shouldRun: (ctx: VerificationContext) => Promise<boolean> run: (ctx: VerificationContext) => Promise<void> } class VerificationCenter { private rules: Map<string, VerificationRule> = new Map() register(rule: VerificationRule) { this.rules.set(rule.id, rule) } async run(trigger: string) { for (const rule of this.rules.values()) { if (rule.when.includes(trigger)) { if (await rule.shouldRun(ctx)) { await rule.run(ctx) } } } } }问题分析:
- ✅优点:高度抽象、易扩展、规则解耦
- ❌缺点:
- 只有 3 个规则,不需要如此复杂的架构
- 增加认知负担,新人难以理解
- 调用链路长,调试困难
- 性能开销(函数调用、对象创建)
- 违反 YAGNI 原则(You Aren't Gonna Need It)
解决方案设计
设计原则
- 简单性优于灵活性:当前需求简单,不需要过度设计
- SOLID 原则:单一职责、开闭原则
- 上下文隔离:路由守卫专用函数不依赖 Vue 上下文
架构对比
方案一:直接调用(✅ 采用)
// 路由守卫中直接调用 router.afterEach((to) => { checkAuthInRouterGuard() checkPasswordExpiredInRouterGuard(router) })优点:
- 代码简洁直观
- 调用链路清晰
- 易于调试和维护
- 性能最优
方案二:保留规则引擎(❌ 放弃)
缺点:
- 过度设计,增加复杂度
- 不符合当前业务规模
- 维护成本高
技术方案
核心思路:为路由守卫创建独立的、不依赖 Composition API 的函数。
// 设计模式:Adapter Pattern(适配器模式) // 将依赖 Composition API 的逻辑适配为独立函数 // 1. 组件内使用(依赖 Composition API) export function useUserTypeAuth() { const router = useRouter() // ✅ 在 setup 中调用 const { t } = useI18n() // ✅ 在 setup 中调用 // ... } // 2. 路由守卫使用(不依赖 Composition API) export function checkAuthInRouterGuard(): void { // ✅ 直接从 store 获取数据,不依赖 Vue 上下文 const userType = getUserTypeFromStore() const user = getUserFromStore() // ✅ 使用安全的 i18n 包装器 const t = getSafeI18n() // ✅ 动态导入 router 实例 const handleAction = async () => { const { default: router } = await import('~/router') await router.push('/profile') } }代码实现
1. 安全的 i18n 包装器
// src/composables/ui/useConfirm.ts /** * 安全获取 i18n 翻译函数 * @description 尝试调用 useI18n(),失败则返回 fallback 翻译 */ function getSafeI18n(): (key: string) => string { try { const { t } = useI18n() return t } catch { // 路由守卫等非 Vue 组件上下文中使用 fallback return (key: string) => { const fallbacks: Record<string, string> = { 'common.confirmTitle': '提示', 'common.ok': '确定', 'common.cancel': '取消', 'common.closeWindow': '关闭窗口', 'ui.confirm.cancelTask': '取消任务', 'ui.confirm.continueOperation': '继续操作', } return fallbacks[key] || key } } } export function useConfirm() { const t = getSafeI18n() // ✅ 安全调用 const confirm = (message: string, options?: ConfirmOptions) => { return ElMessageBox.confirm(message, { title: options?.title || t('common.confirmTitle'), confirmButtonText: options?.okBtnText || t('common.ok'), cancelButtonText: t('common.cancel'), type: options?.type || 'info', // ... }) } return { confirm, alert } }设计亮点:
- 优雅降级:有 Vue 上下文时使用
useI18n(),否则使用 fallback - 零侵入:不影响现有组件的使用方式
- 类型安全:保持完整的 TypeScript 类型推导
2. 用户认证检查(路由守卫专用)
// src/composables/auth/useUserTypeAuth.ts /** * 路由守卫中检查用户认证状态 * @description 不依赖 Composition API,可在路由守卫中安全调用 */ export function checkAuthInRouterGuard(): void { // 1. 从 store 获取数据(不依赖 Vue 上下文) const userType = getUserTypeFromStore() if (!needsAuthPrompt(userType)) { return } const user = getUserFromStore() if (!user) { return } // 2. 使用安全的 confirm(内部使用 getSafeI18n) const { confirm } = useConfirm() const t = getSafeI18n() // 3. 获取提示配置 const config = getAuthPromptMessage(userType, user, t) if (!config.content) { return } // 4. 显示确认对话框 void (async () => { try { if (config.showConfirmBtn) { await confirm(config.content, { title: config.title, type: 'warning', buttons: [ { text: config.confirmText, type: 'primary', customClass: 'customer-button-default customer-primary-button customer-button', onClick: async () => { // ✅ 动态导入 router,避免循环依赖 const { default: router } = await import('~/router') await executeAuthActionForService(userType, user, router) }, }, { text: config.cancelText, type: 'default', customClass: 'trans-bg-btn', }, ], }) } else { // 企业认证审核中:仅显示提示,使用文字按钮 await confirm(config.content, { title: config.title, type: 'warning', buttons: [ { text: config.confirmText, type: 'primary', link: true, // ✅ 文字按钮样式 }, ], }) } } catch { // 用户取消操作 } })() } // ==================== 辅助函数 ==================== /** * 从 Store 获取用户类型 */ function getUserTypeFromStore(): number { const userStore = useUserStoreWithOut() return userStore.userInfo?.userType ?? 0 } /** * 从 Store 获取用户信息 */ function getUserFromStore(): any { const userStore = useUserStoreWithOut() return userStore.userInfo } /** * 判断是否需要显示认证提示 */ function needsAuthPrompt(userType: number): boolean { const NEEDS_AUTH_PROMPT_USER_TYPES = [1, 2, 3, 4] return NEEDS_AUTH_PROMPT_USER_TYPES.includes(userType) } /** * 获取认证提示消息配置 */ function getAuthPromptMessage( userType: number, user: any, t: (key: string) => string, ): AuthPromptConfig { // 个人用户:未激活 if (userType === 1 && user.userStatus === 0) { return { title: t('register.personalActivation'), content: t('register.personalActivationTip'), confirmText: t('register.goActivate'), cancelText: t('common.cancel'), showConfirmBtn: true, } } // 企业用户:认证审核中 if ([2, 3, 4].includes(userType) && user.verificationStatus === 1) { return { title: t('register.enterpriseCertification'), content: t('register.enterpriseCertificationPendingTip'), confirmText: t('common.ok'), cancelText: '', showConfirmBtn: false, // ✅ 仅显示提示,不需要确认按钮 } } // 企业用户:认证被拒绝 if ([2, 3, 4].includes(userType) && user.verificationStatus === 3) { return { title: t('register.enterpriseCertification'), content: t('register.enterpriseCertificationRejectedTip'), confirmText: t('register.goResubmit'), cancelText: t('common.cancel'), showConfirmBtn: true, } } return { title: '', content: '', confirmText: '', cancelText: '', showConfirmBtn: false } }设计亮点:
- 职责分离:数据获取、逻辑判断、UI 展示分离
- 可测试性:纯函数设计,易于单元测试
- 动态导入:避免循环依赖,按需加载
- 错误处理:优雅处理用户取消操作
3. 密码过期检查(路由守卫专用)
// src/composables/auth/usePasswordExpired.ts /** * 路由守卫中检查密码过期并显示重置弹窗 * @description 不依赖 Composition API,可在路由守卫中安全调用 * @param routerInstance - 路由实例 */ export function checkPasswordExpiredInRouterGuard(routerInstance: Router): void { const route = routerInstance.currentRoute.value // 1. 跳过 blank 布局页面(登录、注册等) const isBlank = route?.meta?.layout === 'blank' if (isBlank) return // 2. 只在内部页面检查 const category = route?.meta?.category if (category !== 'internal') return // 3. 检查 sessionStorage 标记 const forceTokenReset = sessionStorage.getItem('vc_force_reset_pwd') === '1' const forceSelfReset = sessionStorage.getItem('vc_force_reset_pwd_self') === '1' if (forceTokenReset || forceSelfReset) { showResetPasswordDialogStandalone(routerInstance) } } /** * 显示密码重置弹窗(独立函数,不依赖 Composition API) * @param routerInstance - 路由实例 */ function showResetPasswordDialogStandalone(routerInstance: Router): void { const container = document.createElement('div') document.body.appendChild(container) // ✅ 使用 createApp 动态挂载组件 const app = createApp({ render() { const useTokenMode = sessionStorage.getItem('vc_force_reset_pwd') === '1' return h(ResetPassWord, { size: 'large', force: true, useToken: useTokenMode, onSuccess: () => { // 清理标记 try { sessionStorage.removeItem('vc_force_reset_pwd') sessionStorage.removeItem('vc_force_reset_pwd_self') sessionStorage.removeItem('vc_pwd_reset_token') sessionStorage.removeItem('vc_origin_password') } catch {} // 清理登录状态 common.setWindowKeyValue('pwd_reset_token', undefined) common.removeLoginAuthToken() window.sessionStorage.clear() // 跳转到登录页 routerInstance.replace(RouteConfig.Login.path) // 卸载组件 app.unmount() if (container.parentNode) container.parentNode.removeChild(container) }, onClose: () => { app.unmount() if (container.parentNode) container.parentNode.removeChild(container) }, }) }, }) // ✅ 注入全局 i18n(从 window 获取,避免依赖 useI18n) try { if ((window as any).i18n) { app.use((window as any).i18n) } } catch {} app.mount(container) // ✅ 监听路由变化,自动关闭弹窗 try { const unwatch = routerInstance.afterEach((to: any) => { const isBlank = to?.meta?.layout === 'blank' if (isBlank) { try { sessionStorage.removeItem('vc_force_reset_pwd') sessionStorage.removeItem('vc_force_reset_pwd_self') } catch {} app.unmount() if (container.parentNode) container.parentNode.removeChild(container) unwatch() } }) } catch {} }设计亮点:
- 动态挂载:使用
createApp+h()渲染函数动态创建组件实例 - 生命周期管理:自动清理 DOM 和事件监听器
- 全局 i18n 注入:从
window获取全局 i18n 实例,避免依赖useI18n() - 路由监听:自动响应路由变化,关闭弹窗
4. 路由守卫集成
// src/router/index.ts import { createRouter, createWebHashHistory } from 'vue-router' import { checkPasswordExpiredInRouterGuard } from '~/composables/auth/usePasswordExpired' import { checkAuthInRouterGuard } from '~/composables/auth/useUserTypeAuth' const router = createRouter({ history: createWebHashHistory(), routes, }) // ==================== 路由后置守卫 ==================== router.afterEach((to, from) => { try { // Keep-Alive 缓存管理 const keepAliveStore = useKeepAliveStoreWithOut() if (to.meta?.keepAlive && to.name) { keepAliveStore.addCachedView(to.name as string) } if (from.meta?.noCache && from.name) { keepAliveStore.deleteCachedView(from.name as string) } // 重置滚动位置 window.scrollTo({ top: 0, left: 0, behavior: 'auto' }) // 停止进度条 setTimeout(() => { nprogressManager.done() CmcLoadingService.closeAll() }, 300) // ==================== 认证检查 ==================== // 跳过 blank 布局页面(登录、注册等) if (to.meta?.layout !== 'blank') { // ✅ 用户认证检查(不依赖 Composition API) checkAuthInRouterGuard() // ✅ 密码过期检查(不依赖 Composition API) checkPasswordExpiredInRouterGuard(router) } } catch (error) { console.error('路由后置守卫执行失败:', error) } }) export default router设计亮点:
- 清晰的职责划分:缓存管理、滚动控制、认证检查分离
- 错误边界:统一的 try-catch 错误处理
- 条件执行:根据路由元信息决定是否执行检查
架构优化思考
1. YAGNI 原则的实践
You Aren't Gonna Need It(你不会需要它)
// ❌ 过度设计:为未来可能的需求预留扩展 class VerificationCenter { private rules: Map<string, VerificationRule> = new Map() private middleware: Middleware[] = [] private eventBus: EventEmitter = new EventEmitter() async run(trigger: string, ctx: VerificationContext) { // 复杂的规则引擎逻辑 // 中间件机制 // 事件发布订阅 } } // ✅ 简单设计:满足当前需求即可 export function checkAuthInRouterGuard(): void { // 直接实现业务逻辑 }反思:
- 当前只有 3 个认证规则,不需要规则引擎
- 未来如果真的需要扩展(如增加到 10+ 规则),再重构也不迟
- 过早优化是万恶之源
2. 简单性 vs 灵活性
| 维度 | 规则引擎(复杂) | 直接调用(简单) |
|---|---|---|
| 代码行数 | ~500 行 | ~200 行 |
| 认知负担 | 高(需理解规则引擎) | 低(直接阅读业务逻辑) |
| 调试难度 | 困难(调用链长) | 简单(调用链短) |
| 扩展性 | 高(添加规则) | 中(直接添加函数) |
| 性能 | 中(函数调用开销) | 高(直接调用) |
| 适用场景 | 10+ 规则 | 3-5 规则 |
结论:在当前业务规模下,简单性优于灵活性。
3. 上下文隔离的设计模式
// 设计模式:Adapter Pattern(适配器模式) // 1. 组件内使用(依赖 Vue 上下文) export function useUserTypeAuth() { const router = useRouter() // 依赖 Vue 上下文 const { t } = useI18n() // 依赖 Vue 上下文 return { checkAndShowAuthDialog: () => { // 组件内逻辑 } } } // 2. 路由守卫使用(不依赖 Vue 上下文) export function checkAuthInRouterGuard(): void { // 适配器:将依赖 Vue 上下文的逻辑转换为独立函数 const userType = getUserTypeFromStore() // 直接访问 store const t = getSafeI18n() // 安全的 i18n 包装器 // 业务逻辑 }设计原则:
- 单一职责:每个函数只做一件事
- 依赖倒置:依赖抽象(store、全局对象)而非具体实现(Vue 实例)
- 开闭原则:对扩展开放(可添加新的检查函数),对修改封闭(不影响现有逻辑)
4. 错误处理策略
// ✅ 优雅降级 function getSafeI18n(): (key: string) => string { try { const { t } = useI18n() return t } catch { // 降级到 fallback 翻译 return (key: string) => fallbacks[key] || key } } // ✅ 静默失败(用户取消操作) void (async () => { try { await confirm(config.content, options) } catch { // 用户取消,不需要处理 } })() // ✅ 全局错误边界 router.afterEach((to, from) => { try { checkAuthInRouterGuard() checkPasswordExpiredInRouterGuard(router) } catch (error) { console.error('路由后置守卫执行失败:', error) // 不阻断路由导航 } })原则:
- 优雅降级:功能不可用时提供 fallback
- 静默失败:用户主动取消的操作不需要错误提示
- 全局边界:关键路径添加 try-catch,防止整个应用崩溃
最佳实践总结
✅ Do's(推荐做法)
为路由守卫创建独立函数
// ✅ 独立函数,不依赖 Composition API export function checkAuthInRouterGuard(): void { const userType = getUserTypeFromStore() // ... }使用安全的 i18n 包装器
// ✅ 优雅降级 function getSafeI18n(): (key: string) => string { try { const { t } = useI18n() return t } catch { return (key: string) => fallbacks[key] || key } }动态导入避免循环依赖
// ✅ 按需加载 const handleAction = async () => { const { default: router } = await import('~/router') await router.push('/profile') }从 Store 获取数据,不依赖 Vue 实例
// ✅ 直接访问 store const userStore = useUserStoreWithOut() const userType = userStore.userInfo?.userType使用 createApp 动态挂载组件
// ✅ 独立的 Vue 应用实例 const app = createApp({ render() { return h(ResetPassWord, { /* props */ }) } }) app.mount(container)❌ Don'ts(避免做法)
不要在路由守卫中直接调用 Composition API
// ❌ 会报错 router.afterEach(() => { const router = useRouter() // 错误! const { t } = useI18n() // 错误! })不要过度设计
// ❌ 3 个规则不需要规则引擎 class VerificationCenter { private rules: Map<string, VerificationRule> = new Map() // 复杂的规则引擎逻辑 }不要忽略错误处理
// ❌ 没有错误边界 router.afterEach(() => { checkAuth() // 如果出错会导致路由导航失败 }) // ✅ 添加错误边界 router.afterEach(() => { try { checkAuth() } catch (error) { console.error(error) } })不要忘记清理副作用
// ❌ 没有清理 DOM 和事件监听器 const app = createApp(Component) app.mount(container) // ✅ 清理副作用 const unwatch = router.afterEach(() => { app.unmount() container.remove() unwatch() })📊 性能优化建议
避免不必要的检查
// ✅ 提前返回 if (to.meta?.layout === 'blank') return if (to.meta?.category !== 'internal') return使用 sessionStorage 缓存标记
// ✅ 避免重复检查 const forceReset = sessionStorage.getItem('vc_force_reset_pwd') === '1' if (!forceReset) return动态导入按需加载
// ✅ 只在需要时加载 const { default: router } = await import('~/router')🧪 可测试性建议
纯函数设计
// ✅ 易于测试 function needsAuthPrompt(userType: number): boolean { return [1, 2, 3, 4].includes(userType) } // 测试 expect(needsAuthPrompt(1)).toBe(true) expect(needsAuthPrompt(5)).toBe(false)依赖注入
// ✅ 可注入 mock router export function checkPasswordExpiredInRouterGuard( routerInstance: Router ): void { // 使用注入的 router 实例 }职责分离
// ✅ 数据获取、逻辑判断、UI 展示分离 const userType = getUserTypeFromStore() // 数据层 const needsAuth = needsAuthPrompt(userType) // 逻辑层 if (needsAuth) showAuthDialog() // UI 层总结
核心要点
理解 Composition API 的上下文限制
- 必须在 Vue 组件的
setup函数顶层调用 - 路由守卫运行在 Vue 上下文之外
- 必须在 Vue 组件的
为路由守卫创建独立函数
- 不依赖
useRouter、useI18n等 Composition API - 从 Store 或全局对象获取数据
- 使用安全的 i18n 包装器
- 不依赖
遵循 YAGNI 原则
- 不要过度设计
- 简单性优于灵活性
- 满足当前需求即可
优雅的错误处理
- 优雅降级(fallback)
- 静默失败(用户取消)
- 全局错误边界
适用场景
- ✅ 路由守卫中需要使用 i18n、router 等 Composition API
- ✅ 需要在非 Vue 组件上下文中执行 Vue 相关逻辑
- ✅ 需要动态挂载组件(如弹窗、通知)
- ✅ 需要简化过度设计的架构