perf(layout): 分包预热改为菜单悬停懒预取
- prefetchRoutes 由「登录后全量预热」改为 WarmRouteByPath 按需预热(幂等、弱网跳过) - 侧边栏菜单项 mouseenter/focusin 触发预热;分组菜单预热其全部子页面 - 首屏零额外请求,仅预热用户已表达意图的页面
This commit is contained in:
1 parent
96e0776407
commit
6a488fe452
2 files changed
+69
-95
No files matched your search
@@ -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
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user