场景:以一体化为例:目前页面涉及页签和大量菜单路由,用户想要实现页面缓存,即列表页、详情页甚至是编辑弹框页都要实现数据缓存。
方案:使用router-view的keep-alive实现 。
一、实现思路
1.需求梳理
需要缓存模块:
- 打开页签的页面
- 新增、编辑的弹框和抽屉等表单项
- 列表页点击同一条数据的编辑页
无需缓存模块:
- 首页
- 登录页
- 已关闭的页签页
- 列表页点击不同数据的编辑页
- 特殊声明无需缓存页
2.缓存的两种方式
2.1使用name方式
注意点:name是vue组件实例的name
include:需缓存的vue组件
exclude:不做缓存的vue组件
<router-view v-slot="{ Component }">
<keep-alive :include="tabKeepAliveNameList" :exclude="excludeNameList">
<component :is="Component"></component>
</keep-alive>
</router-view>
2.2使用meta的keepAlive方式
通过v-if实现对应路由keepAlive为true的路由缓存
<router-view v-slot="{ Component }">
<keep-alive>
<component v-if="$route.meta.keepAlive" :key="$route.path" :is="Component" />
</keep-alive>
<component v-if="!$route.meta.keepAlive" :key="$route.path" :is="Component" />
</router-view>
2.3最终选择
采用1.1的vue组件实例的name方式
优点:
- 精确控制:直接指定要缓存的组件名,颗粒度细,适合明确知道需要缓存的组件。
- 静态匹配:匹配逻辑简单,性能较高,基于组件自身的name属性。
- 组件独立性:不依赖路由配置,组件自身决定是否可被缓存。
- 路由跳转:结合动态路由生成的name,方便页面使用name跳转。
2.4缓存实例的生命周期
请注意:
onActivated
在组件挂载时也会调用,并且onDeactivated
在组件卸载时也会调用。- 这两个钩子不仅适用于
<KeepAlive>
缓存的根组件,也适用于缓存树中的后代组件。
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 调用时机为首次挂载
// 以及每次从缓存中被重新插入时
})
onDeactivated(() => {
// 在从 DOM 上移除、进入缓存
// 以及组件卸载时调用
})
</script>
3.pinia新增缓存维护字段
在pinia中新增keepAliveNameList: [],存入需要缓存的组件实例的name
import { defineStore } from "pinia"
import { loginOut } from "@/api/common.js"
import router from "@/router"
export default defineStore("storeUser", {
persist: {
storage: sessionStorage,
paths: ["keepAliveNameList"]
},
state: () => {
return {
keepAliveNameList: [],
}
},
getters: {
getUserInfo: state => {
return state.userInfo
}
},
actions: {
async loginOut() {
await loginOut()
this.clearUserInfo()
router.push({ path: "/login" })
},
clearUserInfo() {
sessionStorage.clear()
this.keepAliveNameList = [] // 缓存name数据
}
}
})
4.导航守卫
4.1全局前置守卫router.beforeEach
在跳转之前判断地址栏参数是否一致,不同则需要将to页的缓存去除,正常获取
例如:两次点击列表里的数据编辑按钮;点击同一条是需要缓存该条表单数据,点击不同条时候需要去除缓存重新获取
router.beforeEach(async (to, _from, next) => {
// 对于地址栏变化的需要清空缓存
if (userStore.tabStore[userStore.tabStore.findIndex(it => it.path === to.path)] && JSON.stringify(userStore.tabStore[userStore.tabStore.findIndex(it => it.path === to.path)].query) !== JSON.stringify(to.query)) {
userStore.$patch(state => {
state.refreshUrl = to.path
})
let oldName = userStore.keepAliveNameList
userStore.$patch(state => {
state.keepAliveNameList = oldName.filter(it => it !== to.name)
})
}
.
.
.
next()
})
4.2全局解析守卫router.beforeResolve
在离开当前页签时候,将该页签进行数据缓存。结合上方的进入前判断,是否需要清除缓存,达成页签页面正确的区分加入缓存和清除缓存
注意:此时可通过路由的方式获取到路由name;但需要保证路由name同vue组件的name一致(目前通过脚本实现一致)
router.beforeResolve((to, from, next) => {
const { userStore } = useStore()
let keepAliveName = from.matched[from.matched.length - 1]?.name
let tabStoreList = (userStore.tabStore || []).map(ele => ele.name) // 页签集合
if (!userStore.keepAliveNameList.includes(keepAliveName) && keepAliveName && tabStoreList.includes(keepAliveName)) {
userStore.$patch(state => {
state.keepAliveNameList.unshift(keepAliveName)
})
}
next()
})
4.3清除缓存
- 在关闭页签时候,需要将缓存keepAliveNameList当前页的name移除
- 相同菜单,但是地址栏参数变化时候,也需要清除缓存(点击查看、编辑列表页不同数据)
// 关闭页签同时去除缓存
const deleteKeepAliveName = () => {
userStore.$patch(state => {
state.keepAliveNameList = tabStoreList.value.map(it => it.name)
})
}
细节处理
1.vue组件设置name
问题:现有vue组件存在部分未设置name情况,需要统一设置name
方案:通过脚本,统一遍历src/view下的所有组件,有路由name的设置路由name,无路由的组件使用当前路径命名
优点:保证路由页面的name和组件实例name一致
1.1新增auto-set-component-name.mjs脚本
import constantRoutes from "./src/router/constant_routes.js"
import { generateRoutes } from "./src/router/static_routes.js"
// 动态添加路由添加
const dynamicRoutes = constantRoutes.concat(generateRoutes() || [])
// 递归找对象
const findItem = (pathUrl, array) => {
for (const item of array) {
let componentPath
// 使用示例
componentPath = getComponentPath(item.component) ? getComponentPath(item.component).replace(/^@|\.vue$/g, '') : undefined
// 检查当前项的id是否匹配
if (componentPath === pathUrl) return item;
// 如果有子节点则递归查找
if (item.children?.length) {
const result = findItem(pathUrl, item.children);
if (result) return result; // 找到则立即返回
}
}
return undefined; // 未找到返回undefined
}
// 提取组件路径的正则表达式
const IMPORT_PATH_REGEX = /import\(["'](.*?)["']\)/;
// 获取路径字符串
const getComponentPath = (component) => {
if (!component?.toString) return null;
const funcString = component.toString();
const match = funcString.match(IMPORT_PATH_REGEX);
return match ? match[1] : null;
};
import fs from "fs"; // 文件系统模块,用于读写文件
import path from "path"; // 路径处理模块
import { fileURLToPath } from "url"; // 用于转换URL路径
const __filename = fileURLToPath(import.meta.url); // 当前文件绝对路径
const __dirname = path.dirname(__filename); // 当前文件所在目录
// 🔧 配置区 ============================================
const targetDir = path.join(__dirname, "src/views"); // 目标目录:当前目录下的src/views
const PATH_DEPTH = Infinity; // 路径深度设置 自由修改数字:2→最后两级,3→最后三级,Infinity→全部路径
// =====================================================
const toPascalCase = (str) => {
return str
// .replace(/[-_](.)/g, (_, c) => c.toUpperCase()) // 转换连字符/下划线后的字母为大写
// .replace(/(^\w)/, (m) => m.toUpperCase()) // 首字母大写
.replace(/\.vue$/, ""); // 移除.vue后缀
};
const processDirectory = (dir) => {
const files = fs.readdirSync(dir, { withFileTypes: true }); // 读取目录内容
console.log('%c【' + 'dir' + '】打印', 'color:#fff;background:#0f0', dir)
files.forEach((file) => {
const fullPath = path.join(dir, file.name); // 获取完整路径
file.isDirectory() ? processDirectory(fullPath) : processVueFile(fullPath); // 递归处理目录,直接处理文件
});
};
const processVueFile = (filePath) => {
if (path.extname(filePath) !== ".vue") return; // 过滤非Vue文件
// 生成组件名逻辑
const relativePath = path.relative(targetDir, filePath);
console.log('%c【' + 'targetDir' + '】打印', 'color:#fff;background:#0f0', targetDir)
console.log('%c【' + 'filePath' + '】打印', 'color:#fff;background:#0f0', filePath)
console.log('%c【' + 'relativePath' + '】打印', 'color:#fff;background:#0f0', relativePath)
const pathSegments = relativePath
.split(path.sep) // 按路径分隔符拆分
.slice(-PATH_DEPTH) // 根据配置截取路径段
.map((segment) => toPascalCase(segment)); // 转换为PascalCase
const vuePath = '/views/' + pathSegments.join("/"); // 拼接成最终组件名
let componentName = findItem(vuePath, dynamicRoutes)?.name ? findItem(vuePath, dynamicRoutes)?.name : vuePath
console.log(filePath, componentName);
let content = fs.readFileSync(filePath, "utf8"); // 文件内容处理
const oldContent = content; // 保存原始内容用于后续对比
const scriptSetupRegex = /<script\s+((?:.(?!\/script>))*?\bsetup\b[^>]*)>/gim; // 灵活匹配找到script
let hasDefineOptions = false; // 标识是否找到defineOptions
// 处理已存在的 defineOptions
const defineOptionsRegex = /defineOptions\(\s*{([\s\S]*?)}\s*\)/g;
content = content.replace(defineOptionsRegex, (match, inner) => {
hasDefineOptions = true; // 标记已存在defineOptions
// 替换或添加 name 属性
const nameRegex = /(name\s*:\s*['"])([^'"]*)(['"])/;
let newInner = inner;
if (nameRegex.test(newInner)) { // 存在name属性时替换
newInner = newInner.replace(nameRegex, `$1${componentName}$3`);
console.log(`✅ 成功替换【name】: ${componentName} → ${filePath}`);
} else { // 不存在时添加
newInner = newInner.trim() === ""
? `name: '${componentName}'`
: `name: '${componentName}',\n${newInner}`;
console.log(`✅ 成功添加【name】: ${componentName} → ${filePath}`);
}
return `defineOptions({${newInner}})`; // 重组defineOptions
});
// 新增 defineOptions(如果不存在)
if (!hasDefineOptions) {
content = content.replace(scriptSetupRegex, (match, attrs) => {
return `<script ${attrs}>
defineOptions({
name: '${componentName}'
})`;
});
console.log(`✅ 成功添加【defineOptions和name】: ${componentName} → ${filePath}`);
}
// 仅在内容变化时写入文件
if (content !== oldContent) {
fs.writeFileSync(filePath, content);
// console.log(`✅ 成功更新 name: ${componentName} → ${filePath}`);
}
};
processDirectory(targetDir);
console.log("🎉 所有 Vue 组件 name 处理完成!");
1.2通过执行pnpm setName脚本命令,给每个vue组件设置name
"dev": "pnpm setName && vite --mode beta --host",
"setName": "node auto-set-component-name.mjs",
2.路由跳转
问题:因为涉及到动态路由,导致原先跳转到具体菜单页的逻辑不可行,路径是不固定的。
方案:使用路由name跳转;通过需要跳转的文件路径,找到对应跳转的路由name即可
router.push({ name: ComponentA })
3.弹框类缓存处理
问题:默认弹框、抽屉、删除确认框的遮罩层是全局的,当弹框存在时会阻挡点击菜单或页签进行跳转
方案:给jg-dialog弹框设置挂载属性,通过append-to将其挂载在某个具体div下,解决遮罩区域
代码:
:append-to-body="false"
:append-to="'.append_to_class'"
修改前:
修改后:
弹框:
抽屉:
删除确认框:
4.tab类切换缓存
方案:使用<keep-alive>和<component>动态组件实现
<template>
<el-space class="wbg pt pl">
<el-radio-group v-model="activeName">
<el-radio-button label="巡检类" value="1" />
<el-radio-button label="巡检项" value="2" />
</el-radio-group>
</el-space>
<!-- <inspect-cate v-if="activeName == '1'"></inspec t-cate>
<inspect-item v-else></inspect-item> -->
<!-- 采用组件缓存 注释上方切换刷新-->
<keep-alive>
<component :is="componentName[activeName]"></component>
</keep-alive>
</template>
<script setup lang="jsx">
defineOptions({
name: 'InspectionParam'
})
import { ref, shallowRef } from "vue"
import InspectCate from "./components/inspectCate.vue"
import InspectItem from "./components/inspectItem.vue"
const activeName = ref("1")
const componentName = ref({
'1': shallowRef(InspectCate),
'2': shallowRef(InspectItem),
})
</script>
<style lang="scss" scoped></style>