Files
sub2api/frontend/src/composables/useRoutePrefetch.ts
yangjianbo 8efa361728 perf(前端): 优化页面加载性能和用户体验
- 添加路由预加载功能,使用 requestIdleCallback 在浏览器空闲时预加载
- 配置 Vite manualChunks 分离 vendor 库(vue/ui/chart/i18n/misc)
- 新增 NavigationProgress 导航进度条组件,支持防闪烁和无障碍
- 集成 Vitest 测试框架,添加 40 个单元测试和集成测试
- 支持 prefers-reduced-motion 和暗色模式

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 21:43:39 +08:00

305 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 路由预加载组合式函数
* 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验
*/
import { ref, readonly } from 'vue'
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
/**
* 组件导入函数类型
*/
type ComponentImportFn = () => Promise<unknown>
/**
* 预加载配置类型
*/
interface PrefetchConfig {
[path: string]: ComponentImportFn[]
}
/**
* 路由预加载元数据扩展
* 在路由 meta 中可以指定 prefetch 配置
*/
declare module 'vue-router' {
interface RouteMeta {
/** 需要预加载的路由路径列表 */
prefetch?: string[]
}
}
/**
* requestIdleCallback 的返回类型
* 在支持的浏览器中返回 numberpolyfill 中使用 ReturnType<typeof setTimeout>
*/
type IdleCallbackHandle = number | ReturnType<typeof setTimeout>
/**
* 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<string, ComponentImportFn>()
// 第一遍:收集所有路由的组件导入函数
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<IdleCallbackHandle | null>(null)
// 已预加载的路由集合(避免重复预加载)
const prefetchedRoutes = ref<Set<string>>(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<void> => {
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