refactor(frontend): 统一组件名匹配KeepAlive缓存逻辑,重构路由缓存机制

1.  调整所有页面组件的name为路由/菜单对应的route_name,适配KeepAlive匹配规则
2.  重构路由嵌套缓存逻辑,修复多标签页下重复挂载页面导致接口并发请求的问题
3.  移除冗余的onMounted自动请求代码,统一数据加载逻辑
4.  优化缓存键生成和KeepAlive包含排除规则,确保缓存精准生效
This commit is contained in:
zhangtao
2026-09-14 00:30:06 +08:00
parent f12a40090a
commit 2d393247cc
17 changed files with 105 additions and 81 deletions
@@ -17,25 +17,19 @@
<RouterView v-if="isRefresh" v-slot="{ Component, route: router }" :style="contentStyle">
<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">
<!-- 是否缓存以后端菜单 keep_alive → meta.keepAlive 为准;此处 !== false 即包 KeepAlive(与 MenuProcessor 一致) -->
<KeepAlive
v-if="wrapPageWithKeepAlive"
:max="10"
:include="keepAliveInclude"
:exclude="keepAliveExclude"
>
<!--
外层缓存「一级出口组件」:目录菜单为壳组件 NestedRouterParent,一级叶子为页面组件。
此处 KeepAlive 必须常驻,不能按当前路由 meta.keepAlive 做 v-if 开关:
卸载 KeepAlive 会连同已缓存实例一起销毁,导致每次切回都重新挂载(接口重复请求)。
叶子的取舍由 include/exclude 表达(include 只在多标签模式下生效)。
-->
<KeepAlive :max="10" :include="keepAliveInclude" :exclude="keepAliveExclude">
<component
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="routeLeafCacheKey(router)"
:key="routeShellCacheKey(router)"
/>
</KeepAlive>
<component
v-else
class="fa-page-view min-h-0 min-w-0 w-full flex-1"
:is="Component"
:key="routeLeafCacheKey(router)"
/>
</div>
</Transition>
</RouterView>
@@ -67,25 +61,42 @@ import { useSettingsStore, useWorktabStore } from "@stores";
defineOptions({ name: "FaPageContent" });
/**
* KeepAlive / RouterView 子节点缓存键。
* - `meta.remountOnFullPath === true`:整 URL 参与键(依赖 query 初值或须随 query 重建的页)。
* - 有 `name`:`name` + `params`,同一路由仅 query 变化不换实例。
* - 无 `name`:`path`(勿用 fullPath,否则仅 query 变化也会反复挂载)。
* KeepAlive 缓存键(外层出口)。
*
* 本处 `RouterView` 处于 depth=1,其 `Component` 是 **一级路由组件**:
* 目录菜单为壳组件 `NestedRouterParent`,一级叶子(如 /home)为页面组件。
* 因此缓存单位必须是「壳组件」,不能用叶子路由身份(name/params)当键:
* 否则同一壳会被缓存成 N 份实例,旧实例只被 move 到游离容器而不销毁,
* 其内部 RouterView 仍随全局 route 重渲染 —— 切页时目标页面会在所有存活壳里
* 被重复挂载,同一接口被并发触发 N 次(N = 存活壳数,随缓存淘汰动态变化)。
* 用壳组件名作键后,同一壳全局只有一个实例,页面的重复挂载随之消失,
* 叶子级缓存由壳内的 KeepAlive 负责。
*/
function routeLeafCacheKey(r: RouteLocationNormalizedLoaded): string {
if (r.meta.remountOnFullPath === true) {
return r.fullPath;
}
if (r.name != null) {
return `${String(r.name)}:${JSON.stringify(r.params ?? {})}`;
}
return r.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;
}
const route = useRoute();
const router = useRouter();
/** 动态菜单 meta.keepAlive(后端 keep_alive);仅显式 false 时不包 KeepAlive */
const wrapPageWithKeepAlive = computed(() => route.meta.keepAlive !== false);
/**
* 解析 path 在外层 RouterView 出口(depth=1)实际渲染的组件。
* KeepAlive 的 include / exclude 按「组件 name」匹配,所以必须回解组件,不能用路由 name;
* depth=1 命中目录时为壳组件 NestedRouterParent(isShell=true),命中一级叶子时为页面组件。
*/
function resolveDepth1(path: string): { name: string; isShell: boolean } {
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 };
} catch {
return { name: "", isShell: false };
}
}
const isNarrowViewport = useMediaQuery("(max-width: 800px)");
const backtopScrollTarget = computed(() => (isNarrowViewport.value ? "" : "#app-content"));
@@ -94,32 +105,23 @@ const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
/** 嵌套路由壳组件名:内层叶子缓存依赖壳实例存活,include 需一并纳入(对应 routes.ts 的 NestedRouterParent) */
const NESTED_PARENT_COMPONENT_NAME = "NestedRouterParent";
/** 判断路径是否命中嵌套路由(匹配链上存在 NestedRouterParent 壳) */
function isNestedRoutePath(path: string): boolean {
try {
return router
.resolve({ path })
.matched.some((m) => m.components?.default?.name === NESTED_PARENT_COMPONENT_NAME);
} catch {
return false;
}
}
/**
* 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。
* 嵌套路由额外把壳组件 NestedRouterParent 纳入 include,否则内层叶子缓存会随壳销毁而失效。
* 多标签开启时:只把工作栏已打开标签对应的一级组件名放进 include(组件 name,非路由 name)。
* - 目录标签:一级组件是壳 NestedRouterParent,必须恒缓存 —— 壳实例一旦销毁,其内部叶子缓存也随之丢失。
* - 一级叶子标签:按标签自身 keepAlive 取舍。
* 关闭多标签时不传 include,避免白名单过窄误伤缓存。
*/
const keepAliveInclude = computed(() => {
if (!showWorkTab.value) return undefined;
const names = new Set<string>();
for (const t of opened.value) {
if (t.name && t.keepAlive !== false) names.add(String(t.name));
if (t.keepAlive !== false && isNestedRoutePath(t.path)) {
names.add(NESTED_PARENT_COMPONENT_NAME);
}
const { name, isShell } = resolveDepth1(t.path);
if (name && (isShell || t.keepAlive !== false)) names.add(name);
}
// 兜底当前路由:避免 opened 尚未写入时当前页面命中不到白名单而不被缓存
const current = resolveDepth1(route.path);
if (current.name && (current.isShell || route.meta.keepAlive !== false)) {
names.add(current.name);
}
return names.size ? Array.from(names) : undefined;
});
+25 -11
View File
@@ -10,7 +10,7 @@
*/
import type { AppRouteRecordRaw } from "@utils";
import type { AppRouteRecord, RouteMeta } from "@/types/router";
import { defineComponent, h, KeepAlive, onMounted, ref, type VNode } from "vue";
import { computed, defineComponent, h, KeepAlive, onMounted, ref, type VNode } from "vue";
import { RouterView, useRoute } from "vue-router";
import { $t } from "@/locales";
import { useWorktabStore } from "@stores";
@@ -146,20 +146,34 @@ export const NestedRouterParent = defineComponent({
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;
// 内层 KeepAlive(v-slot 模式):按叶子组件名缓存,与工作栏 tab.name 一致,
// 关闭标签时由 keepAliveExclude 精确清除;meta.keepAlive === false 时不缓存叶子
if (route.meta.keepAlive !== false) {
return h(
KeepAlive,
{ exclude: worktabStore.keepAliveExclude },
{ default: () => h(Component) }
);
}
return h(Component);
return h(KeepAlive, { exclude: innerExclude.value }, { default: () => h(Component) });
},
});
},
@@ -36,7 +36,8 @@
import { ref, onMounted } from "vue";
import VersionAPI from "@/api/module_system/version";
defineOptions({ name: "SystemChangeLog" });
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "FastlinkChangeLog" });
interface UpgradeLog {
version: string;
@@ -345,7 +345,8 @@ import { ElMessage } from "element-plus";
import { useI18n } from "vue-i18n";
import { redirectToLogin, dataURLToFile } from "@utils";
defineOptions({ name: "UserProfile" });
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "FastlinkProfile" });
const { t } = useI18n();
const userStore = useUserStore();
@@ -125,7 +125,8 @@ import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import { Check } from "@element-plus/icons-vue";
defineOptions({ name: "DashboardPricing" });
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "FastlinkPricing" });
// ─── Mock Types ───
@@ -1065,7 +1065,8 @@ import { MANUAL_MODULES_AFTER_SYSTEM, MANUAL_SYSTEM_TAIL_PAGES } from "./manualS
import { manualModuleMatchesQuery, manualPageMatchesQuery } from "./manualTocSearch";
import DOMPurify from "dompurify";
defineOptions({ name: "DashboardTutorial" });
// 与路由 name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "FastlinkTutorial" });
const { t } = useI18n();
@@ -141,7 +141,8 @@
<script setup lang="ts">
defineOptions({
name: "ChatSession",
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
name: "Memory",
inheritAttrs: false,
});
+2 -1
View File
@@ -281,7 +281,8 @@ import CacheAPI, {
import { echarts } from "@/plugins/echarts";
import { useWindowSize } from "@vueuse/core";
defineOptions({ name: "CacheMonitor" });
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "MonitorCache" });
const activeTab = ref("0");
@@ -52,7 +52,8 @@
<script setup lang="ts">
defineOptions({
name: "OnlineUser",
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
name: "MonitorOnline",
inheritAttrs: false,
});
@@ -263,11 +264,6 @@ async function handleClearAll() {
clearAllLoading.value = false;
}
}
// 列表数据在页面挂载时加载一次,不自动轮询
onMounted(() => {
refreshData();
});
</script>
<style lang="scss" scoped></style>
@@ -188,7 +188,8 @@
<script lang="ts" setup>
import ServerAPI, { type ServerInfo } from "@/api/module_monitor/server";
defineOptions({ name: "ServerMonitor" });
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "MonitorServer" });
const server = ref<ServerInfo>({
cpu: { cpu_num: 0, used: 0, sys: 0, free: 0 },
@@ -561,10 +561,6 @@ const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
},
});
onMounted(() => {
if (props.dictTypeId) getData();
});
async function handleSearchBarSearch(params: DictDataSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams({
@@ -255,7 +255,9 @@
<script setup lang="ts">
defineOptions({
name: "SysMenu",
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
// eslint-disable-next-line vue/no-reserved-component-names -- 须与后端菜单 route_name 对齐;本组件仅由路由加载,不作为模板标签使用
name: "Menu",
inheritAttrs: false,
});
@@ -237,7 +237,8 @@ import { useConfigStore } from "@stores";
import ParamTabPane from "./components/ParamTabPane.vue";
import ParamFieldCard from "./components/ParamFieldCard.vue";
defineOptions({ name: "ParamsSettings" });
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "Params" });
const configStore = useConfigStore();
const activeTab = ref("brand");
@@ -407,7 +407,8 @@ import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
import FaCardGrid from "@/components/cards/fa-card-grid/index.vue";
defineOptions({
name: "TicketCard",
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
name: "ModuleTicket",
inheritAttrs: false,
});
@@ -150,7 +150,8 @@ import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
defineOptions({
name: "Version",
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
name: "ModuleVersion",
inheritAttrs: false,
});
@@ -368,6 +368,9 @@ import type { FormItemRule, FormRules } from "element-plus";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
defineOptions({ name: "WorkflowTransfer", inheritAttrs: false });
// ── 存储源 ────────────────────────────────────────────────────────────
const sources = ref<SourceTable[]>([]);
const sourceMap = computed<Record<number, SourceTable>>(() => {
@@ -184,7 +184,8 @@ import FaFileBrowserDialog from "./components/FaFileBrowserDialog.vue";
import { protocolColor, protocolLabel } from "./components/protocol.ts";
defineOptions({
name: "Workflow",
// 与菜单 route_name 一致:KeepAlive 的 include/exclude 按组件 name 匹配
name: "WorkflowFlow",
inheritAttrs: false,
});