diff --git a/src/layouts/DefaultLayout.vue b/src/layouts/DefaultLayout.vue index b2bbe88..c47d270 100644 --- a/src/layouts/DefaultLayout.vue +++ b/src/layouts/DefaultLayout.vue @@ -21,6 +21,8 @@ import TabBar from '@/components/TabBar.vue' import RouteProgress from '@/components/RouteProgress.vue' import PageMask from '@/components/PageMask.vue' import RoleSwitcher from '@/components/RoleSwitcher.vue' +import { staticRoutes } from '@/router' +import { prefetchVisibleRoutes } from '@/utils/prefetchRoutes' const appStore = useAppStore() const userStore = useUserStore() @@ -107,8 +109,16 @@ onMounted(() => { unreadTimer = setInterval(() => { void loadUnreadCount() }, 60000) + // 分包预热:把当前用户可访问页面的懒加载 chunk 提前拉取并缓存(详见 utils/prefetchRoutes) + prefetchVisibleRoutes(staticRoutes, (p) => userStore.hasMenuPath(p)) }) +// 菜单变化(切换角色/后端菜单刷新)后重新预热;工具内部按「可见路径集合」签名去重 +watch( + () => userStore.menus, + () => prefetchVisibleRoutes(staticRoutes, (p) => userStore.hasMenuPath(p)), +) + onBeforeUnmount(() => { if (unreadTimer) clearInterval(unreadTimer) mql?.removeEventListener('change', updateIsMobile) diff --git a/src/utils/prefetchRoutes.ts b/src/utils/prefetchRoutes.ts new file mode 100644 index 0000000..9b289d9 --- /dev/null +++ b/src/utils/prefetchRoutes.ts @@ -0,0 +1,123 @@ +/** + * 路由分包预热(prefetch) + * + * 目的:登录后把「当前用户可访问」页面的懒加载分包全部提前请求一遍,让浏览器缓存 + * 落地。构建产物文件名带内容 hash,配合服务端 72h 强缓存(gvsds/apps/core/views.py + * 的 _serve_file 对 hash 文件的 Cache-Control),用户点开新页面时无需再等网络。 + * + * 覆盖面(= "用户有权限的页面"): + * - menuGuarded 路由:仅当路径出现在当前用户菜单树中(即后端判定有权限)才预取 + * - 其余非 menuGuarded 路由:登录即可访问,直接预取(分包本身是公开静态资源) + * - 跳过 Login(未登录场景)与 catch-all NotFound + * + * 调度策略: + * - 首屏渲染完成后用 requestIdleCallback 启动,避免与首屏请求抢带宽 + * - 限并发 + 每个分包之间留间隔,弱网/省流量模式(saveData / 2g)直接跳过 + * - 按「本次可见路径集合」做签名去重:菜单变化(切换角色)后会重新预热一次 + */ +import type { RouteRecordRaw } from 'vue-router' + +/** 同时进行的分包请求数 */ +const CONCURRENCY = 3 +/** 每个分包之间的间隔(毫秒),进一步削峰 */ +const GAP_MS = 150 + +interface PrefetchTarget { + /** 绝对路径(用于菜单权限比对) */ + path: string + /** 懒加载 loader:调用即触发该分包的动态 import */ + load: () => Promise + /** 是否受菜单权限约束 */ + menuGuarded: boolean +} + +/** 上次预热的目标签名(可见路径集合);相同则跳过 */ +let LastSignature = '' + +/** 拼接父子路径为绝对路径 */ +function JoinPath(parent: string, child: string): string { + if (child.startsWith('/')) return child + const base = parent.endsWith('/') ? parent.slice(0, -1) : parent + return `${base}/${child}` +} + +/** 递归收集懒加载路由目标 */ +function Collect(routes: RouteRecordRaw[], parentPath: string, out: PrefetchTarget[]): void { + for (const record of routes) { + const fullPath = JoinPath(parentPath, record.path) + const name = String(record.name || '') + const isLayoutRoot = fullPath === '/' && !!record.children?.length + if (!isLayoutRoot && typeof record.component === 'function' && name !== 'Login' && name !== 'NotFound') { + out.push({ + path: fullPath.split('?')[0], + load: record.component as () => Promise, + menuGuarded: record.meta?.menuGuarded === true, + }) + } + if (record.children?.length) { + Collect(record.children as RouteRecordRaw[], fullPath, out) + } + } +} + +/** 弱网 / 省流量模式:不预热 */ +function IsConstrainedNetwork(): boolean { + const conn = (navigator as unknown as { connection?: { saveData?: boolean; effectiveType?: string } }).connection + if (!conn) return false + if (conn.saveData === true) return true + return conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g' +} + +/** 限并发 + 间隔逐个预热;单个失败不影响后续 */ +async function Run(queue: PrefetchTarget[]): Promise { + let cursor = 0 + const worker = async (): Promise => { + while (cursor < queue.length) { + const item = queue[cursor] + cursor += 1 + try { + await item.load() + } catch { + // 单个分包预热失败(网络抖动/资源缺失)忽略,用户真正打开时会正常报错 + } + await new Promise((resolve) => setTimeout(resolve, GAP_MS)) + } + } + const size = Math.min(CONCURRENCY, queue.length) + await Promise.all(Array.from({ length: size }, () => worker())) +} + +/** + * 预热当前用户可访问页面的分包(幂等:同一可见路径集合只跑一次) + * + * @param routes 应用静态路由表(router 的 staticRoutes) + * @param hasMenuPath 判断路径是否在用户菜单树中(userStore.hasMenuPath) + */ +export function prefetchVisibleRoutes( + routes: RouteRecordRaw[], + hasMenuPath: (path: string) => boolean, +): void { + const targets: PrefetchTarget[] = [] + Collect(routes, '', targets) + const queue = targets.filter((t) => !t.menuGuarded || hasMenuPath(t.path)) + if (!queue.length) return + + // 签名去重:菜单未变化(含 HMR/重复挂载)不重复请求 + const signature = queue.map((t) => t.path).sort().join('|') + if (signature === LastSignature) return + LastSignature = signature + + if (IsConstrainedNetwork()) return + + const start = (): void => { + void Run(queue) + } + const idle = (window as unknown as { + requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number + }).requestIdleCallback + if (typeof idle === 'function') { + idle(start, { timeout: 3000 }) + } else { + setTimeout(start, 1200) + } +}