perf(layout): 分包预热改为菜单悬停懒预取

- prefetchRoutes 由「登录后全量预热」改为 WarmRouteByPath 按需预热(幂等、弱网跳过)

- 侧边栏菜单项 mouseenter/focusin 触发预热;分组菜单预热其全部子页面

- 首屏零额外请求,仅预热用户已表达意图的页面
This commit is contained in:
TermiNexus committed 2026-09-29 12:01:01 +08:00
1 parent 96e0776407
commit 6a488fe452
2 files changed
+69 -95

No files matched your search

+30 -10
View File
@@ -22,7 +22,8 @@ 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'
import { WarmRouteByPath } from '@/utils/prefetchRoutes'
import type { MenuItem } from '@/types/api'
const appStore = useAppStore()
const userStore = useUserStore()
@@ -109,15 +110,21 @@ onMounted(() => {
unreadTimer = setInterval(() => {
void loadUnreadCount()
}, 60000)
// 分包预热:把当前用户可访问页面的懒加载 chunk 提前拉取并缓存(详见 utils/prefetchRoutes)
prefetchVisibleRoutes(staticRoutes, (p) => userStore.hasMenuPath(p))
})
// 菜单变化(切换角色/后端菜单刷新)后重新预热;工具内部按「可见路径集合」签名去重
watch(
() => userStore.menus,
() => prefetchVisibleRoutes(staticRoutes, (p) => userStore.hasMenuPath(p)),
)
// === 菜单意图预热(懒预取) ===
// 鼠标悬停 / 键盘聚焦菜单项时,提前拉取该页面的懒加载分包(幂等,详见 utils/prefetchRoutes),
// 用户真正点击时命中 72h 强缓存直接打开。
/** 单个菜单项:预热其页面分包 */
function WarmMenuIntent(path: string | undefined): void {
WarmRouteByPath(staticRoutes, path)
}
/** 分组菜单:预热其全部子页面 */
function WarmGroupIntent(menu: MenuItem): void {
for (const child of menu.Children || []) WarmMenuIntent(child.Path)
}
onBeforeUnmount(() => {
if (unreadTimer) clearInterval(unreadTimer)
@@ -389,7 +396,12 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
>
<template v-for="menu in userStore.menus" :key="menu.Path || menu.Title">
<!-- 含子菜单 -->
<el-sub-menu v-if="menu.Children?.length" :index="menu.Path || menu.Title">
<el-sub-menu
v-if="menu.Children?.length"
:index="menu.Path || menu.Title"
@mouseenter="WarmGroupIntent(menu)"
@focusin="WarmGroupIntent(menu)"
>
<template #title>
<i v-if="menu.Icon && isFaIcon(menu.Icon)" :class="menu.Icon" class="gerp-menu-icon" />
<el-icon v-else-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
@@ -400,6 +412,8 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
:key="child.Path || child.Title"
:index="child.Path || ''"
:title="child.Title"
@mouseenter="WarmMenuIntent(child.Path)"
@focusin="WarmMenuIntent(child.Path)"
>
<i v-if="child.Icon && isFaIcon(child.Icon)" :class="child.Icon" class="gerp-menu-icon" />
<el-icon v-else-if="child.Icon"><component :is="child.Icon" /></el-icon>
@@ -408,7 +422,13 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
</el-sub-menu>
<!-- 无子菜单 -->
<el-menu-item v-else :index="menu.Path || ''" :title="menu.Title">
<el-menu-item
v-else
:index="menu.Path || ''"
:title="menu.Title"
@mouseenter="WarmMenuIntent(menu.Path)"
@focusin="WarmMenuIntent(menu.Path)"
>
<i v-if="menu.Icon && isFaIcon(menu.Icon)" :class="menu.Icon" class="gerp-menu-icon" />
<el-icon v-else-if="menu.Icon"><component :is="menu.Icon" /></el-icon>
<template #title><span>{{ menu.Title }}</span></template>
+39 -85
View File
@@ -1,38 +1,19 @@
/**
* 路由分包预热(prefetch)
* 路由分包懒预热(intent prefetch)
*
* 目的:登录后把「当前用户可访问」页面的懒加载分包全部提前请求一遍,让浏览器缓存
* 落地。构建产物文件名带内容 hash,配合服务端 72h 强缓存(gvsds/apps/core/views.py
* 的 _serve_file 对 hash 文件的 Cache-Control),用户点开新页面时无需再等网络。
* 策略:不在登录后一次性拉取所有页面分包,而是在用户把鼠标移到(或键盘聚焦到)
* 侧边栏菜单项时才拉取该菜单对应页面的懒加载分包。配合服务端对构建产物的
* 72h immutable 强缓存(见 gvsds/apps/core/views.py 的 _serve_file),
* 用户真正点击时基本瞬时打开。
*
* 覆盖面(= "用户有权限的页面"):
* - 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<unknown>
/** 是否受菜单权限约束 */
menuGuarded: boolean
}
/** 上次预热的目标签名(可见路径集合);相同则跳过 */
let LastSignature = ''
/** 路径 → 懒加载 loader(首次使用时从路由表构建一次) */
let LoaderMap: Map<string, () => Promise<unknown>> | null = null
/** 已预热过的路径(同一会话不重复请求) */
const Warmed = new Set<string>()
/** 拼接父子路径为绝对路径 */
function JoinPath(parent: string, child: string): string {
@@ -41,18 +22,14 @@ function JoinPath(parent: string, child: string): string {
return `${base}/${child}`
}
/** 递归收集懒加载路由目标 */
function Collect(routes: RouteRecordRaw[], parentPath: string, out: PrefetchTarget[]): void {
/** 递归收集「绝对路径 → loader」 */
function Collect(routes: RouteRecordRaw[], parentPath: string, out: Map<string, () => Promise<unknown>>): 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<unknown>,
menuGuarded: record.meta?.menuGuarded === true,
})
out.set(fullPath.split('?')[0], record.component as () => Promise<unknown>)
}
if (record.children?.length) {
Collect(record.children as RouteRecordRaw[], fullPath, out)
@@ -60,6 +37,15 @@ function Collect(routes: RouteRecordRaw[], parentPath: string, out: PrefetchTarg
}
}
/** 惰性构建(首次预热时才遍历路由表) */
function EnsureMap(routes: RouteRecordRaw[]): Map<string, () => Promise<unknown>> {
if (!LoaderMap) {
LoaderMap = new Map()
Collect(routes, '', LoaderMap)
}
return LoaderMap
}
/** 弱网 / 省流量模式:不预热 */
function IsConstrainedNetwork(): boolean {
const conn = (navigator as unknown as { connection?: { saveData?: boolean; effectiveType?: string } }).connection
@@ -68,56 +54,24 @@ function IsConstrainedNetwork(): boolean {
return conn.effectiveType === 'slow-2g' || conn.effectiveType === '2g'
}
/** 限并发 + 间隔逐个预热;单个失败不影响后续 */
async function Run(queue: PrefetchTarget[]): Promise<void> {
let cursor = 0
const worker = async (): Promise<void> => {
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)
* 典型调用点:侧边栏菜单项 mouseenter / focus。分组菜单可对每个子项调用。
* 预热失败会从「已预热」集合移除,允许下次悬停重试。
*
* @param routes 应用静态路由表(router 的 staticRoutes)
* @param path 菜单项路径(如 /company/company)
*/
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)
}
export function WarmRouteByPath(routes: RouteRecordRaw[], path: string | undefined): void {
if (!path || IsConstrainedNetwork()) return
const normalized = path.split('?')[0].replace(/\/+$/, '')
if (!normalized || Warmed.has(normalized)) return
const load = EnsureMap(routes).get(normalized)
if (!load) return
Warmed.add(normalized)
load().catch(() => {
// 失败(网络抖动等)允许下次悬停重试;用户真正打开时路由层会正常报错
Warmed.delete(normalized)
})
}