refactor(router): simplify route cache logic, remove nested parent component

重写多级目录路由处理逻辑,移除冗余的NestedRouterParent壳组件,改用RouterView深度跳级直接渲染叶子页面,简化KeepAlive缓存实现,修复多缓存实例导致的重复请求问题。
This commit is contained in:
zhangtao
2026-09-14 01:06:20 +08:00
parent 2d393247cc
commit 24589ad9cd
6 changed files with 64 additions and 118 deletions
@@ -18,16 +18,19 @@
<Transition :name="actualTransition" mode="out-in">
<div v-if="Component" class="route-view-shell flex min-h-0 min-w-0 w-full flex-1 flex-col">
<!--
外层缓存「一级出口组件」:目录菜单为壳组件 NestedRouterParent,一级叶子为页面组件。
单层缓存「叶子页面组件」:目录路由不挂组件,RouterView 的深度跳级让本出口
直接渲染 matched 中第一个带 components 的后代(即真实页面)。
此处 KeepAlive 必须常驻,不能按当前路由 meta.keepAlive 做 v-if 开关:
卸载 KeepAlive 会连同已缓存实例一起销毁,导致每次切回都重新挂载(接口重复请求)。
叶子的取舍由 include/exclude 表达(include 只在多标签模式下生效)。
不要加 `:max`:缓存集合已由 include/exclude 精确表达,再叠一层 LRU 会在标签
仍打开时悄悄挤掉最早的页面,切回时无谓重挂载,请求次数变得不可预测。
-->
<KeepAlive :max="10" :include="keepAliveInclude" :exclude="keepAliveExclude">
<KeepAlive :include="keepAliveInclude" :exclude="keepAliveExclude">
<component
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="routeShellCacheKey(router)"
:key="routeViewCacheKey(router)"
/>
</KeepAlive>
</div>
@@ -50,8 +53,10 @@
/**
* 布局滚动容器 + 业务路由出口;与 settings.refresh 联动可整体重建 RouterView。
*
* 缓存为单层:目录路由不挂组件,RouterView 深度跳级后本出口直接渲染叶子页面,
* 因此这里的 KeepAlive 就是全局唯一的页面级缓存,不存在壳实例放大问题。
* 缓存开关数据源:后端菜单 `keep_alive` → MenuProcessor 写入 `meta.keepAlive`;工作栏 tab 随路由写入同一 meta。
* include / wrapPageWithKeepAlive 均用 `!== false`,与静态路由里显式 `keepAlive: false`、后端布尔字段对齐。
* include / exclude 均用 `!== false`,与静态路由里显式 `keepAlive: false`、后端布尔字段对齐。
*/
import type { CSSProperties } from "vue";
import { useMediaQuery } from "@vueuse/core";
@@ -61,40 +66,38 @@ import { useSettingsStore, useWorktabStore } from "@stores";
defineOptions({ name: "FaPageContent" });
/**
* KeepAlive 缓存键(外层出口)。
* KeepAlive 缓存键(本出口渲染的是叶子页面组件)。
*
* 本处 `RouterView` 处于 depth=1,其 `Component` 是 **一级路由组件**:
* 目录菜单为壳组件 `NestedRouterParent`,一级叶子(如 /home)为页面组件。
* 因此缓存单位必须是「壳组件」,不能用叶子路由身份(name/params)当键:
* 否则同一壳会被缓存成 N 份实例,旧实例只被 move 到游离容器而不销毁,
* 其内部 RouterView 仍随全局 route 重渲染 —— 切页时目标页面会在所有存活壳里
* 被重复挂载,同一接口被并发触发 N 次(N = 存活壳数,随缓存淘汰动态变化)。
* 用壳组件名作键后,同一壳全局只有一个实例,页面的重复挂载随之消失,
* 叶子级缓存由壳内的 KeepAlive 负责。
* 用叶子路由 path 作键:同组件的不同 path(含动态参数)各自成一份实例,
* query 变化不新建实例。同一 path 全局只有一个实例,切走再切回命中缓存,
* 页面不会重新挂载、接口不会重复请求。
*/
function routeShellCacheKey(r: RouteLocationNormalizedLoaded): string {
const shell = r.matched[1]?.components?.default as { name?: string } | undefined;
if (shell?.name) return shell.name;
return r.matched[1]?.path ?? r.path;
function routeViewCacheKey(r: RouteLocationNormalizedLoaded): string {
return r.path;
}
const route = useRoute();
const router = useRouter();
/**
* 解析 path 在外层 RouterView 出口(depth=1)实际渲染的组件。
* KeepAlive 的 include / exclude 按「组件 name」匹配,所以必须回解组件,不能用路由 name;
* depth=1 命中目录时为壳组件 NestedRouterParent(isShell=true),命中一级叶子时为页面组件。
* 解析 path 在本出口(depth=1)实际渲染的组件名。
*
* 目录路由不挂组件,RouterView 的深度跳级会跳过它们,命中 matched 中
* 第一个带 `components` 的后代 —— 即真实页面组件(如 /system/user → matched[2])。
* KeepAlive 的 include / exclude 按「组件 name」匹配,所以必须回解组件,不能用路由 name。
*/
function resolveDepth1(path: string): { name: string; isShell: boolean } {
function resolveOutletComponentName(path: string): string {
try {
const matched = router.resolve({ path }).matched;
const comp = matched[1]?.components?.default as
| { name?: string; __name?: string }
| undefined;
return { name: comp?.name ?? comp?.__name ?? "", isShell: matched.length > 2 };
for (let i = 1; i < matched.length; i++) {
const comp = matched[i]?.components?.default as
| { name?: string; __name?: string }
| undefined;
if (comp) return comp.name ?? comp.__name ?? "";
}
return "";
} catch {
return { name: "", isShell: false };
return "";
}
}
@@ -103,25 +106,37 @@ const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-
const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"));
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
const { opened, keepAliveExclude: worktabKeepAliveExclude } = storeToRefs(useWorktabStore());
/**
* 多标签开启时:只把工作栏已打开标签对应的一级组件名放进 include(组件 name,非路由 name)。
* - 目录标签:一级组件是壳 NestedRouterParent,必须恒缓存 —— 壳实例一旦销毁,其内部叶子缓存也随之丢失。
* - 一级叶子标签:按标签自身 keepAlive 取舍。
* 多标签开启时:只把工作栏已打开标签对应的页面组件名放进 include(组件 name,非路由 name)。
* 关闭多标签时不传 include,避免白名单过窄误伤缓存。
*/
const keepAliveInclude = computed(() => {
if (!showWorkTab.value) return undefined;
const names = new Set<string>();
for (const t of opened.value) {
const { name, isShell } = resolveDepth1(t.path);
if (name && (isShell || t.keepAlive !== false)) names.add(name);
if (t.keepAlive === false) continue;
const name = resolveOutletComponentName(t.path);
if (name) names.add(name);
}
// 兜底当前路由:避免 opened 尚未写入时当前页面命中不到白名单而不被缓存
const current = resolveDepth1(route.path);
if (current.name && (current.isShell || route.meta.keepAlive !== false)) {
names.add(current.name);
if (route.meta.keepAlive !== false) {
const current = resolveOutletComponentName(route.path);
if (current) names.add(current);
}
return names.size ? Array.from(names) : undefined;
});
/**
* 关闭标签时 store 会把组件名压入 exclude(按组件名累计);
* 另外关闭多标签时 include 为空,`meta.keepAlive === false` 的页面需在此兜底排除,避免被缓存。
*/
const keepAliveExclude = computed(() => {
const names = new Set(worktabKeepAliveExclude.value ?? []);
if (route.meta.keepAlive === false) {
const name = resolveOutletComponentName(route.path);
if (name) names.add(name);
}
return names.size ? Array.from(names) : undefined;
});
+2 -2
View File
@@ -10,7 +10,6 @@ import {
DASHBOARD_PARENT_META,
dashboardLayoutChildren,
ROUTE_COMPONENT_LAYOUT,
ROUTE_COMPONENT_NESTED_PARENT,
} from "./routes";
import { MenuTypeEnum } from "@/enums/system/menu.enum";
@@ -56,7 +55,8 @@ function mapMenuNode(item: MenuTable, depth = 0, parentAbsolutePath = ""): AppRo
let component: string | undefined;
if (isDirectory || (hasKids && !(item.component_path ?? "").trim())) {
component = depth === 0 ? ROUTE_COMPONENT_LAYOUT : ROUTE_COMPONENT_NESTED_PARENT;
// 目录:父级不挂组件,由 RouterView 的深度跳级直接渲染首个含组件的后代(单层 KeepAlive)
component = undefined;
} else if ((item.component_path ?? "").trim()) {
component = toComponentImportPath(item.component_path!);
}
+8 -27
View File
@@ -14,10 +14,8 @@ import { h } from "vue";
import {
IframeRouteManager,
IframeView,
NestedRouterParent,
ROOT_LAYOUT_ROUTE_NAME,
ROUTE_COMPONENT_LAYOUT,
ROUTE_COMPONENT_NESTED_PARENT,
} from "./routes";
// ──────── ComponentLoader ────────
@@ -63,16 +61,6 @@ export class ComponentLoader {
return IframeView;
}
/** NestedRouterParent 占位组件 */
loadNestedParent(): any {
return NestedRouterParent;
}
/** 空组件(空白 div) */
createEmptyComponent(): any {
return { render: () => null };
}
/** 错误提示组件(后端配置了不存在的路径时显示) */
private createErrorComponent(path: string): any {
return {
@@ -140,7 +128,7 @@ function warnInvalidRouteConfig(routes: AppRouteRecord[], parentPath = ""): void
*
* 1. 一级叶子菜单(如 /user)→ 包一层 Layout,其下挂实际组件
* 2. iframe 菜单 → 一级包 Layout,子级直接 IframeView
* 3. 多级目录 → 父级用 NestedRouterParent 占位,子级挂实际组件
* 3. 多级目录 → 父级不挂组件(靠 RouterView 深度跳级直达叶子),子级挂实际组件
* 4. 常规子菜单 → 直接绑定组件
*/
export class RouteTransformer {
@@ -244,15 +232,15 @@ export class RouteTransformer {
/**
* 常规路由处理
*
* - 无 children 或 children 都是叶子 → 直接绑定组件
* - 有 children(多级目录)→ 父级用 NestedRouterParent 占位,递归处理子级
* - 无 children → 直接绑定组件
* - 有 children(目录)→ 父级不挂组件,由 RouterView 的深度跳级直接渲染叶子
*/
private handleNormalRoute(route: AppRouteRecord, depth: number): Record<string, any> | null {
if (!route.children?.length) {
return this.buildLeafRoute(route, depth);
}
// 多级目录:父级占位 + 递归子级
// 目录:父级 component 置空 + 递归子级
const children = route.children
.map((child) => this.transform(child, depth + 1))
.filter(Boolean);
@@ -262,10 +250,9 @@ export class RouteTransformer {
name: route.name,
redirect: children.length > 0 ? { name: children[0]?.name } : undefined,
component:
route.component &&
![ROUTE_COMPONENT_NESTED_PARENT, ROUTE_COMPONENT_LAYOUT].includes(String(route.component))
route.component && route.component !== ROUTE_COMPONENT_LAYOUT
? this.loader.load(String(route.component))
: NestedRouterParent,
: undefined,
meta: route.meta,
children,
};
@@ -273,20 +260,14 @@ export class RouteTransformer {
/** 叶子路由(直接绑定组件) */
private buildLeafRoute(route: AppRouteRecord, depth: number): Record<string, any> | null {
if (
(!route.component ||
route.component === ROUTE_COMPONENT_NESTED_PARENT ||
route.component === ROUTE_COMPONENT_LAYOUT) &&
route.meta?.link
) {
if ((!route.component || route.component === ROUTE_COMPONENT_LAYOUT) && route.meta?.link) {
return null;
}
return {
path: this.routerPath(route.path, depth),
name: route.name,
component:
route.component &&
![ROUTE_COMPONENT_NESTED_PARENT, ROUTE_COMPONENT_LAYOUT].includes(String(route.component))
route.component && route.component !== ROUTE_COMPONENT_LAYOUT
? this.loader.load(String(route.component))
: undefined,
meta: route.meta,
+2 -47
View File
@@ -10,10 +10,9 @@
*/
import type { AppRouteRecordRaw } from "@utils";
import type { AppRouteRecord, RouteMeta } from "@/types/router";
import { computed, defineComponent, h, KeepAlive, onMounted, ref, type VNode } from "vue";
import { RouterView, useRoute } from "vue-router";
import { defineComponent, h, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { $t } from "@/locales";
import { useWorktabStore } from "@stores";
import LayoutComponent from "@/layouts/index.vue";
import DashboardWorkplace from "@views/dashboard/workplace/index.vue";
import DashboardAnalysis from "@views/dashboard/analysis/index.vue";
@@ -140,51 +139,9 @@ export const ROOT_LAYOUT_ROUTE_NAME = "RootLayout" as const;
/** 首页子路由 name(面包屑组件会用) */
export const HOME_ROUTE_NAME = "Home" as const;
/** 纯 RouterView 占位组件 —— 多级目录只需要嵌一层,不需要实际页面 */
export const NestedRouterParent = defineComponent({
name: "NestedRouterParent",
setup() {
const route = useRoute();
const worktabStore = useWorktabStore();
/** 当前叶子组件名:KeepAlive 的 include/exclude 按「组件 name」匹配,不能用路由 name */
const leafComponentName = computed(() => {
const comp = route.matched[route.matched.length - 1]?.components?.default as
| { name?: string; __name?: string }
| undefined;
return comp?.name ?? comp?.__name ?? "";
});
/**
* 关闭标签由工作栏的 keepAliveExclude 负责清理;
* meta.keepAlive === false 的叶子额外追加自身组件名使其不进缓存。
* 这里始终渲染 KeepAlive:若按当前路由 keepAlive 做 v-if 开关,卸载 KeepAlive
* 会把其余叶子的缓存一并销毁,导致切回时重新挂载(接口重复请求)。
*/
const innerExclude = computed(() => {
const base = worktabStore.keepAliveExclude ?? [];
if (route.meta.keepAlive === false && leafComponentName.value) {
return [...base, leafComponentName.value];
}
return base;
});
return () =>
h(RouterView, null, {
default: ({ Component }: { Component?: VNode }) => {
if (!Component) return null;
return h(KeepAlive, { exclude: innerExclude.value }, { default: () => h(Component) });
},
});
},
});
/** 后端菜单中 component: "/index/index" = 使用 Layout 框架 */
export const ROUTE_COMPONENT_LAYOUT = "/index/index";
/** 多级目录父级占位 component */
export const ROUTE_COMPONENT_NESTED_PARENT = "/nested/router-view-parent";
/** 登录页的备用 path(守卫判断用) */
export const ROUTE_PATH_LOGIN_ALT = "/auth/login";
@@ -292,7 +249,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
path: "dashboard",
name: "Dashboard",
redirect: "/dashboard/workplace",
component: NestedRouterParent,
meta: DASHBOARD_PARENT_META,
children: dashboardLayoutChildren,
},
@@ -300,7 +256,6 @@ export const staticRoutes: AppRouteRecordRaw[] = [
{
path: "fastlink",
name: "Fastlink",
component: NestedRouterParent,
meta: { hidden: true },
children: [
{
-6
View File
@@ -21,7 +21,6 @@ export interface RouteMeta extends Record<string | number | symbol, unknown> {
authMark?: string;
parentPath?: string;
shellRoute?: boolean;
remountOnFullPath?: boolean;
scope?: "web" | "app";
}
@@ -76,11 +75,6 @@ declare module "vue-router" {
* @default false
*/
keepAlive?: boolean;
/**
* 为 true 时 KeepAlive 子组件 `:key` 使用 `fullPath`(query/hash 变化会整页重挂载)。
* 默认用 `name + params`,减轻 query 微调导致的重复 onMounted / useTable immediate。
*/
remountOnFullPath?: boolean;
/**
* 静态壳层路由(路由已在 router 注册,菜单项仅用于跳转,无 component 字段)