
如果你维护过稍微有点规模的中后台项目大概率遇过这个场景在列表页调好了筛选条件往下翻了几页点进一条数据查看详情返回时整个列表被重置成初始状态滚动位置回到顶部刚才那堆筛选条件全白选了。这类问题在 Vue 里的标准解法就是 keep-alive 组件——它会把组件实例缓存起来配合 activated 和 deactivated 这两个专属生命周期钩子让页面切走之后还能原样回来。这篇文章就从生命周期钩子的角度把 keep-alive 的运行机制、路由接入方式、常见坑和我实际踩过的雷一次讲清楚适合正在用 Vue 2 / Vue 3 做中后台系统、想做页面状态保持但总被缓存问题困扰的同学。我一直觉得很多同学对 keep-alive 的认知停留在给组件包一层就不会重新加载了但一旦遇到为什么又缓存了为什么我明明写了 include 还是不生效为什么 activated 执行了两遍这类问题就抓瞎。要真正用好它必须从生命周期钩子的变化入手理解而不是背配置。接下来我从一个最典型的痛点场景展开。1. 为什么需要 keep-alive一个返回页面就丢状态的经典场景1.1 痛点案例列表页筛选条件与滚动位置全部消失先描述一个我做过无数次的项目场景。后台管理系统的订单列表页顶部有三个筛选条件订单状态、下单时间范围、关键词搜索框。用户选了已发货、时间范围选了最近七天、关键词输了一个订单号然后翻到了第五页看到一条感兴趣的订单点进去看详情。看完了点返回页面一下子就回到了第一页的初始状态筛选条件全部清空滚动位置也在顶部。这个问题的本质是在默认的路由切换机制下离开列表页时组件实例被销毁了组件内部所有本地状态随着内存释放一起没了。返回时 Vue 重新创建了一个全新的组件实例重新走一遍数据请求渲染出来的当然是最初始的状态。用户刚才辛辛苦苦调的筛选条件、翻的页码全都没有被保存下来。很多团队第一次遇到这个问题时第一反应是把筛选条件存到 Vuex / Pinia 里。但存进去之后又发现不仅仅是筛选条件还有表格滚动位置、当前展开的行、临时勾选的数据、某个面板的开合状态……要存的东西越来越多而且每一次都要小心翼翼地处理什么时候存、什么时候恢复的逻辑。做得久了代码里全是状态恢复的补丁。其实这个问题的通用解法很简单别让组件销毁让它活着。1.2 keep-alive 做的事实例缓存而不是销毁重建keep-alive 是 Vue 内置的一个抽象组件它本身不会渲染任何额外的 DOM 节点作用只有一个把内部嵌套的组件实例缓存起来。当内部组件因为路由切换等原因要被替换掉时keep-alive 会拦下这次销毁动作转而把组件实例挂到内部一个隐藏的区域里当组件再次被需要时如果缓存命中它直接把原来的实例和 DOM 拿回来重新渲染。你可以把它类比成宿舍里的床位普通模式下你每次回来都要重新租一个房间、重新搬行李、重新布置而 keep-alive 模式下你的床位一直保留着人出去一趟回来铺位上东西都还在坐下来就能继续干活。这个类比基本涵盖了 keep-alive 的核心价值——保留状态、避免重复渲染、省掉重复请求。但这里必须说清楚keep-alive 不是银弹。缓存意味着这些实例的内存不会释放组件内部如果有定时器、WebSocket、全局事件监听切走时不会自动清理需要在合适的钩子里手动处理。这些细节我会在后面专门展开。先记住一句话keep-alive 让你省去了销毁重建的代价同时也把管理组件副作用的责任移交给了你。2. activated 与 deactivated缓存组件独有的两个钩子2.1 首次进入时钩子顺序为什么不一样普通组件从创建到显示走的生命周期是setup或 Vue 2 的created→mounted之后数据更新触发beforeUpdate/updated销毁时触发beforeUnmount/unmounted。被 keep-alive 包裹的组件多了一对钩子activated和deactivated。被缓存的组件首次挂载时created/mounted会正常执行一遍执行完mounted之后紧接着就执行activated。也就是说一个页面第一次被打开时mounted和activated都会触发。activated为什么排在mounted后面因为activated表达的是组件已经进入活跃状态可以开始工作了。对 keep-alive 来说只有确保组件真正渲染到界面上、DOM 挂载完成才应该通知外部它已经活了。这个顺序在 Vue 2 和 Vue 3 里保持一致。这里有一个非常容易踩的坑首次进入时mounted和activated都会执行。如果你把初始化逻辑同时写在两个钩子里它就会被执行两次。我见过有人在mounted里初始化图表、在activated里也初始化一遍结果图表第一次打开就闪了一下重新渲染。后面我会给出推荐的分工方式先记住这个执行顺序首次打开缓存组件created - mounted - activated2.2 缓存命中后再进入只有 activated 会触发第二次以及以后进入这个页面时组件实例已经被缓存Vue 不会重新创建实例也不会再走created/mounted。你唯一能感知到的钩子是activated。离开页面时组件不会走beforeUnmount/unmounted而是触发deactivated实例进入休眠状态。我用一张表把这个流程完整列出来方便你对照排查场景createdmountedactivateddeactivatedbeforeUnmount / unmounted首次进入缓存组件执行执行执行--缓存后再次进入不执行不执行执行--从缓存页面切走---执行不执行缓存被淘汰 / 组件真正卸载----执行这张表是我排查 keep-alive 问题时的基准。在代码里打日志看到一组钩子执行顺序就能立刻判断这次进入是走了缓存还是全新创建。比如你发现某个页面每次进入都有created日志说明 keep-alive 根本没有缓存住它应该去查 include 或 key 的问题。如果发现切走时走了unmounted说明这个组件被淘汰了或者它压根没被 keep-alive 接管。2.3 Vue 3 组合式 API 中的 onActivated 与 onDeactivatedVue 3 的组合式 API 提供了对应的钩子onActivated和onDeactivated用法跟选项式的activated/deactivated几乎一样。唯一要注意的是这两个函数必须在setup执行阶段同步注册不能放到setInterval回调、异步方法或者条件分支里注册否则注册不到当前组件实例上。script setup import { onActivated, onDeactivated, ref } from vue const pageNo ref(1) onActivated(() { console.log(页面重新激活当前页码, pageNo.value) }) onDeactivated(() { console.log(页面切走在这里清理定时器或事件监听) }) /script如果选项式和组合式同时写了这两个钩子两者都会执行。正常项目里不会有人刻意混用但如果你接手老代码时要意识到这一点别看到一个activated还要再翻一遍script setup里有没有对应的onActivated。Vue 2.7 的script setup也支持onActivated但 Vue 2 老项目里更常见的还是选项式写法两种写法本质是同一套生命周期机制。3. 路由场景下让 keep-alive 按需生效的完整写法3.1 基础接入router-view 外层包裹最朴素的接入方式是把路由出口包进 keep-alivetemplate router-view v-slot{ Component } keep-alive component :isComponent / /keep-alive /router-view /templateVue 2 里更常见的写法是keep-aliverouter-view //keep-alive这个写法在 Vue 3 里也能用但如果你需要在 include 名单上做动态控制推荐上面这种基于v-slot的写法因为它把当前路由组件Component暴露出来方便继续操作。这里必须说一个原则几乎不会有正经项目采用所有页面全部缓存的策略。全缓存会带来两个问题一是随着项目页面增多所有访问过的页面实例都被存在内存里内存占用不停上涨二是像登录页、404 页、实时数据大屏这类页面缓存反而会产生错误行为——大屏每次进入应该拉最新数据结果你拿到的是几个小时后之前的数据。所以按需缓存几乎是唯一合理的选择。3.2 include/exclude 控制缓存名单keep-alive 提供了include和exclude两个 prop用来控制哪些组件可以被缓存、哪些绝对不能缓存。它们可以接收字符串、正则表达式在 Vue 3 里也可以传数组。keep-alive :include[ListPage, DetailPage] component :isComponent / /keep-alive使用 include / exclude 时最大的坑是匹配的是组件的 name而不是路由的 name。很多同学在路由配置里写了name: list然后在 include 里也写list结果发现缓存死活不生效。原因在于 keep-alive 内部判断的是组件实例上的name字段跟路由配置里的 name 是两套东西。如果你在 Vue 3 里使用script setup组件默认不会自动生成 name 字段必须用defineOptions显式声明script setup defineOptions({ name: ListPage }) /script如果是选项式组件name就是组件选项里那个name。写 include 之前先确认目标组件的 name 是什么用defineOptions声明过才算数这是 keep-alive 接入时最容易忽略的一步。3.3 用路由 meta 动态管理缓存视图中后台项目最推荐的缓存管理方式是在路由表里配置 meta 标记再根据路由动态生成 include 数组。这样哪些页面要缓存、哪些不要缓存都集中在路由配置里管理而不是散落在组件代码中。路由配置{ path: /list, name: list, component: () import(/views/ListPage.vue), meta: { keepAlive: true } }布局组件里的实际包裹方式template router-view v-slot{ Component, route } keep-alive :includecachedViews component :isComponent :keyroute.path / /keep-alive /router-view /templateimport { computed } from vue import { useRoute } from vue-router const route useRoute() // 实际项目中这个数组往往由路由配置统一生成这里示意一个静态数组 const cachedViews computed(() [ListPage])include 数组是响应式的keep-alive 会根据名单变化动态新增或淘汰缓存。比如某个页面原本标记了需要缓存后来因为业务调整把 meta.keepAlive 改成了 false对应组件就会在名单移除后被真正销毁并触发 unmounted。这个特性可以用来处理页面从缓存状态恢复到常规状态的需求。这里我再补一个细节:keyroute.path的作用是区分同一个组件在不同路由路径下的实例。后面会讲到多实例场景key 是 keep-alive 正确工作的关键一环先记住这个写法。4. 缓存带来的数据残留陷阱与多实例场景区分4.1 activated 不是 mounted别在钩子里做一次性初始化我见过的最典型错误把初始化图表对象注册 window resize 事件获取基础字典配置这类逻辑写进activated。结果每次从缓存切回来图表重新初始化一遍、事件监听重复注册一份表现就是页面越来越卡、图表闪烁、resize 被触发多次。这是对生命周期语义理解不透彻造成的。正确的认知是created/mounted是这个组件实例一生只执行一次的地方适合放一次性初始化activated是每次进入都会执行的钩子适合放每次回到这个页面都需要刷新一遍的逻辑。如果你确实是首次进入时初始化后续进入时跳过初始化的需求可以加一个标志位let initialized false onActivated(() { if (!initialized) { initChart() initialized true } fetchListData() })这比把所有初始化都放进 activated 可靠得多。实际项目里我还见过一种衍生问题组件被缓存后activated里的逻辑如果在切走前异步执行了一半切回来时会出现上一次请求和这一次请求同时返回、数据互相覆盖的情况。我习惯在请求函数里加一个当前请求唯一性判断或者干脆用 AbortController 在 deactivated 时取消未完成的请求这个处理对体验提升很明显。4.2 多标签页场景下如何区分触发的是哪个实例中后台管理系统经常做多标签页Tab效果用户每打开一个路由页面对应一个 Tab组件被切走时缓存切回来时保留原样。当多个 Tab 同时存在时activated会在每次标签切换时触发但它不告诉你是哪个实例被激活了。比如列表页和详情页都开了切换两个 Tab两个组件的activated都会各自触发这是正常的因为每个缓存组件实例在激活时都会执行自己的activated。但有一个容易混淆的场景同一个组件被实例化了多份比如用户详情组件被两个不同用户的 Tab 同时打开。这时候两个实例的activated都会触发你需要一个方法来区分当前是哪个实例。最常用的是读取当前路由信息来判断import { useRoute } from vue-router onActivated(() { const route useRoute() if (route.params.id 1001) { // 当前激活的是用户 1001 的详情 } })如果同一个路由组件要支持并行多个不同参数的实例就必须给组件设置不同的 key。比如component :isComponent :keyroute.fullPath /直接用route.fullPath作为 key同一个路由不同 query 参数会被当成不同实例缓存。如果只用一个固定 key 或者不加 key两个用户的详情会互相覆盖数据这是多标签页项目里非常经典的坑。4.3 嵌套路由中 keep-alive 的命中问题嵌套路由在真实项目里非常常见一个布局组件下挂着多个子路由页面子页面往往有自己的筛选和滚动位置。如果你把 keep-alive 直接包在父布局的外层会碰到一个诡异的现象整个 Layout 被缓存了但 Layout 内部的子路由视图并没有独立的缓存切出去再切回来时Layout 的 activated 触发了子页面却显示的还是旧状态甚至内部一些依赖路由参数的逻辑没有重新计算。这种问题出现的原因在于keep-alive 缓存的最小单位是组件实例而嵌套路由下父组件 子组件是组合在一起的。父组件被缓存时子路由视图是由父组件内部的router-view渲染出来的它并不直接归属于外层的 keep-alive 管理。我的实践经验是遇到嵌套路由先画清楚谁被缓存、谁在切换、谁要状态保持的关系。通常更合理的安排是keep-alive 只包在最内层真正的页面组件上布局组件不做整层缓存。如果你确实需要整体缓存也要明确子页面依赖的响应式数据是否能在 activated 触发后正确刷新。调试时最直接的方式是在每个候选组件的 activated / deactivated 里打日志看看到底谁触发了、谁没有触发比对着代码猜快得多。5. 缓存页面如何拿到最新数据刷新策略与钩子分工5.1 activated 里拉数据代价与正确姿势缓存让列表状态保持了但紧接着会有新问题切回来时数据还是旧的。比如列表页缓存了但另一个页面改了订单状态回到列表应该看到最新数据。如果只在mounted里请求过一次缓存复用时不会走 mounted列表永远不会更新。最直接的做法是把请求动作从mounted挪到activated。这样每次进入缓存页面都会重新请求数据保持新鲜。代价是缓存只省了渲染和状态保持的成本没有省网络请求成本——每次进页面照样打接口。如果数据实时性没那么强可以用缓存过期时间来折中记录上次请求时间activated时判断是否超过规定时长超时才重新请求const lastFetchTime ref(0) const listData ref([]) async function fetchList(force false) { const now Date.now() if (!force now - lastFetchTime.value 60 * 1000) return listData.value await getListApi() lastFetchTime.value now } onMounted(() fetchList(true)) onActivated(() fetchList())这个 60 秒的阈值完全按业务场景定有些页面 30 秒就要最新数据有些页面 5 分钟不刷新也没关系。这个模式特别适合列表详情这种状态要保持、数据要新鲜的典型场景。5.2 beforeRouteEnter 与 activated 的分工很多同学分不清beforeRouteEnter和activated的区别。beforeRouteEnter是路由守卫发生在导航确认前、组件实例尚未创建时。因此在这个钩子里拿不到this也不能直接操作组件数据。你可以在里面做权限校验、页面访问拦截或者通过next(vm ...)拿到实例再做一些初始化操作。activated是组件实例的钩子发生在组件创建完成或者已经从缓存中恢复之后可以安全地访问响应式数据、调用方法、发起请求。在业务上我做这样的分工需要拦截导航的放beforeRouteEnter需要刷新数据的放activated。两者可以同时存在执行顺序是beforeRouteEnter先于activated。一个实际建议如果只是回来刷新数据这个需求没必要把请求写到beforeRouteEnter里直接写activated更干净。beforeRouteEnter适合做带这次导航是否放行语义的事情而activated适合做页面已经可见我把数据搞新的事情。很多项目把请求逻辑硬塞进beforeRouteEnter写下来代码绕来绕去其实没有必要。5.3 watch 监听 activated 混合使用的经验keep-alive 缓存实例里watch依然正常工作。比如页面依赖某个全局 store 里的状态切回页面时 store 已经更新watch就会触发。如果你同时又在activated里发起一次同样的请求就可能出现同一份数据请求两遍的尴尬。我的处理原则是同一类数据更新只交给一个触发源。每次进入页面都必须刷新的数据用activated控制依赖外部状态变化而更新的数据用watch控制。如果确实两个触发源都会碰到同一个数据刷新逻辑就把刷新函数抽出来共用并且加一个简单的请求中标记避免并发重复请求。还有一个很容易被忽略的坑组件被缓存后切走时不会触发unmounted如果你把事件监听、定时器的清理写在unmounted里它可能一直不会执行。正确做法是把这些清理动作放到onDeactivated里。这一点对 keep-alive 场景下的内存管理非常重要我排查过的几个线上卡顿问题最后都定位到某个页面缓存后定时器还在无限运行。6. 什么时候不该用 keep-alive内存与性能权衡6.1 缓存会放大什么成本keep-alive 缓存的是整个组件实例包括响应式数据、虚拟节点和真实 DOM 节点。列表页里有几千行表格数据、大图预览、长列表的话这些内容都会被存在内存里。缓存五到十个这样的页面内存占用可能轻松超过一两百 MB在内存受限的设备上体感非常明显。所以用 keep-alive 前必须想清楚这个页面真的值得缓存吗判断标准是用户离开这个页面再回来是否需要保持原样。带有筛选条件、分页和滚动位置的列表编辑到一半的表单值得缓存实时监控大屏、一次性报表、异常页、404 页不值得缓存。我还见过一种缓存放肆的项目为了省事把所有页面都丢进 keep-alive结果页面越开越多缓存越来越多最后出现各种内存问题和状态残留问题。这种问题排查起来非常痛苦因为看起来每个页面都正常但整体上项目越来越卡。所以我在项目里一直强调缓存名单要经过设计不是所有页面都要缓存。6.2 max 限制与淘汰策略keep-alive 提供了max属性限制最多缓存多少个实例keep-alive :max10 component :isComponent / /keep-alive超过上限时最久没有被访问的缓存实例会被淘汰。被淘汰的组件会真正卸载走beforeUnmount/unmounted。这个行为和浏览器的 LRU 缓存机制类似。如果你把某个页面视为必须始终在缓存里但 max 设得太小它在中途被挤出去了下次进入时会重新创建状态也就丢了。项目里我一般把 max 设为需要缓存页面的数量再加上一两页余量而不是随便写个很大的数。更重要的是一旦你意识到缓存可能被淘汰所有依赖这个实例一定存在的代码都要谨慎。这就是为什么前面反复强调activated里的刷新逻辑要兜底——哪怕组件真的被重新创建了activated依然会触发数据逻辑不会断。6.3 排查 keep-alive 失效的常用思路最后分享一套我排查 keep-alive 问题的固定流程遇到怎么不缓存了怎么状态丢了之类的问题可以直接照着查先确认目标组件有没有被 keep-alive 实际包住。在组件里打console.log(activated)切走再切回看钩子是否触发。没触发说明组件根本不在 keep-alive 的管理范围。检查 include / exclude 是否匹配到正确的组件 name。用defineOptions显式声明 name 是最稳妥的做法别依赖任何自动推断。检查组件外层是否有 v-if / v-for / :key 在变化。key 一旦变化Vue 会认为这是一个不同的节点keep-alive 的缓存失效组件走重建流程。检查是否存在多个 keep-alive 互相嵌套、组件被包在错误层级的情况。画出谁负责接管这个组件实例的关系图比盲目加 include 有效得多。检查淘汰策略max 是否够用、有没有动态修改 include 导致缓存被主动丢弃。我用了很多年这套流程基本覆盖了日常工作中九成的 keep-alive 异常。剩下的情况大多是缓存实例内部某个副作用没清理干净导致的表现异常比如定时器、事件监听、全局状态残留这种只能通过 review 组件内部的资源和事件管理来定位。最后再分享一个我个人坚持的小习惯在接入 keep-alive 之前先画一张简单的页面流转图把需要缓存、不需要缓存、每次进入需要刷新的页面分别标出来再设计 include 名单。这个动作看起来要多花十分钟但项目逐渐变大之后你会感谢当初这张图帮你避免了一堆这个页面到底该不该缓存的反复争论。这也是我做中后台项目这些年里最想推荐给后来者的一条实战经验。