vue3前端路由缓存实现

效果:


文章目录

  • 注意
  • 一、 实现思路
    • 1. 需求梳理
    • 2. 缓存的两种方式
      • 2.1使用name方式
      • 2.2使用meta的keepAlive方式
      • 2.3最终选择
      • 2.4缓存实例的生命周期
    • 3. pinia新增缓存维护字段
    • 4. 导航守卫
      • 4.1全局前置守卫router.beforeEach
      • 4.2全局解析守卫router.beforeResolve
      • 4.3清除缓存
  • 二、 细节处理
    • 1. vue组件设置name
    • 2. 路由跳转
    • 3. 弹框类缓存处理
    • 4. tab类切换缓存
    • 5.携带参数类tab切换
    • 6.当前页直接通过按钮再次跳转当前页处理

注意

提示:本篇是实际实现缓存功能的代码:具体是我的项目顶部有页签功能,用户要求打开除首页部分页面外,任意页签页面都要缓存,关闭页签和刷新时候清除缓存
链接1:实现页面页签功能

链接2:vue3实现缓存的细节和坑

最好先看一遍链接2缓存的坑

场景:目前页面涉及页签和大量菜单路由,用户想要实现页面缓存,即列表页、详情页甚至是编辑弹框页都要实现数据缓存。
方案:使用router-view的keep-alive实现 。


一、 实现思路

1. 需求梳理

需要缓存模块
• 打开页签的页面
• 新增、编辑的弹框和抽屉等表单项
• 列表页点击同一条数据的编辑页
无需缓存模块
• 首页
• 登录页
• 已关闭的页签页
• 列表页点击不同数据的编辑页
• 特殊声明无需缓存页

2. 缓存的两种方式

2.1使用name方式

注意点:name是vue组件实例的name
include:需缓存的vue组件
exclude:不做缓存的vue组件

:key="$route.path"最初我是没加,后来发现有的页面缓存name被清空了,但是onMounted没触发

  <router-view v-slot="{ Component }">
    <keep-alive :include="tabKeepAliveNameList" :exclude="excludeNameList">
      <component :is="Component" :key="$route.path"></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 在组件卸载时也会调用。
• 这两个钩子不仅适用于 缓存的根组件,也适用于缓存树中的后代组件。

<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", "refreshUrl"]
  },
  state: () => {
    return {
      keepAliveNameList: [],
      refreshUrl: undefined, // 记录需要刷新页面的路由,解决编辑类的页面,路由不变但是id变化的情况;以及当前页面url,通过小铃铛点击传了url?id=1的查询情况
    }
  },
  getters: {
    getUserInfo: state => {
      return state.userInfo
    }
  },
  actions: {
    async loginOut() {
      await loginOut()
      this.clearUserInfo()
      router.push({ path: "/login" })
    },
    clearUserInfo() {
      sessionStorage.clear()
      this.keepAliveNameList = [] // 缓存name数据
      this.refreshUrl = undefined
    }
  }
})

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下,解决遮罩区域
实现方案

修改前:
vue3前端路由缓存实现_第1张图片

修改后:
vue3前端路由缓存实现_第2张图片

4. tab类切换缓存

方案:使用和动态组件实现

<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>

5.携带参数类tab切换

场景复杂:
1.厂站规则配置页调到对应的厂站地址记录页的对应tab,是会携带参数查询;需要处理跳到对应tab打开对应tab并按照参数查询
2.跳到tab后,当前页面切换相互的tab,需要保证被切换的tab的数据是正常查询,而不是参数查询
3.跳到tab后,此时当前tab是有参数查询的;那么切换页签到别页 再切换回来,此时地址栏url没变依旧有原参数,那么就是使用缓存数据
4.跳到tab后,此时当前tab是有参数查询的;那么切换页签到别页 再点击菜单回来,此时地址栏url没参数,那么不应有缓存,要直接查询

首先厂站地址记录页父级vue

