diff --git a/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts b/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts index 7c556bec..95a7716f 100644 --- a/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts +++ b/frontend/src/composables/__tests__/useRoutePrefetch.spec.ts @@ -2,20 +2,7 @@ * useRoutePrefetch 组合式函数单元测试 */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import type { RouteLocationNormalized } from 'vue-router' - -// Mock 所有动态 import -vi.mock('@/views/admin/AccountsView.vue', () => ({ default: {} })) -vi.mock('@/views/admin/UsersView.vue', () => ({ default: {} })) -vi.mock('@/views/admin/DashboardView.vue', () => ({ default: {} })) -vi.mock('@/views/admin/GroupsView.vue', () => ({ default: {} })) -vi.mock('@/views/admin/SubscriptionsView.vue', () => ({ default: {} })) -vi.mock('@/views/admin/RedeemView.vue', () => ({ default: {} })) -vi.mock('@/views/user/KeysView.vue', () => ({ default: {} })) -vi.mock('@/views/user/UsageView.vue', () => ({ default: {} })) -vi.mock('@/views/user/DashboardView.vue', () => ({ default: {} })) -vi.mock('@/views/user/RedeemView.vue', () => ({ default: {} })) -vi.mock('@/views/user/ProfileView.vue', () => ({ default: {} })) +import type { RouteLocationNormalized, Router, RouteRecordNormalized } from 'vue-router' import { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch' @@ -32,11 +19,37 @@ const createMockRoute = (path: string): RouteLocationNormalized => ({ redirectedFrom: undefined }) +// Mock Router +const createMockRouter = (): Router => { + const mockImportFn = vi.fn().mockResolvedValue({ default: {} }) + + const routes: Partial[] = [ + { path: '/admin/dashboard', components: { default: mockImportFn } }, + { path: '/admin/accounts', components: { default: mockImportFn } }, + { path: '/admin/users', components: { default: mockImportFn } }, + { path: '/admin/groups', components: { default: mockImportFn } }, + { path: '/admin/subscriptions', components: { default: mockImportFn } }, + { path: '/admin/redeem', components: { default: mockImportFn } }, + { path: '/dashboard', components: { default: mockImportFn } }, + { path: '/keys', components: { default: mockImportFn } }, + { path: '/usage', components: { default: mockImportFn } }, + { path: '/redeem', components: { default: mockImportFn } }, + { path: '/profile', components: { default: mockImportFn } } + ] + + return { + getRoutes: () => routes as RouteRecordNormalized[] + } as Router +} + describe('useRoutePrefetch', () => { let originalRequestIdleCallback: typeof window.requestIdleCallback let originalCancelIdleCallback: typeof window.cancelIdleCallback + let mockRouter: Router beforeEach(() => { + mockRouter = createMockRouter() + // 保存原始函数 originalRequestIdleCallback = window.requestIdleCallback originalCancelIdleCallback = window.cancelIdleCallback @@ -58,14 +71,14 @@ describe('useRoutePrefetch', () => { describe('_isAdminRoute', () => { it('应该正确识别管理员路由', () => { - const { _isAdminRoute } = useRoutePrefetch() + const { _isAdminRoute } = useRoutePrefetch(mockRouter) expect(_isAdminRoute('/admin/dashboard')).toBe(true) expect(_isAdminRoute('/admin/users')).toBe(true) expect(_isAdminRoute('/admin/accounts')).toBe(true) }) it('应该正确识别非管理员路由', () => { - const { _isAdminRoute } = useRoutePrefetch() + const { _isAdminRoute } = useRoutePrefetch(mockRouter) expect(_isAdminRoute('/dashboard')).toBe(false) expect(_isAdminRoute('/keys')).toBe(false) expect(_isAdminRoute('/usage')).toBe(false) @@ -74,7 +87,7 @@ describe('useRoutePrefetch', () => { describe('_getPrefetchConfig', () => { it('管理员 dashboard 应该返回正确的预加载配置', () => { - const { _getPrefetchConfig } = useRoutePrefetch() + const { _getPrefetchConfig } = useRoutePrefetch(mockRouter) const route = createMockRoute('/admin/dashboard') const config = _getPrefetchConfig(route) @@ -82,7 +95,7 @@ describe('useRoutePrefetch', () => { }) it('普通用户 dashboard 应该返回正确的预加载配置', () => { - const { _getPrefetchConfig } = useRoutePrefetch() + const { _getPrefetchConfig } = useRoutePrefetch(mockRouter) const route = createMockRoute('/dashboard') const config = _getPrefetchConfig(route) @@ -90,7 +103,7 @@ describe('useRoutePrefetch', () => { }) it('未定义的路由应该返回空数组', () => { - const { _getPrefetchConfig } = useRoutePrefetch() + const { _getPrefetchConfig } = useRoutePrefetch(mockRouter) const route = createMockRoute('/unknown-route') const config = _getPrefetchConfig(route) @@ -100,7 +113,7 @@ describe('useRoutePrefetch', () => { describe('triggerPrefetch', () => { it('应该在浏览器空闲时触发预加载', async () => { - const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter) const route = createMockRoute('/admin/dashboard') triggerPrefetch(route) @@ -112,7 +125,7 @@ describe('useRoutePrefetch', () => { }) it('应该避免重复预加载同一路由', async () => { - const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter) const route = createMockRoute('/admin/dashboard') triggerPrefetch(route) @@ -129,7 +142,7 @@ describe('useRoutePrefetch', () => { describe('cancelPendingPrefetch', () => { it('应该取消挂起的预加载任务', () => { - const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch() + const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter) const route = createMockRoute('/admin/dashboard') triggerPrefetch(route) @@ -142,7 +155,7 @@ describe('useRoutePrefetch', () => { describe('路由变化时取消之前的预加载', () => { it('应该在路由变化时取消之前的预加载任务', async () => { - const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter) // 触发第一个路由的预加载 triggerPrefetch(createMockRoute('/admin/dashboard')) @@ -160,7 +173,7 @@ describe('useRoutePrefetch', () => { describe('resetPrefetchState', () => { it('应该重置所有预加载状态', async () => { - const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch() + const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch(mockRouter) const route = createMockRoute('/admin/dashboard') triggerPrefetch(route) @@ -194,7 +207,7 @@ describe('useRoutePrefetch', () => { return setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), timeout) }) - const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch(mockRouter) const route = createMockRoute('/dashboard') triggerPrefetch(route) @@ -208,12 +221,24 @@ describe('useRoutePrefetch', () => { describe('预加载失败处理', () => { it('预加载失败时应该静默处理不影响页面功能', async () => { - // 这个测试验证预加载失败不会抛出异常 - const { triggerPrefetch } = useRoutePrefetch() + const { triggerPrefetch } = useRoutePrefetch(mockRouter) const route = createMockRoute('/admin/dashboard') // 不应该抛出异常 expect(() => triggerPrefetch(route)).not.toThrow() }) }) + + describe('无 router 时的行为', () => { + it('没有传入 router 时应该正常工作但不执行预加载', async () => { + const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch() + const route = createMockRoute('/admin/dashboard') + + triggerPrefetch(route) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // 没有 router,无法获取组件,所以不会预加载 + expect(prefetchedRoutes.value.size).toBe(0) + }) + }) }) diff --git a/frontend/src/composables/useRoutePrefetch.ts b/frontend/src/composables/useRoutePrefetch.ts index 854305ce..b1b5a032 100644 --- a/frontend/src/composables/useRoutePrefetch.ts +++ b/frontend/src/composables/useRoutePrefetch.ts @@ -1,9 +1,14 @@ /** * 路由预加载组合式函数 * 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验 + * + * 优化说明: + * - 不使用静态 import() 映射表,避免增加入口文件大小 + * - 通过路由配置动态获取组件的 import 函数 + * - 只在实际需要预加载时才执行 */ import { ref, readonly } from 'vue' -import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' +import type { RouteLocationNormalized, Router } from 'vue-router' /** * 组件导入函数类型 @@ -11,32 +16,31 @@ import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' type ComponentImportFn = () => Promise /** - * 预加载配置类型 + * 预加载邻接表:定义每个路由应该预加载哪些相邻路由 + * 只存储路由路径,不存储 import 函数,避免打包问题 */ -interface PrefetchConfig { - [path: string]: ComponentImportFn[] -} - -/** - * 路由预加载元数据扩展 - * 在路由 meta 中可以指定 prefetch 配置 - */ -declare module 'vue-router' { - interface RouteMeta { - /** 需要预加载的路由路径列表 */ - prefetch?: string[] - } +const PREFETCH_ADJACENCY: Record = { + // Admin routes - 预加载最常访问的相邻页面 + '/admin/dashboard': ['/admin/accounts', '/admin/users'], + '/admin/accounts': ['/admin/dashboard', '/admin/users'], + '/admin/users': ['/admin/groups', '/admin/dashboard'], + '/admin/groups': ['/admin/subscriptions', '/admin/users'], + '/admin/subscriptions': ['/admin/groups', '/admin/redeem'], + // User routes + '/dashboard': ['/keys', '/usage'], + '/keys': ['/dashboard', '/usage'], + '/usage': ['/keys', '/redeem'], + '/redeem': ['/usage', '/profile'], + '/profile': ['/dashboard', '/keys'] } /** * requestIdleCallback 的返回类型 - * 在支持的浏览器中返回 number,polyfill 中使用 ReturnType */ type IdleCallbackHandle = number | ReturnType /** - * requestIdleCallback polyfill - * Safari < 15 不支持 requestIdleCallback + * requestIdleCallback polyfill (Safari < 15) */ const scheduleIdleCallback = ( callback: IdleRequestCallback, @@ -45,12 +49,8 @@ const scheduleIdleCallback = ( if (typeof window.requestIdleCallback === 'function') { return window.requestIdleCallback(callback, options) } - // Fallback: 使用 setTimeout 模拟,延迟 1 秒执行 return setTimeout(() => { - callback({ - didTimeout: false, - timeRemaining: () => 50 - }) + callback({ didTimeout: false, timeRemaining: () => 50 }) }, 1000) } @@ -62,169 +62,46 @@ const cancelScheduledCallback = (handle: IdleCallbackHandle): void => { } } -/** - * 从路由配置自动生成预加载映射表 - * 根据路由的 meta.prefetch 配置和同级路由自动生成 - * - * @param routes - 路由配置数组 - * @returns 预加载映射表 - */ -export function generatePrefetchMap(routes: RouteRecordRaw[]): PrefetchConfig { - const prefetchMap: PrefetchConfig = {} - const routeComponentMap = new Map() - - // 第一遍:收集所有路由的组件导入函数 - const collectComponents = (routeList: RouteRecordRaw[], prefix = '') => { - for (const route of routeList) { - if (route.redirect) continue - - const fullPath = prefix + route.path - if (route.component && typeof route.component === 'function') { - routeComponentMap.set(fullPath, route.component as ComponentImportFn) - } - - // 递归处理子路由 - if (route.children) { - collectComponents(route.children, fullPath) - } - } - } - - collectComponents(routes) - - // 第二遍:根据 meta.prefetch 或同级路由生成预加载映射 - const generateMapping = (routeList: RouteRecordRaw[], siblings: RouteRecordRaw[] = []) => { - for (let i = 0; i < routeList.length; i++) { - const route = routeList[i] - if (route.redirect || !route.component) continue - - const path = route.path - const prefetchPaths: string[] = [] - - // 优先使用 meta.prefetch 配置 - if (route.meta?.prefetch && Array.isArray(route.meta.prefetch)) { - prefetchPaths.push(...route.meta.prefetch) - } else { - // 自动预加载相邻的同级路由(前后各一个) - const siblingRoutes = siblings.length > 0 ? siblings : routeList - const currentIndex = siblingRoutes.findIndex((r) => r.path === path) - - if (currentIndex > 0) { - const prev = siblingRoutes[currentIndex - 1] - if (prev && !prev.redirect && prev.component) { - prefetchPaths.push(prev.path) - } - } - if (currentIndex < siblingRoutes.length - 1) { - const next = siblingRoutes[currentIndex + 1] - if (next && !next.redirect && next.component) { - prefetchPaths.push(next.path) - } - } - } - - // 转换为组件导入函数 - const importFns: ComponentImportFn[] = [] - for (const prefetchPath of prefetchPaths) { - const importFn = routeComponentMap.get(prefetchPath) - if (importFn) { - importFns.push(importFn) - } - } - - if (importFns.length > 0) { - prefetchMap[path] = importFns - } - - // 递归处理子路由 - if (route.children) { - generateMapping(route.children, route.children) - } - } - } - - // 分别处理用户路由和管理员路由 - const userRoutes = routes.filter( - (r) => !r.path.startsWith('/admin') && !r.path.startsWith('/auth') && !r.path.startsWith('/setup') - ) - const adminRoutes = routes.filter((r) => r.path.startsWith('/admin')) - - generateMapping(userRoutes, userRoutes) - generateMapping(adminRoutes, adminRoutes) - - return prefetchMap -} - -/** - * 默认预加载映射表(手动配置,优先级更高) - * 可以覆盖自动生成的映射 - */ -const defaultAdminPrefetchMap: PrefetchConfig = { - '/admin/dashboard': [ - () => import('@/views/admin/AccountsView.vue'), - () => import('@/views/admin/UsersView.vue') - ], - '/admin/accounts': [ - () => import('@/views/admin/DashboardView.vue'), - () => import('@/views/admin/UsersView.vue') - ], - '/admin/users': [ - () => import('@/views/admin/GroupsView.vue'), - () => import('@/views/admin/DashboardView.vue') - ] -} - -const defaultUserPrefetchMap: PrefetchConfig = { - '/dashboard': [ - () => import('@/views/user/KeysView.vue'), - () => import('@/views/user/UsageView.vue') - ], - '/keys': [ - () => import('@/views/user/DashboardView.vue'), - () => import('@/views/user/UsageView.vue') - ], - '/usage': [ - () => import('@/views/user/KeysView.vue'), - () => import('@/views/user/RedeemView.vue') - ] -} - /** * 路由预加载组合式函数 * - * @param customPrefetchMap - 自定义预加载映射表(可选) + * @param router - Vue Router 实例,用于获取路由组件 */ -export function useRoutePrefetch(customPrefetchMap?: PrefetchConfig) { - // 合并预加载映射表:自定义 > 默认管理员 > 默认用户 - const prefetchMap: PrefetchConfig = { - ...defaultUserPrefetchMap, - ...defaultAdminPrefetchMap, - ...customPrefetchMap - } - +export function useRoutePrefetch(router?: Router) { // 当前挂起的预加载任务句柄 const pendingPrefetchHandle = ref(null) - // 已预加载的路由集合(避免重复预加载) + // 已预加载的路由集合 const prefetchedRoutes = ref>(new Set()) /** - * 判断是否为管理员路由 + * 从路由配置中获取组件的 import 函数 */ - const isAdminRoute = (path: string): boolean => { - return path.startsWith('/admin') + const getComponentImporter = (path: string): ComponentImportFn | null => { + if (!router) return null + + const routes = router.getRoutes() + const route = routes.find((r) => r.path === path) + + if (route && route.components?.default) { + const component = route.components.default + // 检查是否是懒加载组件(函数形式) + if (typeof component === 'function') { + return component as ComponentImportFn + } + } + return null } /** - * 获取当前路由对应的预加载配置 + * 获取当前路由应该预加载的路由路径列表 */ - const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => { - return prefetchMap[route.path] || [] + const getPrefetchPaths = (route: RouteLocationNormalized): string[] => { + return PREFETCH_ADJACENCY[route.path] || [] } /** * 执行单个组件的预加载 - * 静默处理错误,不影响页面功能 */ const prefetchComponent = async (importFn: ComponentImportFn): Promise => { try { @@ -249,56 +126,77 @@ export function useRoutePrefetch(customPrefetchMap?: PrefetchConfig) { /** * 触发路由预加载 - * 在浏览器空闲时执行,超时 2 秒后强制执行 */ const triggerPrefetch = (route: RouteLocationNormalized): void => { - // 取消之前的预加载任务 cancelPendingPrefetch() - const prefetchList = getPrefetchConfig(route) - if (prefetchList.length === 0) { - return - } + const prefetchPaths = getPrefetchPaths(route) + if (prefetchPaths.length === 0) return - // 在浏览器空闲时执行预加载 pendingPrefetchHandle.value = scheduleIdleCallback( () => { pendingPrefetchHandle.value = null - // 过滤掉已预加载的组件 const routePath = route.path - if (prefetchedRoutes.value.has(routePath)) { - return + if (prefetchedRoutes.value.has(routePath)) return + + // 获取需要预加载的组件 import 函数 + const importFns: ComponentImportFn[] = [] + for (const path of prefetchPaths) { + const importFn = getComponentImporter(path) + if (importFn) { + importFns.push(importFn) + } } - // 执行预加载 - Promise.all(prefetchList.map(prefetchComponent)).then(() => { - prefetchedRoutes.value.add(routePath) - }) + if (importFns.length > 0) { + Promise.all(importFns.map(prefetchComponent)).then(() => { + prefetchedRoutes.value.add(routePath) + }) + } }, - { timeout: 2000 } // 2 秒超时 + { timeout: 2000 } ) } /** - * 重置预加载状态(用于测试) + * 重置预加载状态 */ const resetPrefetchState = (): void => { cancelPendingPrefetch() prefetchedRoutes.value.clear() } + /** + * 判断是否为管理员路由 + */ + const isAdminRoute = (path: string): boolean => { + return path.startsWith('/admin') + } + + /** + * 获取预加载配置(兼容旧 API) + */ + const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => { + const paths = getPrefetchPaths(route) + const importFns: ComponentImportFn[] = [] + for (const path of paths) { + const importFn = getComponentImporter(path) + if (importFn) importFns.push(importFn) + } + return importFns + } + return { prefetchedRoutes: readonly(prefetchedRoutes), triggerPrefetch, cancelPendingPrefetch, resetPrefetchState, - // 导出用于测试 _getPrefetchConfig: getPrefetchConfig, _isAdminRoute: isAdminRoute } } -// 导出预加载映射表(用于测试) -export const _adminPrefetchMap = defaultAdminPrefetchMap -export const _userPrefetchMap = defaultUserPrefetchMap +// 兼容旧测试的导出 +export const _adminPrefetchMap = PREFETCH_ADJACENCY +export const _userPrefetchMap = PREFETCH_ADJACENCY diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index bdfa1fec..96127063 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -330,7 +330,8 @@ let authInitialized = false // 初始化导航加载状态和预加载 const navigationLoading = useNavigationLoadingState() -const routePrefetch = useRoutePrefetch() +// 延迟初始化预加载,传入 router 实例 +let routePrefetch: ReturnType | null = null router.beforeEach((to, _from, next) => { // 开始导航加载状态 @@ -414,6 +415,10 @@ router.afterEach((to) => { // 结束导航加载状态 navigationLoading.endNavigation() + // 懒初始化预加载(首次导航时创建,传入 router 实例) + if (!routePrefetch) { + routePrefetch = useRoutePrefetch(router) + } // 触发路由预加载(在浏览器空闲时执行) routePrefetch.triggerPrefetch(to) })