
做后台管理系统久了你会对“页面切换”这四个字特别敏感。用户点一下菜单页面从列表切到详情再切回来如果滚动条归零、表单填了一半被清空、列表重新 loading那就是一次不合格的体验——这类问题我这边十次有八次是靠动态组件component :is加 keep-alive 解决的。这篇就讲清楚它俩怎么配合以及我在项目里从“点一次卡一次”到“秒切”的完整改造过程。不管你是刚接触 Vue 的新手还是已经写了几年业务的老手只要被页面切换性能烦过都值得看完这一段。1. 先搞懂动态组件切换的背后是一次次重新“出生”1.1 动态组件到底是什么先聊一个最简单的场景页面上有“列表”、“详情”、“统计”三个板块tab 切来切去。很多人第一版是这么写的template div v-ifcurrentTab list ListPanel / /div div v-else-ifcurrentTab detail DetailPanel / /div div v-else StatsPanel / /div /template能跑但代码很啰嗦。Vue 提供一个更优雅的写法template component :iscurrentComponent / /template script setup import { shallowRef } from vue import ListPanel from ./ListPanel.vue import DetailPanel from ./DetailPanel.vue import StatsPanel from ./StatsPanel.vue const currentComponent shallowRef(ListPanel) /scriptcomponent :isxxx就是动态组件xxx可以是一个组件对象、字符串也可以是一段运行时计算出来的值。它的价值在于把“渲染谁”的决定权从模板转移到逻辑层代码量减少扩展性也更强以后加第四个 tab 只需要在 tab 配置里加一行模板完全不用动。需要注意一个细节这里用shallowRef而不是ref。因为组件对象是静态引用不需要深度响应式跟踪用shallowRef可以省掉一层 Proxy 包装的性能开销。虽然差别很小但高频切换场景下这种细节积累起来就是体感差距。1.2 不带缓存的切换有多贵动态组件解决了“怎么写”的问题但没解决“性能”的问题。要理解性能瓶颈得先知道一次切 tab 背后发生了什么旧组件卸载触发beforeUnmount和unmounted组件实例被销毁DOM 被移除。新组件挂载走完setup、render、mounted重新初始化所有ref、reactive数据、computed、watcher重新创建 DOM。业务逻辑重跑列表页的onMounted里发了请求切换走后请求中断切回来又发一次详情页滚到一半的滚动位置归零表单里选好的数据全部清空。一句话总结没有缓存每次切换都是一次“从零开始的新生”。用户点的明明是同一个页面体验却像是重新打开了一个新窗口。这还不是最糟的。如果组件里挂了大图表、大表格、复杂的树形结构重新创建的开销会放大到肉眼可见的卡顿。尤其移动端低端机一次切换掉帧掉到个位数这种性能问题会被用户直接骂上热搜。所以解决思路很明确能不能把组件实例留下来切走时“冻结”切回来时“复活”2. keep-alive把“现场”和“状态”一起留住2.1 核心原理缓存的是实例不是代码Vue 提供的答案是内置组件keep-alive。怎么用一句话把动态组件包一层。template keep-alive component :iscurrentComponent / /keep-alive /template很多人的第一反应是这不就是把组件“存起来”吗没那么简单。keep-alive 缓存的不是一段代码也不是简单的 HTML 字符串而是组件实例本身——包括实例内部的响应式数据、计算属性、DOM 树、滚动状态、事件监听一个都不丢。原理层面keep-alive 是 Vue 内置的抽象组件。它在内部维护了一个缓存 Map键是组件的key值是组件对应的 VNode。组件第一次渲染时正常创建、挂载同时把该组件的 VNode 存入缓存组件即将被卸载时keep-alive 拦截了这个卸载过程把它放到一个“不渲染但保留”的状态下次再切换回来直接从缓存里取 VNode 和组件实例跳过创建过程直接渲染。用人话说无缓存切换让组件“死”一次、“生”一次有缓存切换只是“睡了一觉”又“醒来”。这个机制是页面切换性能优化最核心的那一层地基。2.2 max 与 LRU缓存也要有边界缓存不是越多越好。如果一个后台系统有 30 个页面每个页面都挂着复杂表格全部缓存下来内存占用会非常可观。这就是max存在的意义keep-alive :max5 component :iscurrentComponent / /keep-alivemax指定最多缓存多少个组件实例。超过上限时keep-alive 会启用一套**LRU最近最少使用**淘汰策略优先销毁最久没有被访问的那个实例。这跟 Redis 的内存淘汰策略、操作系统页面置换算法是同一个思路也是一种“业务上的性能与内存的权衡策略”。实际项目中max的值建议按页面体量来定轻量页面可以放到 10~15重量级页面大表格、大图表、富文本编辑器建议控制在 3~5。不要迷信“缓存越多越快”低端安卓机上缓存几百个 DOM 树的下场往往是切换不卡了、整体更卡了。2.3 include/exclude精确控制缓存范围更精细的控制方式是include和exclude它们接受字符串、正则或数组匹配的是组件的name选项keep-alive :include[ListPanel, DetailPanel] component :iscurrentComponent / /keep-alive这里有一个非常容易踩的坑匹配的不是路由地址也不是文件名而是组件内部声明的name。在 Vue 3 的script setup模式下组件默认没有name需要通过defineOptions显式声明script setup defineOptions({ name: ListPanel }) /script如果忘记声明keep-alive 匹配不到组件就不会被缓存表现就是“明明写了 keep-alive切回来还是重新加载”。这个坑我见过太多次了排查思路直接看组件有没有name命中率很高。2.4 activated/deactivated缓存状态下的新生命周期组件被 keep-alive 包裹后生命周期会多出两个勾子activated和deactivated。在组合式 API 里对应onActivated和onDeactivated。第一次进入时执行顺序是beforeMount - mounted - activated。之后每次切回来只执行activated不再执行mounted。这个变化直接改变了我们的编码习惯——这也是新手最容易理解错的地方。比如有个列表页数据在onMounted里请求了一次切走再切回来onMounted不跑了数据还是旧的。正确做法是把“刷新数据”的逻辑放进onActivatedonActivated(() { fetchList() })但同样要警惕onActivated每次切回来都会执行。如果里面挂了一个高频接口切一次打一次后端接口压力会成倍上升——这种“缓存优化引发的性能反噬”需要结合业务决定有的场景适合每次都刷新有的场景适合借助ref(false)标记只刷新一次。deactivated则是对应的“冻结钩子”。组件切走时这里用来清理定时器、注销监听、保存临时状态。注意它不等于unmounted组件没有被销毁只是休眠了所以别在deactivated里做“销毁级”操作。3. 实战改造从“点一次卡一次”到“秒切”3.1 先定场景多标签页管理页面理论说完了来一个具体的实战案例。最常见、最典型的需求是多标签页后台管理系统类似编辑器的多页签顶部一排 tab点击切换显示对应页面要求切换时保持各 tab 内的状态——列表数据、滚动位置、搜索条件都不能丢。改造前的痛点每切一次 tab表格重新 loading、查询条件被重置、滚动条回到顶部、接口压力翻倍。页面多的时候切 tab 有明显的白屏闪烁和掉帧。这就是一个标准的动态组件 keep-alive 优化场景。3.2 用动态组件 keep-alive 落地我先给一个可直接抄的简化版本。假设有 4 个 tab数据源是一个数组template div classtabs-container div classtabs-header button v-fortab in tabs :keytab.name :class{ active: activeTab tab.name } clickactiveTab tab.name {{ tab.title }} /button /div keep-alive :includecachedNames :max4 component :isactiveComponent / /keep-alive /div /template script setup import { ref, shallowRef, computed, watch } from vue import TabList from ./TabList.vue import TabTable from ./TabTable.vue import TabChart from ./TabChart.vue import TabEditor from ./TabEditor.vue const tabs [ { name: list, title: 列表, component: TabList }, { name: table, title: 表格, component: TabTable }, { name: chart, title: 图表, component: TabChart }, { name: editor, title: 编辑器, component: TabEditor }, ] const activeTab ref(list) const activeComponent shallowRef(tabs[0].component) // 缓存所有 tab但数量不超过 4 个 const cachedNames tabs.map((tab) tab.name) watch(activeTab, (newVal) { const target tabs.find((tab) tab.name newVal) if (target) { activeComponent.value target.component } }) /script关键点有三个第一activeComponent用shallowRef组件对象本身是静态引用用ref会增加不必要的响应式跟踪开销切换频繁时这个差距会被放大。第二include数组里直接用name如果你用的是script setup记得在每个子组件里补上defineOptions({ name: TabList })。第三控制好缓存上限。4 个 tab 就设:max4不多不少避免内存浪费。3.3 用 route.meta 控制缓存范围还有一种更通用的做法路由页面切换场景下给每个路由配置meta再根据meta动态决定要不要缓存。很多后台系统都是这么玩的。路由配置const routes [ { path: /list, component: () import(./views/List.vue), meta: { keepAlive: true } }, { path: /detail, component: () import(./views/Detail.vue), meta: { keepAlive: false } }, { path: /stats, component: () import(./views/Stats.vue), meta: { keepAlive: true } }, ]页面模板template keep-alive :includecachedComponents router-view / /keep-alive /template script setup import { computed } from vue import { useRouter } from vue-router const router useRouter() // 把 meta.keepAlive 为 true 的组件 name 收集起来 const cachedComponents computed(() router.getRoutes() .filter((route) route.meta?.keepAlive) .map((route) route.components?.default?.name) .filter(Boolean) ) /script这样做的价值在于决策权在路由表上。以后哪个页面要缓存改一行 meta 就行不用动模板哪个页面状态敏感、必须每次重新拉数据就把meta.keepAlive设为false。这种“按需缓存”的做法比无脑全缓存更能兼顾体验和内存。不过要注意一个细节route.components.default.name依赖的是异步组件加载完成后的name。如果组件没声明name这个过滤链会直接把组件过滤掉导致缓存不生效。所以配合路由懒加载使用时每个需要缓存的组件都得显声明defineOptions({ name })这点反复强调也不为过。3.4 性能数据怎么量别凭感觉优化优化做完了怎么证明有效我习惯用浏览器 DevTools 三个面板配合验证Network 面板看接口。切换 tab 后如果列表接口没有重新发起请求说明组件实例被完整缓存这是最直观的信号。Performance 面板记录切换耗时。用 Performance 面板录制一次切换操作看Scripting、Rendering、Painting三段时间。缓存命中后新组件创建过程被完全跳过Scripting 时间会显著下降。想更精确可以在代码里手动打点performance.mark(tab-switch-start) // 触发切换... requestAnimationFrame(() { performance.mark(tab-switch-end) performance.measure(tab-switch, tab-switch-start, tab-switch-end) })Memory 面板看堆内存曲线。反复切换所有 tab内存应该稳定在某个水平而不是持续上涨。如果每次切换都涨一截且回不来大概率是缓存没限界或者组件里挂了没清理的定时器和监听器。我在项目里实测的一组数据很典型无任何优化的多 tab 系统切 tab 平均耗时 380ms表格重加载、滚动位置失控接口重复请求率 100%加上 keep-alive 后平均耗时降到 45ms 左右接口只在新数据需要刷新时请求。这个差距不是“微调”是用户能直接感知的级别。这背后的经验本质上跟服务端 SQL 调优、执行计划优化一个道理不是切一个点而是把那些“重复建造成本极高”的环节真正省掉性能才有质变。4. 踩过的坑与排查技巧实录4.1 缓存命中但数据是旧的这是 keep-alive 最经典的坑组件实例被完整缓存了包括它内部的数据。如果业务要求每次切回来都展示最新数据而你把请求放在onMounted里那切回来永远看到的是旧数据。排查思路不难确认组件是否在defineOptions里声明了name然后确认 keep-alive 的include是否匹配到它最后直接看逻辑层——把请求从onMounted挪到onActivated该复用缓存时复用缓存该刷新数据时刷新数据两条路分开走别混在一起。4.2 内存持续上涨先查这三级内存上涨是一个缓慢积累的问题可能项目跑了一周才暴露。我给一个优先排查清单一级max设了没有。没设max意味着无限缓存页面多的时候内存曲线必然失控。二级组件里有没有定时器、全局事件监听、ECharts 实例、WebSocket这些资源在deactivated里有没有挂起或销毁。如果只创建、不回收内存会变成“只进不出”。三级include范围是不是设得太宽。有些页面根本不需要缓存比如一次性引导页、数据实时性要求极高的监控页把它们的name从缓存列表里拿走内存压力立刻下来。我见过一个真实案例系统里 30 多个页面全部缓存图表组件里还各自创建了 ResizeObserver切几轮下来页面直接崩。后来缓存上限压到 5、图表离开页面时销毁实例内存曲线就平了——这是典型的“优化手段用过头”。4.3 移动端低端机的切换反而更卡这是个反直觉的问题加了 keep-alive 后桌面端很流畅但低端安卓手机上切 tab 反而卡。原因很简单缓存的组件实例和 DOM 树都留在内存里大量 DOM 节点同时存在渲染引擎的压力反而更大了——移动端性能优化跟桌面端侧重点不同桌面端内存管够低端移动设备却可能缓存崩溃。保守做法把max压小2~3 个激进做法在移动端彻底禁用 keep-alive改成把关键状态滚动位置、表单值、列表数据剥离出来放进 Store 或sessionStorage页面重建时恢复状态。体验上接近“恢复现场”内存上不背 DOM 树的包袱。很多时候在移动端把“组件缓存”换成“状态缓存”才是更聪明的优化。4.4 常见问题速查表现象可能原因解决办法组件没有被缓存每次都重新 loading组件没声明name或include与name不匹配用defineOptions声明name确认匹配规则切回来数据是旧的请求逻辑写在onMounted里把刷新逻辑移到onActivated切回来后界面短暂闪烁/白屏缓存没命中或组件体积过大检查max和include对重量级组件单独处理整体越来越卡内存持续上涨缓存数量无限增长或定时器/图表实例未回收设置max上限在deactivated中清理资源滚动条位置丢失滚动容器被重新创建或容器本身没有被缓存确认滚动发生在缓存组件内部的容器上而非外层布局切回页面后图表重新渲染闪烁图表实例被重新创建了在deactivated里只隐藏而非销毁或复用实例4.5 和 transition 配合的细节页面切换场景很多人会加过渡动画。 keep-alive 和 transition 一起用时注意结构顺序——keep-alive 要包在 transition 内部transition namefade keep-alive component :iscurrentComponent / /keep-alive /transition顺序反了过渡动画可能捕捉不到正确的“进入/离开状态”表现为切 tab 时动画闪烁或干脆没有动画。另外配合key使用时要小心给组件绑key会让 keep-alive 把它当作不同实例处理间接绕过缓存不绑key多个不同组件切换时可能复用同一个 DOM 容器导致状态串味。这块需要在具体项目里开 DevTools 逐个验证遇到动画和缓存打架时先拆掉动画排查是常规手段。4.6 多 tab 切换的“有序关闭”最后补充一个偏门但实用的经验多 tab 系统里用户会手动关闭某些 tab比如关闭后重新打开不希望看到旧状态但没关闭的 tab切换时一定要保留现场。我通常的做法是维护一个可响应的“存活名单”把已经关闭的 tab 名称从include里剔除const closedTabs ref([]) const cachedNames computed(() tabs.map((tab) tab.name).filter((name) !closedTabs.value.includes(name)) )这样已关闭的 tab 会在 keep-alive 的缓存里自然失效下次打开重新创建未关闭的 tab 依然秒切。这个模式对“需要精细控制缓存生命周期”的业务场景非常实用比单纯设置max更可控。这些坑我基本都踩过一遍最后分享一个个人习惯凡是页面切换类场景我会先默认按需开启 keep-alive但上线前一定会看一次 Memory 面板的曲线。缓存不是开了就完事它是性能优化里的“放大器”——用得好丝滑到无缝用不好内存爆炸和依赖缓存的状态逻辑会让你加班到怀疑人生。真正动手前先想清楚哪些页面值得缓、哪些页面不值得效果一定比无脑给所有页面套keep-alive好得多。