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:
yangjianbo
2026-01-16 21:43:39 +08:00
parent eb432a49ed
commit 92234857f7
15 changed files with 4243 additions and 4 deletions

View 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)
})
})
})

View 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()
})
})
})