为什么iframe页面在keep-alive中缓存无效?切换tab总是会重新加载iframe中的页面?我觉得有必要深入分析一下,讲讲为什么?怎么解决?
【环境说明:vue3+vue-router4】
要想解决这个问题首先我们需要了解两个知识点:
iframe什么情况下会重载?
keep-alive工作原理是什么?
iframe什么情况下会重载?
触发加载有多种情况(前提设置有效的src):
html解析期的DOM树构建触发加载
iframe设置laoding="lazy"视口检测+渲染调用触发
iframe一些数据修改触发加载,例如:src、sandbox(但修改display/visibility属性不触发加载)
浏览器行为触发加载,例如:用户点击、滚动、.resize()
DOM树更新触发加载
【DOM树更新触发加载】这个和我们主题相关,其加载触发的核心前提是【该iframe节点被纳入父页面DOM树】,底层对应DOM树的突变触发资源调度。
iframe这种DOM剥离会触发浏览器销毁对应的浏览上下文,导致iframe的运行状态彻底丢失。iframe挂载又是一个创建全新浏览上下文的过程,所以才会导致iframe重新加载。
keep-alive工作原理是什么?
我们知道keep-alive是用于存储被缓存的组件实例,这里我们用[Tab 形式路由 + keep-alive 的底层工作流程]为例,以「Tab1(路由 A)→ Tab2(路由 B)→ 切回 Tab1(路由 A)」的完整流程来讲讲它的工作逻辑:
首次加载 Tab1(路由 A):全新创建 + 挂载
此时路由 A 组件未被缓存,Vue 会:
创建路由 A 的组件实例(包含 data/ref 状态、方法等);
根据组件实例生成全新 VNode 树(描述组件 DOM 结构的抽象对象);
基于 VNode 树创建真实 DOM 元素,并将 DOM 挂载到页面的 Tab 容器中;
路由 A 组件激活后,keep-alive 会将「组件实例 + VNode 树」存入缓存容器(内部 Map 结构)。
切换到 Tab2(路由 B):Tab1 失活(卸载 DOM + 保留缓存)
此时切换路由,路由 A 组件进入失活状态:
keep-alive 触发路由 A 的 deactivated 钩子;
路由 A 对应的真实 DOM 会被从页面 DOM 树中卸载(移除),但 DOM 元素本身并未被销毁(只是脱离页面渲染树);
路由 A 的「组件实例 + VNode 树」仍保存在 keep-alive 缓存中,状态(如表单输入、滚动位置)不会丢失;
若 Tab2 是首次加载,会重复「首次加载」的流程(创建组件实例→生成 VNode→创建 DOM→挂载),若已缓存则直接复用(后续流程同 Tab1 切回逻辑)。
切回 Tab1(路由 A):复用缓存 + 挂载已有 DOM
keep-alive 从缓存容器中直接取出路由 A 的「组件实例 + 已缓存的 VNode 树」(无需重新创建);
基于缓存的 VNode 树,找到之前卸载的「真实 DOM 元素」(该 DOM 未被销毁,只是之前脱离了页面);
将这份已有真实 DOM 直接重新挂载到页面 Tab 容器中(仅执行挂载操作,不执行 DOM 创建);
触发路由 A 的 activated 钩子,组件激活完成,之前保留的状态(如表单内容)直接显示,无需重新渲染。
从上面完整的例子我们知道,keep-alive缓存的组件在失活状态DOM元素本身是脱离页面渲染树,激活重新挂载。
到这里我们已经找到keep-alive对iframe看似 “无效”的答案:keep-alive会让缓存组件脱离和重新挂载文档流,对iframe这种dom元素会触发重新加载。
vue-router也是一样,包裹起来也是会导致重新挂载问题
解决方案
通过以上的分析,我知道iframe想要“有缓存”,那就是要避免iframe脱离文档流,那我们就不能把iframe放在keep-alive或router-view里。这里以[Tab 形式路由 + keep-alive]的架构进行设置:
1. 创建项目
1 npm create vite@latest my-vue-project -- --template vue
2. 创建路由router.js
meta中keep-alive决定是否开启缓存, iframe决定是否页面为外链
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 import { createRouter, createWebHistory } from 'vue-router' import A from './components/A.vue' const routes = [ { path: '/', redirect: '/a' }, { path: '/a', name: 'A', component: A, meta: { isKeepAlive: true, title: '我是A页面, 被keep-alive缓存了' } }, { path: '/b', name: 'B', component: A, meta: { isKeepAlive: false, title: '我是B页面, 没有被缓存' } }, { path: '/c', name: 'C-cache(iframe)', meta: { isKeepAlive: true, iframe: 'https://www.baidu.com', title: '我是C页面, iframe有“缓存”' } }, { path: '/d', name: 'D-no-cache(iframe)', meta: { isKeepAlive: false, iframe: 'https://baidu.com', title: '我是D页面, iframe没有“缓存”' } } ] const router = createRouter({ // 路由模式:createWebHistory(H5 历史模式,无 # 号),createWebHashHistory(哈希模式,带 # 号) history: createWebHistory(import.meta.env.BASE_URL), routes: routes // 注入路由规则(简写:routes) }) export default router
3. 实现App.vue用来加载路由
这里IframeComponent的src需要传入,不能通过在组件内使用useRoute获取,不然还是动态修改src导致外链重载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 <template> <div> <div> <router-link to="/a"><button :class="{active: route.name === 'A'}">A</button></router-link> <router-link to="/b"><button :class="{active: route.name === 'B'}">B</button></router-link> <router-link to="/c"><button :class="{active: route.name === 'C-cache(iframe)'}">C</button></router-link> <router-link to="/d"><button :class="{active: route.name === 'D-no-cache(iframe)'}">D</button></router-link> </div> <router-view v-slot="{ Component, meta }"> <!-- keep-alive 缓存普通vue组件--> <keep-alive> <component :is="!route.meta?.iframe && route.meta.isKeepAlive ? Component : null"></component> </keep-alive> <!-- 非keep-alive组件(包含iframe非缓存) --> <component :is="!route.meta.isKeepAlive ? (route.meta.iframe ? h(IframeComponent, { src: route.meta.iframe, name: route.name }) : Component) : null"></component> </router-view> <div v-show="route.meta?.iframe && route.meta.isKeepAlive"> <!-- 生成所有iframe路由, 并且通过v-show进行模拟keep-alive缓存数据 --> <IframeComponent v-for="(i) in cacheIframe" :key="i.fullPath" v-show="route.fullPath === i.fullPath" :src="i.meta.iframe"></IframeComponent> </div> </div> </template> <script setup> import { onUnmounted, h } from 'vue'; import { RouterView, useRoute, useRouter } from 'vue-router' import { ref } from 'vue' import IframeComponent from './components/IFrameComponent.vue'; const cacheIframe = ref([]); const route = useRoute(); // 获取当前路由对象 const router = useRouter() const removeGuard = router.beforeEach((to, from, next) => { if (to.meta?.iframe && to.meta.isKeepAlive) { const exists = cacheIframe.value.find(i => i.fullPath === to.fullPath) if (!exists) { cacheIframe.value.push(to) } } next(); }) onUnmounted(() => { if (typeof removeGuard === 'function') removeGuard() }) </script> <style scoped> .active { background-color: #42b983; color: white; } </style>
4. A组件实现
承载普通页面容器组件
1 2 3 4 5 6 7 8 9 10 11 12 <script setup> import { useRoute } from 'vue-router' const route = useRoute(); defineOptions({ name: 'A' }) </script> <template> <h1>{{ route.meta.title }}</h1> <input /> </template>
5. iframe组件
承载外链的容器组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script setup> import { useRoute } from 'vue-router' const props = defineProps({ src: { type: String, default: '' } }) const route = useRoute(); </script> <template> <h1>{{route.meta.title}}页</h1> <iframe style="width: 800px;height:500px;" :src="props.src"></iframe> </template>