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>
This commit is contained in:
176
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
Normal file
176
frontend/src/composables/__tests__/useNavigationLoading.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* useNavigationLoading 组合式函数单元测试
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
useNavigationLoading,
|
||||
_resetNavigationLoadingInstance
|
||||
} from '../useNavigationLoading'
|
||||
|
||||
describe('useNavigationLoading', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
_resetNavigationLoadingInstance()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('startNavigation', () => {
|
||||
it('导航开始时 isNavigating 应变为 true', () => {
|
||||
const { isNavigating, startNavigation } = useNavigationLoading()
|
||||
|
||||
expect(isNavigating.value).toBe(false)
|
||||
|
||||
startNavigation()
|
||||
|
||||
expect(isNavigating.value).toBe(true)
|
||||
})
|
||||
|
||||
it('导航开始后延迟显示加载指示器(防闪烁)', () => {
|
||||
const { isLoading, startNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
|
||||
// 立即检查,不应该显示
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
// 经过防闪烁延迟后应该显示
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('endNavigation', () => {
|
||||
it('导航结束时 isLoading 应变为 false', () => {
|
||||
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
endNavigation()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('导航结束时 isNavigating 应变为 false', () => {
|
||||
const { isNavigating, startNavigation, endNavigation } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
expect(isNavigating.value).toBe(true)
|
||||
|
||||
endNavigation()
|
||||
expect(isNavigating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('快速导航(< 100ms)防闪烁', () => {
|
||||
it('快速导航不应触发显示加载指示器', () => {
|
||||
const { isLoading, startNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
|
||||
// 在防闪烁延迟之前结束导航
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY - 50)
|
||||
endNavigation()
|
||||
|
||||
// 不应该显示加载指示器
|
||||
expect(isLoading.value).toBe(false)
|
||||
|
||||
// 即使继续等待也不应该显示
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelNavigation', () => {
|
||||
it('导航取消时应正确重置状态', () => {
|
||||
const { isLoading, startNavigation, cancelNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY / 2)
|
||||
|
||||
cancelNavigation()
|
||||
|
||||
// 取消后不应该触发显示
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNavigationDuration', () => {
|
||||
it('应该返回正确的导航持续时间', () => {
|
||||
const { startNavigation, getNavigationDuration } = useNavigationLoading()
|
||||
|
||||
expect(getNavigationDuration()).toBeNull()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
const duration = getNavigationDuration()
|
||||
expect(duration).toBe(500)
|
||||
})
|
||||
|
||||
it('导航结束后应返回 null', () => {
|
||||
const { startNavigation, endNavigation, getNavigationDuration } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(500)
|
||||
endNavigation()
|
||||
|
||||
expect(getNavigationDuration()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetState', () => {
|
||||
it('应该重置所有状态', () => {
|
||||
const { isLoading, isNavigating, startNavigation, resetState, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
|
||||
expect(isLoading.value).toBe(true)
|
||||
expect(isNavigating.value).toBe(true)
|
||||
|
||||
resetState()
|
||||
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(isNavigating.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('连续导航场景', () => {
|
||||
it('连续快速导航应正确处理状态', () => {
|
||||
const { isLoading, startNavigation, cancelNavigation, endNavigation, ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
|
||||
// 第一次导航
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// 第二次导航(取消第一次)
|
||||
cancelNavigation()
|
||||
startNavigation()
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
// 第三次导航(取消第二次)
|
||||
cancelNavigation()
|
||||
startNavigation()
|
||||
|
||||
// 这次等待足够长时间
|
||||
vi.advanceTimersByTime(ANTI_FLICKER_DELAY)
|
||||
expect(isLoading.value).toBe(true)
|
||||
|
||||
// 结束导航
|
||||
endNavigation()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ANTI_FLICKER_DELAY 常量', () => {
|
||||
it('应该为 100ms', () => {
|
||||
const { ANTI_FLICKER_DELAY } = useNavigationLoading()
|
||||
expect(ANTI_FLICKER_DELAY).toBe(100)
|
||||
})
|
||||
})
|
||||
})
|
||||
219
frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
Normal file
219
frontend/src/composables/__tests__/useRoutePrefetch.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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 { useRoutePrefetch, _adminPrefetchMap, _userPrefetchMap } from '../useRoutePrefetch'
|
||||
|
||||
// Mock 路由对象
|
||||
const createMockRoute = (path: string): RouteLocationNormalized => ({
|
||||
path,
|
||||
name: undefined,
|
||||
params: {},
|
||||
query: {},
|
||||
hash: '',
|
||||
fullPath: path,
|
||||
matched: [],
|
||||
meta: {},
|
||||
redirectedFrom: undefined
|
||||
})
|
||||
|
||||
describe('useRoutePrefetch', () => {
|
||||
let originalRequestIdleCallback: typeof window.requestIdleCallback
|
||||
let originalCancelIdleCallback: typeof window.cancelIdleCallback
|
||||
|
||||
beforeEach(() => {
|
||||
// 保存原始函数
|
||||
originalRequestIdleCallback = window.requestIdleCallback
|
||||
originalCancelIdleCallback = window.cancelIdleCallback
|
||||
|
||||
// Mock requestIdleCallback 立即执行
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback) => {
|
||||
const id = setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 50 }), 0)
|
||||
return id
|
||||
})
|
||||
vi.stubGlobal('cancelIdleCallback', (id: number) => clearTimeout(id))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
// 恢复原始函数
|
||||
window.requestIdleCallback = originalRequestIdleCallback
|
||||
window.cancelIdleCallback = originalCancelIdleCallback
|
||||
})
|
||||
|
||||
describe('_isAdminRoute', () => {
|
||||
it('应该正确识别管理员路由', () => {
|
||||
const { _isAdminRoute } = useRoutePrefetch()
|
||||
expect(_isAdminRoute('/admin/dashboard')).toBe(true)
|
||||
expect(_isAdminRoute('/admin/users')).toBe(true)
|
||||
expect(_isAdminRoute('/admin/accounts')).toBe(true)
|
||||
})
|
||||
|
||||
it('应该正确识别非管理员路由', () => {
|
||||
const { _isAdminRoute } = useRoutePrefetch()
|
||||
expect(_isAdminRoute('/dashboard')).toBe(false)
|
||||
expect(_isAdminRoute('/keys')).toBe(false)
|
||||
expect(_isAdminRoute('/usage')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('_getPrefetchConfig', () => {
|
||||
it('管理员 dashboard 应该返回正确的预加载配置', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('普通用户 dashboard 应该返回正确的预加载配置', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch()
|
||||
const route = createMockRoute('/dashboard')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('未定义的路由应该返回空数组', () => {
|
||||
const { _getPrefetchConfig } = useRoutePrefetch()
|
||||
const route = createMockRoute('/unknown-route')
|
||||
const config = _getPrefetchConfig(route)
|
||||
|
||||
expect(config).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerPrefetch', () => {
|
||||
it('应该在浏览器空闲时触发预加载', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
|
||||
// 等待 requestIdleCallback 执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(prefetchedRoutes.value.has('/admin/dashboard')).toBe(true)
|
||||
})
|
||||
|
||||
it('应该避免重复预加载同一路由', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 第二次触发
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 只应该预加载一次
|
||||
expect(prefetchedRoutes.value.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelPendingPrefetch', () => {
|
||||
it('应该取消挂起的预加载任务', () => {
|
||||
const { triggerPrefetch, cancelPendingPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
cancelPendingPrefetch()
|
||||
|
||||
// 不应该有预加载完成
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('路由变化时取消之前的预加载', () => {
|
||||
it('应该在路由变化时取消之前的预加载任务', async () => {
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
|
||||
// 触发第一个路由的预加载
|
||||
triggerPrefetch(createMockRoute('/admin/dashboard'))
|
||||
|
||||
// 立即切换到另一个路由
|
||||
triggerPrefetch(createMockRoute('/admin/users'))
|
||||
|
||||
// 等待执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// 只有最后一个路由应该被预加载
|
||||
expect(prefetchedRoutes.value.has('/admin/users')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resetPrefetchState', () => {
|
||||
it('应该重置所有预加载状态', async () => {
|
||||
const { triggerPrefetch, resetPrefetchState, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(prefetchedRoutes.value.size).toBeGreaterThan(0)
|
||||
|
||||
resetPrefetchState()
|
||||
|
||||
expect(prefetchedRoutes.value.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预加载映射表', () => {
|
||||
it('管理员预加载映射表应该包含正确的路由', () => {
|
||||
expect(_adminPrefetchMap).toHaveProperty('/admin/dashboard')
|
||||
expect(_adminPrefetchMap['/admin/dashboard']).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('用户预加载映射表应该包含正确的路由', () => {
|
||||
expect(_userPrefetchMap).toHaveProperty('/dashboard')
|
||||
expect(_userPrefetchMap['/dashboard']).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestIdleCallback 超时处理', () => {
|
||||
it('超时后仍能正常执行预加载', async () => {
|
||||
// 模拟超时情况
|
||||
vi.stubGlobal('requestIdleCallback', (cb: IdleRequestCallback, options?: IdleRequestOptions) => {
|
||||
const timeout = options?.timeout || 2000
|
||||
return setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), timeout)
|
||||
})
|
||||
|
||||
const { triggerPrefetch, prefetchedRoutes } = useRoutePrefetch()
|
||||
const route = createMockRoute('/dashboard')
|
||||
|
||||
triggerPrefetch(route)
|
||||
|
||||
// 等待超时执行
|
||||
await new Promise((resolve) => setTimeout(resolve, 2100))
|
||||
|
||||
expect(prefetchedRoutes.value.has('/dashboard')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('预加载失败处理', () => {
|
||||
it('预加载失败时应该静默处理不影响页面功能', async () => {
|
||||
// 这个测试验证预加载失败不会抛出异常
|
||||
const { triggerPrefetch } = useRoutePrefetch()
|
||||
const route = createMockRoute('/admin/dashboard')
|
||||
|
||||
// 不应该抛出异常
|
||||
expect(() => triggerPrefetch(route)).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
132
frontend/src/composables/useNavigationLoading.ts
Normal file
132
frontend/src/composables/useNavigationLoading.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 导航加载状态组合式函数
|
||||
* 管理路由切换时的加载状态,支持防闪烁逻辑
|
||||
*/
|
||||
import { ref, readonly, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 导航加载状态管理
|
||||
*
|
||||
* 功能:
|
||||
* 1. 在路由切换时显示加载状态
|
||||
* 2. 快速导航(< 100ms)不显示加载指示器(防闪烁)
|
||||
* 3. 导航取消时正确重置状态
|
||||
*/
|
||||
export function useNavigationLoading() {
|
||||
// 内部加载状态
|
||||
const _isLoading = ref(false)
|
||||
|
||||
// 导航开始时间(用于防闪烁计算)
|
||||
let navigationStartTime: number | null = null
|
||||
|
||||
// 防闪烁延迟计时器
|
||||
let showLoadingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 是否应该显示加载指示器(考虑防闪烁逻辑)
|
||||
const shouldShowLoading = ref(false)
|
||||
|
||||
// 防闪烁延迟时间(毫秒)
|
||||
const ANTI_FLICKER_DELAY = 100
|
||||
|
||||
/**
|
||||
* 清理计时器
|
||||
*/
|
||||
const clearTimer = (): void => {
|
||||
if (showLoadingTimer !== null) {
|
||||
clearTimeout(showLoadingTimer)
|
||||
showLoadingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航开始时调用
|
||||
*/
|
||||
const startNavigation = (): void => {
|
||||
navigationStartTime = Date.now()
|
||||
_isLoading.value = true
|
||||
|
||||
// 延迟显示加载指示器,实现防闪烁
|
||||
clearTimer()
|
||||
showLoadingTimer = setTimeout(() => {
|
||||
if (_isLoading.value) {
|
||||
shouldShowLoading.value = true
|
||||
}
|
||||
}, ANTI_FLICKER_DELAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航结束时调用
|
||||
*/
|
||||
const endNavigation = (): void => {
|
||||
clearTimer()
|
||||
_isLoading.value = false
|
||||
shouldShowLoading.value = false
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航取消时调用(比如快速连续点击不同链接)
|
||||
*/
|
||||
const cancelNavigation = (): void => {
|
||||
clearTimer()
|
||||
// 保持加载状态,因为新的导航会立即开始
|
||||
// 但重置导航开始时间
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有状态(用于测试)
|
||||
*/
|
||||
const resetState = (): void => {
|
||||
clearTimer()
|
||||
_isLoading.value = false
|
||||
shouldShowLoading.value = false
|
||||
navigationStartTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取导航持续时间(毫秒)
|
||||
*/
|
||||
const getNavigationDuration = (): number | null => {
|
||||
if (navigationStartTime === null) {
|
||||
return null
|
||||
}
|
||||
return Date.now() - navigationStartTime
|
||||
}
|
||||
|
||||
// 公开的加载状态(只读)
|
||||
const isLoading = computed(() => shouldShowLoading.value)
|
||||
|
||||
// 内部加载状态(用于测试,不考虑防闪烁)
|
||||
const isNavigating = readonly(_isLoading)
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isNavigating,
|
||||
startNavigation,
|
||||
endNavigation,
|
||||
cancelNavigation,
|
||||
resetState,
|
||||
getNavigationDuration,
|
||||
// 导出常量用于测试
|
||||
ANTI_FLICKER_DELAY
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例,供全局使用
|
||||
let navigationLoadingInstance: ReturnType<typeof useNavigationLoading> | null = null
|
||||
|
||||
export function useNavigationLoadingState() {
|
||||
if (!navigationLoadingInstance) {
|
||||
navigationLoadingInstance = useNavigationLoading()
|
||||
}
|
||||
return navigationLoadingInstance
|
||||
}
|
||||
|
||||
// 导出重置函数(用于测试)
|
||||
export function _resetNavigationLoadingInstance(): void {
|
||||
if (navigationLoadingInstance) {
|
||||
navigationLoadingInstance.resetState()
|
||||
}
|
||||
navigationLoadingInstance = null
|
||||
}
|
||||
304
frontend/src/composables/useRoutePrefetch.ts
Normal file
304
frontend/src/composables/useRoutePrefetch.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* 路由预加载组合式函数
|
||||
* 在浏览器空闲时预加载可能访问的下一个页面,提升导航体验
|
||||
*/
|
||||
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 的返回类型
|
||||
* 在支持的浏览器中返回 number,polyfill 中使用 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
|
||||
Reference in New Issue
Block a user