ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue动态组件+keep-alive实战:一次讲透页面切换卡顿优化

Vue动态组件+keep-alive实战:一次讲透页面切换卡顿优化 切页面卡顿这事可以说是前端实战里绕不开的老大难。Vue 项目做到后期页面越来越多、组件越来越重动态组件切换看起来很顺手实际上每次切换都在做销毁和重建。重一点的页面你就能感受到白屏、闪动甚至点击按钮后半天没反应。动态组件和 keep-alive 的组合就是解决这类体验问题的常规武器但很多人只记住了include和max的基本用法真落地时缓存不生效、内存飙高、生命周期混乱的坑一个接一个。这篇文章就结合我在中后台管理系统、低代码平台里做多页签、列表与详情跳转、表单页签的实际经历把原理、写法、踩坑点一次讲透。适合正在用 Vue 2 / Vue 3 做中后台、移动端 H5并且对页面流畅度和交互稳定性有要求的同学参考。1. 页面切换为什么会卡顿组件频繁销毁重建的代价1.1 从一次普通切换说起先看一个最常见的场景页面上有几个 Tab点击不同标签下方内容区域跟着变。很多人第一反应就是动态组件代码大概长这样component :iscurrentTab /currentTab一变Vue 就开始忙着做一套完整的组件生命周期。旧组件要销毁触发beforeUnmount、unmounted移除事件监听、清理指令、断开响应式依赖新组件要创建实例化、初始化 state、编译渲染、挂载 DOM子组件也跟着全部重新创建一遍。这套过程在博客、文档站的轻量页面上完全没问题但换成列表页、图表页、流程引擎这种重页面开销就非常明显。特别是移动端低端机上点击切换的一瞬间交互线程被占满滚动掉帧、白屏闪烁用户体感就是“卡”。很多同学以为卡顿是切换动画或大量计算造成的但其实真正吃掉性能的是组件实例的反复创建和销毁。每个组件实例都带着自己的响应式数据、watcher、DOM 树重建不是简单地“重画一次”而是把所有东西从零再来一遍。1.2 卡顿的根因资源重建而不是切换逻辑本身如果只是简单渲染一个按钮重建成本可以忽略。可实际页面里通常有这些重资产页面初始化时发起的接口请求每次进入都会重新请求一遍慢接口直接拖住首屏长列表渲染出的大量 DOM 节点重建等于让浏览器重新布局和绘制图表组件、富文本编辑器、地图组件这类带内部状态和一次性绑定的库初始化成本极高表单里用户已经填写的内容一旦组件销毁数据全部清空切换再回来就是空白。换句话说用户每次切换页面看到的不只是“页面换了”而是“整个页面重新加载了一次”。如果这个页面有定时轮询、WebSocket 连接、动画循环情况更糟销毁时还得想办法把资源都释放掉稍不留神就内存泄漏。这种时候 keep-alive 的价值就出来了。它不会改变动态组件的灵活切换能力但会把销毁这一步省掉让组件实例和 DOM 留在内存里下次再切回来时直接复用页面秒开。2. 动态组件是把双刃剑灵活切换但默认不保留状态2.1 动态组件的使用边界动态组件本身是个好东西component :isxxx /一行代码就能实现多组件切换。相比一堆v-if/v-else-if动态组件更干净也好扩展新的 Tab 只要注册组件并更新is的值就能接入。但它有一个隐藏前提切换等同于销毁重建。我用一个生活化类比来解释动态组件切换相当于每次都把房间里的家具全搬走再按新房间的图纸重新装修一遍。房间还是那个房间但里面住的人、摆的东西早就换了。对于轻量级组件这种成本可以忽略但对于重组件这就是卡顿和状态丢失的源头。所以动态组件适合用在“切换后不需要保留状态”的场景比如步骤条中的下一步/上一步或者是不同类型消息详情展示。而像 Tab 页签、列表跳详情再返回、多步骤表单这类“用户会反复进出、希望保留现场”的场景就必须配合 keep-alive。2.2 无缓存时切换页面的三个典型副作用我在实际项目里总结过不套 keep-alive 的动态组件切换最常导致三个问题如果你也遇到了基本可以确定该上缓存了。第一个是重复请求。组件重建created/mounted重新执行写在里面的接口调用全都再来一遍。列表页回到主页再切回来loading 转圈和数据闪没闪出的问题就来了。明明数据几分钟内不会变却被用户一遍遍看着加载。第二个是表单内容丢失。用户填到一半切换到其他 Tab回来发现所有输入框、下拉选、日期范围全部重置。轻则烦躁重则直接把用户填了大半的数据搞丢这在后台管理系统里是会被投诉的。第三个是滚动位置归零。一个滚动到第 50 页的长列表切走再回来瞬间回到顶部。用户已经翻了很久内容结果要重新翻一遍基本可以确定这个页面“不好用”。这三个副作用出现任何一个keep-alive 就是你要考虑的方向。但注意不是所有场景都适合也不是所有组件都适合缓存在内存里后面我会专门聊边界。3. keep-alive 的核心机制与正确打开方式3.1 keep-alive 到底缓存了什么、没有缓存什么keep-alive 是 Vue 内置组件它的作用是把内部组件的 vnode 和 DOM 实例缓存起来。说得直白一点组件第一次被渲染时会额外多做一份“快照”把实例和渲染结果保存下来。之后再次遇到同一组件不会再走完整的创建流程而是直接拿出缓存好的实例和 DOM 来激活。很多人以为 keep-alive 只缓存数据是不够准确的。它缓存的是整个组件实例包括组件的 data、computed、watcher、DOM 子节点。所以表单值、滚动位置、图表配置这些才能原样保留。但这也带来一个重要认知缓存不等于“完全不执行任何逻辑”。被缓存的组件在重新进入时仍然会触发activated钩子。如果代码里把不合适的逻辑放在activated里依然会重复执行。所以 keep-alive 不是免死金牌它只是免掉了“销毁和重建”这层昂贵操作业务逻辑的刷新频率还是得自己把控。3.2 生命周期变化activated / deactivated 的取舍一套带 keep-alive 的组件完整的生命周期会比普通组件多两个钩子activated和deactivated。首次进入时created、mounted都会正常执行随后触发一次activated。组件切走时触发deactivated但组件并不会被卸载。再次进入时created和mounted不会再执行只有activated被触发。这个变化是新手最容易踩坑的地方。比如有人把接口请求放在mounted里第一次能正常加载第二次切回来数据却不刷新了。解决办法很简单把需要每次进入都执行的任务挪到activated里或者同时放在activated和mounted里并根据业务判断用不用执行。我个人的习惯是首次初始化量大、后续不需要频繁刷新的数据放mounted每次进入都需要检查的状态比如“当前用户是否有新消息”“列表是否需要静默刷新”放在activated。但要注意避免在activated里做太重的事否则就算组件没有被重建也可能因为逻辑重而卡住切换。3.3 include / exclude 与 max缓存也要做减法keep-alive 提供了三个常用参数分别是include、exclude和max。它们才是真正控制缓存范围和内存占用的关键。include决定哪些组件可以被缓存exclude决定哪些组件不能被缓存。两者都支持字符串、正则或数组但匹配的是组件的name选项不是文件路径也不是路由名。max用来限制最大缓存实例数默认是无上限超出后按 LRULeast Recently Used最近最少使用策略淘汰最久没被访问的缓存。参数作用匹配规则使用建议include白名单只缓存指定组件匹配组件的 name 选项适合明确知道哪些页面需要缓存exclude黑名单排除指定组件匹配组件 name 选项适合大部分页面都要缓存少数页面需要实时刷新max最大缓存实例数数字控制内存占用建议设置 5-20 之间这三个参数用好了缓存就是可控的。比如一个系统里有 20 个页面只有列表页和表单页需要缓存那直接:include[ListPage, FormPage]就够了。反过来如果大部分页面都要缓存只有几个流程性页面不希望保留状态那就用exclude。4. 实战构建一个高性能的标签页切换系统4.1 基础实现动态组件 keep-alive 组合先从一个最简单的“多标签页 动态组件 keep-alive”开始我会用 Options API 写一版逻辑直观换成 Composition API 也是一样的思路。template div classtabs-container div classtab-header button v-fortab in tabs :keytab.name :class{ active: activeTab tab.name } clickactiveTab tab.name {{ tab.title }} /button /div div classtab-content keep-alive :max5 component :isactiveTab / /keep-alive /div /div /template script import ListPage from ./ListPage.vue import FormPage from ./FormPage.vue import ChartPage from ./ChartPage.vue export default { name: TabsContainer, components: { ListPage, FormPage, ChartPage }, data() { return { activeTab: ListPage, tabs: [ { name: ListPage, title: 列表页 }, { name: FormPage, title: 表单页 }, { name: ChartPage, title: 图表页 } ] } } } /script关键就是keep-alive包着component :isactiveTab /。每次切换旧的组件实例不会被销毁而是进入缓存切回来时直接从缓存中恢复。三个组件内部的状态包括表单输入、滚动位置、图表数据都能原样保留。这里有几个细节要注意。一是被缓存的子组件必须设置name否则include/exclude匹配不到。二是:max5不是越大越好后面专门讲内存问题。三是如果你在component上加了key比如:keyactiveTabkeep-alive 会认为这是不同的组件强制重新渲染缓存就失效了。4.2 按路由维度缓存多页签场景下的路由与组件联动真实项目里很少只用本地组件切换更多是路由之间的切换。比如从列表页跳到详情页再从详情页返回希望列表页不要重新加载或者后台系统里的多个页签切到任意一个都保持在离开时的状态。这种场景需要把 keep-alive 和 vue-router 配合起来用。我的做法是在路由配置里加一个meta标记控制哪些路由需要缓存。{ path: /list, name: List, component: () import(/views/ListPage.vue), meta: { keepAlive: true, keepAliveName: ListPage } }, { path: /detail, name: Detail, component: () import(/views/DetailPage.vue), meta: { keepAlive: false } }然后在 App.vue 里动态维护一个缓存名单。include只认组件name所以这里我用keepAliveName来记录真正要缓存的组件名。template keep-alive :includecachedViews :max10 router-view / /keep-alive /template script export default { data() { return { cachedViews: [] } }, watch: { $route: { immediate: true, handler(route) { const name route.meta?.keepAliveName if (name !this.cachedViews.includes(name)) { this.cachedViews.push(name) } } } } } /script为什么用动态数组而不是在路由表里把所有需要缓存的组件一次性列出来因为多页签场景下用户真正打开过的页面才应该被缓存。如果一开始就把全系统可缓存页面全部加入 include那“缓存”就从优化变成了内存压力。动态维护的好处是只有访问过的页面才进入缓存名单关闭页签时还可以从数组里移除真正做到按需缓存。组件侧也要记得定义name比如ListPage.vue里script export default { name: ListPage // ... } /script这一步最容易漏漏了之后 include 完全匹配不上看起来配置全对但缓存就是不生效。5. 基于常见实践的补充毫秒级切换背后的性能细节5.1 异步组件与 keep-alive 的配合中后台项目一般都会做路由懒加载组件通过() import(...)引入打包时拆成独立 chunk。第一次进入页面时才去加载这个 chunk打开过一个页面之后JS 代码已经在浏览器里了后续切换会快很多。但如果你在动态组件里直接用异步组件比如const ListPage () import(./ListPage.vue)第一次切换时会有一段加载时间表现为白屏或 loading。用 keep-alive 包裹后第一次加载完成后缓存住实例第二次切换基本就是毫秒级。所以推荐的做法是异步组件负责“首次加载体积不拖累首屏”keep-alive 负责“重复切换不重复创建实例”两者是互补关系。还有一点异步组件如果想要被include正确匹配必须在异步组件内部定义name而不是在 import 语句里起别名。因为 keep-alive 匹配的是组件实例上的 name不是模块变量名。script export default { name: AsyncListPage } /script5.2 缓存也要控制内存max 值怎么选keep-alive 缓存的是整个组件实例和 DOM 树。如果一个页面里有大数据表格、上千行 DOM、地图瓦片、WebSocket 数据这个实例占用的内存是相当可观的。缓存 10 个这种页面内存占用可能直接多出几百 MB移动端很容易触发浏览器崩溃。max就是控制这类风险的核心参数。它的淘汰策略是 LRU也就是说最久没被访问的缓存会被自动移出缓存。但要注意被移出意味着这个组件会被真实的销毁下次再进又会重新创建所以max设太小会导致“缓存了个寂寞”设太大又怕内存炸。我的实践经验是普通中后台项目单个页面实例不算太重时max可以设为 10 左右如果页面里有重表格、富文本、低代码渲染器max我会压到 3-5。宁缺毋滥只缓存业务上真正频繁往返的页面比如列表和详情、主页面和编辑页。另外要养成一个习惯不再需要的页面要主动从缓存名单里移除。比如用户关闭了一个业务页签对应组件的name可以从cachedViews数组里删除让 keep-alive 释放缓存。只堆不删内存迟早出问题。5.3 用数据说话如何量化优化效果性能优化不能靠“感觉变快了”最好量化成可对比的指标。最简单的办法是在切换逻辑里手动打点通过performance.now()记录耗时。const start performance.now() // 模拟切换动作比如更新 activeTab activeTab.value ChartPage // 等 DOM 更新后计算耗时 nextTick(() { const cost performance.now() - start console.log(页面切换耗时${cost.toFixed(2)}ms) })如果你想对比开启 keep-alive 前后的差异可以在子组件mounted和activated里分别打点。开启 keep-alive 后第二次切换不会再走mounted只走activated所以耗时主要来自activated里的业务逻辑而不是组件重建。多次点击取中位数数据比体感可靠得多。还有个更直观的观察方式打开 Chrome DevTools 的 Performance Monitor开启 keep-alive 前反复切换页面看 JS Heap 和 DOM Nodes 的曲线开启后再次切换曲线明显平稳说明实例和 DOM 没有被反复创建释放。优化前后的内存曲线对比比嘴上说“不卡了”更有说服力。6. 常见问题与排查技巧实录6.1 缓存了却不生效include 没匹配上这是我自己也踩过的大坑。最典型的原因有三个一是子组件没有写name二是include里的名字和组件name不一致三是include传的是静态字符串而不是数组。静态字符串有个陷阱如果你写成includeListPage, FormPageVue 会把它当单个字符串去匹配不是逗号分隔的数组。除非匹配规则是精确等于否则就是无效配置。排查时先打开 Vue DevTools选中缓存组件看组件的 name 是否出现再看 keep-alive 的 include 值是不是一个数组。如果两者都对得上还是失效看看是不是在component上加了动态key。6.2 activated 与 mounted 的执行时机低估很多人会忽略activated是在首次挂载之后还会再触发一次的。如果把接口请求同时写在mounted和activated里首次进入会请求两次。正确做法是明确区分首次初始化放mounted每次进入都要刷新的放activated也可以在activated里判断一个标志位来避免重复请求。另外activated里不能假设一定会等 DOM 渲染完成。如果业务逻辑要操作当前 DOM需要nextTick包一层否则可能拿到的是上一状态。6.3 缓存太多页面反而更卡有些项目为了“省事”把所有路由页面全部缓存结果内存曲线一路走高页面切来切去越来越慢最后甚至白屏。这是典型的过度优化。keep-alive 缓存不是免费的每一个缓存的组件都会占据内存。如果一个页面你只是偶尔打开一次完全没必要让它留在缓存名单里。我的原则是高频切换、状态重要的页面才缓存低频访问的详情页、大报表页宁可重新加载也不要长期缓存。动态维护缓存名单配合max限制才能让 keep-alive 成为优化而不是负担。6.4 deactivated 里清数据会破坏缓存的意义有同学为了让“下次进入数据变新”在deactivated里重置 data。这样做表面上是刷新了但 keep-alive 的缓存价值就完全失去了。因为下次进入时组件实例还在但数据已经清空跟重新创建没什么区别而且 DOM 白保留了。如果确实需要在切换回来时拿到新数据就不要在deactivated里做重置而是在activated里发起更新请求在数据回来前先展示缓存内容。这样用户能立刻看到上次的页面数据刷新完再局部更新体验比白屏加载好得多。6.5 需要强制刷新缓存怎么办业务也会有缓存失效的需求比如用户提交详情页之后希望列表页的数据刷新。但列表页被 keep-alive 缓存了回到列表页不会重新执行mounted。我常用的方案是给列表组件加一个key或从缓存名单中移除。比如在详情页提交成功后把列表页的 name 从cachedViews数组里移除组件会立刻被销毁再返回时重新创建并加载新数据。但这个操作要克制别在每次返回时都移除否则缓存就形同虚设了。更好的做法是在列表页的activated里设置一个“需要刷新”的标志位由其他页面通过事件总线或全局状态来触发。列表页先从缓存中读出旧数据再静默获取新数据并更新用户感知不到加载闪断体验最为顺滑。这套动态组件和 keep-alive 的组合拳我在多个项目里实测下来对页面切换体验的提升非常明显。它并不是什么黑魔法核心思路就一句话把昂贵的组件创建销毁过程替换成可复用的缓存激活过程。但也要记住缓存有成本不是开得越满越好。如果你正被页面切换卡顿困扰不妨先从几个高频切换页面开始给它们加上合适的 include 和 max同时用 DevTools 盯一下内存曲线很快就能找到最适合自己项目的平衡点。
返回列表