/** * 路由预加载组合式函数 * 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验 */ import { ref, readonly } from 'vue' import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router' /** * 组件导入函数类型 */ type ComponentImportFn = () => Promise /** * 预加载配置类型 */ interface PrefetchConfig { [path: string]: ComponentImportFn[] } /** * 路由预加载元数据扩展 * 在路由 meta 中可以指定 prefetch 配置 */ declare module 'vue-router' { interface RouteMeta { /** 需要预加载的路由路径列表 */ prefetch?: string[] } } /** * requestIdleCallback 的返回类型 * 在支持的浏览器中返回 number,polyfill 中使用 ReturnType */ type IdleCallbackHandle = number | ReturnType /** * requestIdleCallback polyfill * Safari < 15 不支持 requestIdleCallback */ const scheduleIdleCallback = ( callback: IdleRequestCallback, options?: IdleRequestOptions ): IdleCallbackHandle => { if (typeof window.requestIdleCallback === 'function') { return window.requestIdleCallback(callback, options) } // Fallback: 使用 setTimeout 模拟,延迟 1 秒执行 return setTimeout(() => { callback({ didTimeout: false, timeRemaining: () => 50 }) }, 1000) } const cancelScheduledCallback = (handle: IdleCallbackHandle): void => { if (typeof window.cancelIdleCallback === 'function' && typeof handle === 'number') { window.cancelIdleCallback(handle) } else { clearTimeout(handle) } } /** * 从路由配置自动生成预加载映射表 * 根据路由的 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 - 自定义预加载映射表(可选) */ export function useRoutePrefetch(customPrefetchMap?: PrefetchConfig) { // 合并预加载映射表:自定义 > 默认管理员 > 默认用户 const prefetchMap: PrefetchConfig = { ...defaultUserPrefetchMap, ...defaultAdminPrefetchMap, ...customPrefetchMap } // 当前挂起的预加载任务句柄 const pendingPrefetchHandle = ref(null) // 已预加载的路由集合(避免重复预加载) const prefetchedRoutes = ref>(new Set()) /** * 判断是否为管理员路由 */ const isAdminRoute = (path: string): boolean => { return path.startsWith('/admin') } /** * 获取当前路由对应的预加载配置 */ const getPrefetchConfig = (route: RouteLocationNormalized): ComponentImportFn[] => { return prefetchMap[route.path] || [] } /** * 执行单个组件的预加载 * 静默处理错误,不影响页面功能 */ const prefetchComponent = async (importFn: ComponentImportFn): Promise => { try { await importFn() } catch (error) { // 静默处理预加载错误 if (import.meta.env.DEV) { console.debug('[Prefetch] Failed to prefetch component:', error) } } } /** * 取消挂起的预加载任务 */ const cancelPendingPrefetch = (): void => { if (pendingPrefetchHandle.value !== null) { cancelScheduledCallback(pendingPrefetchHandle.value) pendingPrefetchHandle.value = null } } /** * 触发路由预加载 * 在浏览器空闲时执行,超时 2 秒后强制执行 */ const triggerPrefetch = (route: RouteLocationNormalized): void => { // 取消之前的预加载任务 cancelPendingPrefetch() const prefetchList = getPrefetchConfig(route) if (prefetchList.length === 0) { return } // 在浏览器空闲时执行预加载 pendingPrefetchHandle.value = scheduleIdleCallback( () => { pendingPrefetchHandle.value = null // 过滤掉已预加载的组件 const routePath = route.path if (prefetchedRoutes.value.has(routePath)) { return } // 执行预加载 Promise.all(prefetchList.map(prefetchComponent)).then(() => { prefetchedRoutes.value.add(routePath) }) }, { timeout: 2000 } // 2 秒超时 ) } /** * 重置预加载状态(用于测试) */ const resetPrefetchState = (): void => { cancelPendingPrefetch() prefetchedRoutes.value.clear() } return { prefetchedRoutes: readonly(prefetchedRoutes), triggerPrefetch, cancelPendingPrefetch, resetPrefetchState, // 导出用于测试 _getPrefetchConfig: getPrefetchConfig, _isAdminRoute: isAdminRoute } } // 导出预加载映射表(用于测试) export const _adminPrefetchMap = defaultAdminPrefetchMap export const _userPrefetchMap = defaultUserPrefetchMap