<template>
  <div class="page-view wbg flex-column">
    <el-tabs v-model="activeName" class="demo-tabs pl pr">
      <el-tab-pane label="设备地址" name="first"></el-tab-pane>
      <el-tab-pane label="互联地址" name="second"></el-tab-pane>
      <el-tab-pane label="业务地址" name="third"></el-tab-pane>
    </el-tabs>
    <!-- <DeviceAddress v-if="activeName === 'first'"></DeviceAddress>
    <InternetAddress v-if="activeName === 'second'"></InternetAddress>
    <BusinessAddress v-if="activeName === 'third'"></BusinessAddress> -->
    <!-- 采用组件缓存 注释上方切换刷新-->
    <keep-alive>
      <component :is="componentName[activeName]"></component>
    </keep-alive>
  </div>
</template>

<script setup>
import { onMounted, ref, shallowRef } from 'vue'
import { useRoute } from "vue-router"
import DeviceAddress from './components/deviceAddress.vue'
import InternetAddress from './components/internetAddress.vue'
import BusinessAddress from './components/businessAddress.vue'

defineOptions({
  name: 'FactorySiteAddressRecord'
})

const route = useRoute()
let searchData = JSON.parse(route.query?.searchData || "{}")
const activeName = ref(searchData?.activeName || 'first')
const componentName = ref({
  'first': shallowRef(DeviceAddress),
  'second': shallowRef(InternetAddress),
  'third': shallowRef(BusinessAddress),
})
onMounted(() => {
})
</script>
<style lang="scss" scoped></style>

但是三个子集tab都要如此处理:目前仅仅以厂站地址记录页的设备地址tab为例:

import { useRoute } from "vue-router"
import useStore from "@/store/index.js"

const { userStore } = useStore()
const route = useRoute()
let refreshUrl = ref(userStore.refreshUrl) // 是否需要刷新页面




const refreshList = (searchData)=>{
  setTimeout(() => {
    searchEvent({...searchData, msg: +new Date()}) // 查询列表
  }, 100);
}
watch(
  () => route,
  e => {
    let dataObj = JSON.parse(e?.query?.searchData || "{}")
    setTimeout(() => {
      refreshUrl.value = userStore.refreshUrl // 不放延时器 有时拿不到
      console.log('%c【' + 'refreshUrl.value ' + '】打印', 'color:#fff;background:#0f0', refreshUrl.value )
      if (dataObj?.activeName === 'first') {
        searchData.value['addressRuleId'] = dataObj.row?.id
      } else {
        searchData.value['addressRuleId'] = undefined
      }
      if (refreshUrl.value) {
        refreshList(searchData.value)
      }
    }, 10)
  },
  { deep: true, immediate: true }
)
onMounted(() => {
  // 触发刷新浏览器获取最新数据(解决列表页缓存,但是请求地址换了参数的情况)
  let dataObj = JSON.parse(route.query?.searchData || "{}")
  if (dataObj?.activeName === 'first') {
    searchData.value['addressRuleId'] = dataObj.row?.id
  } else {
    searchData.value['addressRuleId'] = undefined
  }
  refreshList(searchData.value)
})

6.当前页直接通过按钮再次跳转当前页处理


问题:当前页不管是否清空缓存,当前页直接跳转当前页,此时的onMounted、onActivated、onDeactivated都不会再次触发
方案:只能通过watch监听路由route变化,知道路由参数变化后(即refreshUrl有值),那么就主动请求查询接口(当然此时缓存也做了清空处理----在导航守卫里)

import { ref, onMounted, watch } from "vue"
import { useRouter, useRoute } from "vue-router"
import useStore from "@/store/index.js"


const { userStore } = useStore()
const router = useRouter()
const route = useRoute()
let refreshUrl = ref(userStore.refreshUrl) // 是否需要刷新页面


// 触发刷新的动作:获取地主蓝的id等传参
const refreshList = (searchData)=>{
  searchList()
}

watch(
  () => route,
  e => {
    let searchData = JSON.parse(e?.query?.searchData || "{}")
    setTimeout(() => {
      refreshUrl.value = userStore.refreshUrl // 不放延时器 有时拿不到
      if (refreshUrl.value) {
        refreshList(searchData)
      }
    }, 0)
  },
  { deep: true, immediate: true }
)

onMounted(() => {
  // 触发刷新浏览器获取最新数据(解决列表页缓存,但是请求地址换了参数的情况)
  let searchData = JSON.parse(route.query?.searchData || "{}")
  refreshList(searchData)
  getCodeList()
  getSelectList()
})

你可能感兴趣的:(vue3,缓存,页签实现缓存,vue3前端路由缓存实现,tab实现缓存,keep-alive)