perf(layout): 登录后预热用户可访问页面的分包,配合 72h 强缓存
- 新增 utils/prefetchRoutes:遍历静态路由表收集懒加载 loader - menuGuarded 路由仅当其路径在用户菜单树中才预热;其余登录即可访问的直接预热 - requestIdleCallback 启动 + 限并发 3 + 间隔 150ms,弱网/省流量模式跳过 - 按可见路径集合签名去重,菜单变化(切换角色)后重新预热一次
This commit is contained in:
1 parent
1b907b3bb6
commit
96e0776407
2 files changed
+133
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user