perf(layout): 登录后预热用户可访问页面的分包,配合 72h 强缓存

- 新增 utils/prefetchRoutes:遍历静态路由表收集懒加载 loader

- menuGuarded 路由仅当其路径在用户菜单树中才预热;其余登录即可访问的直接预热

- requestIdleCallback 启动 + 限并发 3 + 间隔 150ms,弱网/省流量模式跳过

- 按可见路径集合签名去重,菜单变化(切换角色)后重新预热一次
This commit is contained in:
TermiNexus committed 2026-09-29 11:32:43 +08:00
1 parent 1b907b3bb6
commit 96e0776407
2 files changed
+133

No files matched your search

+10
View File
@@ -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)
+123
View File
@@ -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<unknown>
/** 是否受菜单权限约束 */
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<unknown>,
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<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)
*/
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)
}
}