mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-27 14:52:56 +00:00
refactor(router): simplify route cache logic, remove nested parent component
重写多级目录路由处理逻辑,移除冗余的NestedRouterParent壳组件,改用RouterView深度跳级直接渲染叶子页面,简化KeepAlive缓存实现,修复多缓存实例导致的重复请求问题。
This commit is contained in:
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 字段)
|
||||
|
||||
Reference in New Issue
Block a user