从热搜词里能看出来,大家搜“Vue 缓存机制”这个关键词时,很大一部分是带着具体问题来的——路由切换后组件状态丢了、表单填写一半被刷新了、页面二次进入白屏了、打包部署后用户还是旧版本。这些问题表面上看是“缓存没生效”或者“缓存乱了”,但根因往往不在同一个层面上。
我这些年用 Vue 做过不少项目,从简单的管理后台到复杂的实时看板都有,踩过的缓存坑也算得上五花八门。这篇就把我的经验和理解做一个系统梳理,按“数据层缓存、组件层缓存、资源层缓存、踩坑排查”这四个维度展开,不绕弯子,直接上干货。
1. 先搞清楚 Vue 里到底有哪几种“缓存”
很多开发者一提到 Vue 缓存,第一反应就是 keep-alive。确实,这是 Vue 框架层面最显眼的一个缓存 API,但它远远不是 Vue 缓存机制的全部。甚至可以说,如果你只把目光盯在 keep-alive 上,那说明还没有建立对缓存体系的整体认知。
我把 Vue 项目里实际涉及的缓存分成四个层面来看:
| 缓存层面 | 典型载体 | 生命周期 | 解决的问题 |
|---|---|---|---|
| 运行时数据缓存 | computed、data、ref/reactive | 组件实例存续期间 | 避免重复计算、保存临时状态 |
| 组件实例缓存 | keep-alive、动态组件 | 随路由/条件切换存续 | 避免重复渲染、保留滚动位置与表单状态 |
| 持久化数据缓存 | localStorage、sessionStorage、IndexedDB | 跨页面会话/跨天 | 登录态、用户偏好、接口数据降级 |
| 静态资源缓存 | HTTP 强缓存、协商缓存、Service Worker | 浏览器会话/长期 | 加速页面加载、离线可用 |
这四个层面的缓存,在日常开发里经常是同时协作的。比如一个列表页,数据从接口拉回来先存到 store 里(运行时数据),页面切换走 keep-alive 保住 DOM 状态(组件实例),用户勾选的筛选条件写到 localStorage(持久化),页面引用的 JS/CSS 走 HTTP 缓存(静态资源)。任何一层出了问题,表现都可能是“页面不对”,但排查路径完全不同。
理解这个分层结构有一个非常实用的价值:当你遇到一个和缓存相关的 Bug 时,先定位它属于哪一层,再对症下药。拿到一个报错或者异常表现,先问“这个状态是存在哪儿的”,比直接搜“Vue keep-alive 失效”要高效得多。
再补充一个容易被忽视的概念——响应式缓存。Vue 3 的响应式系统基于 Proxy,数据读取时会建立依赖,数据变化时会触发更新,这个过程本身也是一层“缓存”:它缓存的是“数据与视图的对应关系”。所以当你用Object.freeze()冻结一个纯展示用的数据源时,Vue 会跳过响应式代理,本质上是在说“这个数据不需要缓存响应关系”,从而获得性能提升。反向理解就是:不该缓存的(不需要响应式的)你硬要缓存,就是在白白浪费内存和代理开销。
这一层搞明白了,下面谈到的 computed、keep-alive、Storage 选型,就都是在回答同一个问题:这份数据/状态,它的生命周期应该和什么东西绑定。
2. computed 与数据层面的“计算缓存”:为什么它比 method 快
很多面试题和基础教程都提到过“computed 有缓存,method 没有缓存”,但如果你只是把这个结论背下来,遇到实际问题还是会犯错。我先说结论,再解释缓存到底是怎么实现的,最后讲什么时候必须打破这个缓存。
2.1 依赖收集机制:computed 缓存能生效的前提
Vue 3 里 computed 的缓存依赖的是响应式系统的“依赖收集 + 脏值标记”机制。当你访问一个 computed 属性时,它内部会做两件事:
- 检查自身的
_dirty标记是否为 true - 如果为 true,重新执行 getter 函数,更新缓存值并把
_dirty置为 false;如果为 false,直接返回上一次的缓存值
那_dirty什么时候会被重新置为 true?答案是:getter 内部读取的响应式依赖发生变化时。Vue 的响应式系统在 getter 执行期间会自动收集依赖,任何一个被读取的 ref/reactive 触发 setter 时,都会通知这个 computed 把_dirty标记重置。
这就解释了为什么 computed 只有在“依赖变化”时才重新计算,而 method 只要被调用就会完整执行一遍。下面这个例子可以直观说明实际性能差异:
const list = ref([{ price: 10, count: 2 }, { price: 20, count: 1 }]); // 计算结果完全一致,但内部逻辑执行次数不同 const total = computed(() => { console.log('computed 执行了'); return list.value.reduce((sum, item) => sum + item.price * item.count, 0); }); function getTotal() { console.log('method 执行了'); return list.value.reduce((sum, item) => sum + item.price * item.count, 0); }模板里放三处{{ total }},只有第一次会打印“computed 执行了”,后面两次直接命中缓存;但三处{{ getTotal() }}会打印三次“method 执行了”。数据量小的时候无所谓,一旦列表有几千条、reduce 的消费函数里还嵌套着复杂逻辑,节省下来的重复计算就非常可观了。
2.2 什么时候 computed 的缓存会失效
只理解了触发机制还不够,还有一个让人容易误解的细节:computed 内部读取的如果不是响应式数据,缓存永远不会失效。
const now = computed(() => Date.now()); const randomNum = computed(() => Math.random());这两个 computed 每次访问返回的都是同一个值——因为Date.now()和Math.random()不是响应式依赖,没有 setter 会来把它们标记为脏。如果你想做“30 秒内不重复请求”这种带时间窗口的缓存,就必须手动管理失效时机,不能指望 computed 帮你自动过期。
我自己在做轮询类业务时,经常需要这样一个“可手动刷新的数据源”:
// 手动管理一个刷新触发器 const refreshFlag = ref(0); // 任意时刻调用 refresh() 即可让相关 computed 重新计算 function refresh() { refreshFlag.value++; } const userProfile = computed(() => { refreshFlag.value; // 建立依赖 return api.getUserProfile(); });这里api.getUserProfile()作为示例,实际开发中请勿在 computed 中直接发异步请求,原因见下文。
2.3 打破“滥用 computed”的三个关键场景
再说三个我经常在代码评审里看到的误用场景。这些场景不是说 computed 有什么问题,而是它的缓存机制和你的业务目标不匹配。
场景一:computed 里发请求。computed 的设计初衷是同步的派生状态。如果在 getter 里发异步请求,返回的初始值是 undefined,之后响应回来你也无法通过赋值“触发”computed 更新——因为它内部没有响应式依赖。规范做法是:用watch监听状态变化后发请求,或者干脆在事件/生命周期里主动调用函数。
场景二:需要“每次进入都重新拉取数据”。有些页面放在 keep-alive 里后,你会发现onMounted只在第一次进入时执行。如果数据拉取是写在onMounted里的,第二次进入页面就看到的是旧数据。不要强行给组件塞一个随机 key 来强制重建,那等于放弃了 keep-alive 的意义。正确的做法是把请求逻辑挪到onActivated里,配合一个“是否首次激活”标记来控制是否静默刷新。
场景三:值相同但引用不同的派生数据。computed 缓存比较的是响应式依赖是否变化,不是返回值的深比较。如果你每次返回一个新数组,即使内容一模一样,只要依赖变了就会触发视图更新。这时候如果你接的是一个 props 监听,很容易造成子组件无意义重渲染。可以考虑在 getter 尾部对返回值做一层 serialize 比对,但——说实话,大部分业务根本不需要做这种过度优化,真到了这一步,你先审视一下数据结构设计是不是有问题。
2.4 从 computed 延伸到 watchOptions 的缓存联动
在处理缓存联动时,watch的flush: 'post'和deep: true选项,其实也承担着一部分“缓存更新调度”的职责。flush: 'post'表示回调会在 DOM 更新之后触发,配合 computed 可以避免频繁同步造成的中间态抖动。而deep: true需要你特别注意:它会让 watcher 深度遍历对象的每一个属性。对象层级深、数据量大时,这里会有明显的性能开销。
我的建议是:能用 computed 的派生关系表达的状态,不用 watch 去同步;能用 watch 监听明确来源的场景,不要用deep: true去扫整个大对象。缓存的代价不仅仅是内存,还有依赖追踪和遍历的开销,这是很多初学者没意识到的。
3. keep-alive 的进阶用法与生命周期配合
keep-alive 是 Vue 内置组件,作用就是缓存“组件实例”。它的原理并不玄妙:当被缓存的组件要被卸载时,Vue 不会真正销毁它,而是把它的 vnode 和实例存到一个缓存对象里;下次需要渲染时,直接从缓存里取出实例,跳过重新创建的过程。
3.1 路由级 keep-alive 的标准姿势
项目里最多见的用法是和 vue-router 配合,按路由 name 来缓存页面组件:
<router-view v-slot="{ Component }"> <keep-alive include="Dashboard,ListPage"> <component :is="Component" :key="route.name" /> </keep-alive> </router-view>这里有两处细节值得展开。
第一,include匹配的是组件定义时的name选项,不是路由的 path,如果你用 Vue 3 的<script setup>,需要额外通过defineOptions({ name: 'ListPage' })或者单独加一个普通<script>块来声明名字:
<script> export default { name: 'ListPage' }; </script> <script setup> // 实际逻辑 </script>第二,:key="route.name"这行很容易被忽略,但它非常关键。假设你有两个路由/detail/1和/detail/2,都渲染同一个DetailPage组件,如果不加这个 key,Vue 会认为它们是同一个组件实例,切换时直接复用,onMounted和onActivated都不会触发,页面内容不会更新。加上 key 后,路由参数变了就相当于两个不同的组件实例,缓存互不干扰。
我在一个电商后台项目里遇到过真实的线上 Bug:用户从订单 A 的详情页跳到订单 B 的详情页,再返回列表,再重新进入,看到的还是订单 A 的信息。排查了半天,问题就出在路由复用同一个组件、却没有加 key 强制区分实例。这个案例后来也成了我评审代码时必查的一个点。
3.2 钩子函数的执行时机:onActivated 与 onDeactivated
一旦被 keep-alive 包裹,组件的生命周期钩子就会多出两个:onActivated和onDeactivated。
- 首次渲染:
onMounted→onActivated - 从缓存中恢复:仅触发
onActivated,不会再走onMounted - 再次离开:触发
onDeactivated,而不是onUnmounted - 缓存被销毁(比如 keep-alive 的 max 超出后淘汰,或组件被显式销毁):才走
onUnmounted
理解这个时序后,有个非常典型的场景:你用一个 Tab 页签风格的工作台,用户在很多个标签页之间切换。你肯定不希望每个标签页每次切换都重新渲染一遍,所以会开启 keep-alive。但是某些页面需要在重新可见时刷新一下数据——比如和用户操作紧密关联的“待办中心”,用户在处理完一条待办后切走了,过一会儿切回来,希望看到的数据是最新的。
如果只在onMounted里写请求逻辑,切回来时完全不触发;如果写在onActivated里,首次和每次恢复都会触发。所以我的做法是拆开来:
onMounted(initData); // 首次创建时的完整初始化 onActivated(() => { if (isInitialEnter.value) { isInitialEnter.value = false; // 首次激活已经由 onMounted 处理 } else { refreshData(); // 非首次进入时做轻量刷新 } });实际项目中,这个“是否需要二次激活刷新”的判断,也不一定非要自己维护标记位。如果你是配合路由详情返回列表的场景,可以用一个“页面级数据版本号”或者前端路由的state参数做精细控制。但尽量不要做“每次激活都全量拉接口”,那样缓存的意义会打折扣,服务端压力也没降下来。
3.3 缓存上限与淘汰策略:max 的陷阱和 LRU 的近似实现
keep-alive 提供了max属性,用来限制缓存实例的最大数量。在源码层面,keep-alive 的缓存淘汰策略是 LRU(Least Recently Used,最近最少使用):当缓存数量超过max时,会淘汰最久没有被访问的那个实例。
<keep-alive :max="10"> <component :is="Component" /> </keep-alive>这个机制本身是好的,能防止无限缓存占用内存。但在实际业务里有两个坑。
坑一:max到底开多大?开太小了,用户来回切换几个大页面,前面的缓存就被挤掉了,每次切回都得重新渲染,体验和数据刷新都变得不可预期;开太大了,有些带图表、大列表、视频播放器的页面实例会吃掉很多内存。我一般建议默认 5 到 10 个,但要看页面里组件的重量级——如果你缓存的是包含 ECharts 实例、WebSocket 连接、甚至 Canvas 动画的页面,“轻轻松松”就能吃到几十 MB 内存。
坑二:keep-alive 是纯前端实例缓存,它不理解你的业务权重。比如在一个进销存系统里,商品列表页是高频使用的核心页面,订单审核页只是一个偶尔看一眼的辅助页面,但对 LRU 来说它们地位平等——如果缓存数量满了,核心页面反而可能因为“有一段时间没访问”而被优先淘汰。真要精细控制,可以动态维护 include 列表,把核心页面常驻,普通页面走 LRU 自然淘汰。这个方案有点重,我通常在核心页面不超过 3 个时才会这么干。
从实践出发,还有一个我自己经常用的技巧:使用 include 时,可以把 include 本身做成响应式数组。比如在用户点击页签关闭按钮时,把该页签对应的组件 name 从 include 数组里移除,再从缓存中 delete 掉对应的 vnode 缓存条目,这样用户下次重新打开页面时,就是一次全新的挂载,不会看到残留的旧状态。这一步需要用 keep-alive 暴露的底层 API 或者从组件实例的 setupState 里找缓存对象,操作起来不够优雅,但业务确实需要“关闭页签并清缓存”的能力时,这是唯一干净的路。
3.4 keep-alive 缓存与异步数据、WebSocket 的冲突处理
再分享一个很容易踩的隐藏坑:被 keep-alive 缓存的组件内部如果存在异步资源,并不会在onDeactivated时自动释放。
一段典型的错误思维是:“我用 keep-alive 缓存了 Dashboard 页面,用户在别的页面待了几个小时后切回来,发现 Dashboard 已经断线了”——这不是 keep-alive 的错,是因为你的 ECharts 实例、WebSocket 连接、定时器根本没有随着页面切走而销毁。
正确处理方式是在onDeactivated里做“轻量的暂停”,在onActivated里做“恢复”:
let timer = null; onMounted(() => { startPolling(); }); onActivated(() => { // 有些场景 onMounted 先于 onActivated 执行,这里需要防止重复启动 if (!timer) startPolling(); }); onDeactivated(() => { clearInterval(timer); timer = null; }); onUnmounted(() => { clearInterval(timer); timer = null; });定时器这样处理没有太大问题,但 WebSocket 比较重。我在一个在线协同编辑项目里,一开始简单地用 keep-alive 缓存了所有文档编辑页,切走时没有及时断开连接,结果一个用户开了三个文档页签,服务端看到三个持久连接,数据库里出现了大量的锁等待。后来单独做了一个连接管理器:文档编辑组件激活时才建立连接,失活时主动断连,只在内存里保存未提交的本地内容片段。本质上这也是“缓存策略就是业务策略”的体现——不能因为你用了 keep-alive,就把组件当成永远不会离开的单页来写。
4. 项目资源层的 HTTP 缓存策略与版本更新的“隐秘角落”
这一层虽然不在 Vue 框架代码里,但 Vue 项目打包部署后,用户访问时慢、更新后还是旧页面,十有八九都是这一层在作怪。很多前端开发只关注 webpack 和 vite 的配置,但对 HTTP 缓存的相互作用缺乏了解。
4.1 强缓存和协商缓存的默认行为
简单回顾一下浏览器缓存机制中的两个关键阶段。
- 强缓存:请求发出前,浏览器直接判断本地缓存是否新鲜,如果新鲜就不发请求,直接读本地内容。服务器通过
Cache-Control: max-age=31536000或Expires来告诉浏览器缓存多久。 - 协商缓存:缓存已过期,浏览器带着
If-None-Match或If-Modified-Since问服务器“我这个版本能不能用”,服务器返回 304 就继续用本地缓存,返回 200 就下载新内容替换。
现代前端打包工具默认生成带 hash 的文件名,本质上就是利用 hash 文件名和强缓存打配合:内容变了文件名就变,文件名没变说明内容一定没变,可以放心用一年。
// Vite 打包后的产物 assets/index-abc123.js // 文件内容 hash 是 abc123,一年强缓存 assets/index-def456.js // 重新构建后 hash 发生变化,浏览器请求新文件这种做法本身没问题,但有个前置条件:入口 HTML 文件不能被强缓存。HTML 是所有资源的“导航页”,如果 index.html 被缓存 10 分钟,那这 10 分钟内后端已经更新上线了,用户拿到的还是旧的 HTML,里面引用的旧 JS 文件名可能已经不存在于服务器上,轻则白屏,重则报一堆加载错误。
4.2 发版后白屏 / 旧页面的经典案例
我遇到过最典型的一个线上事故是这样的:
项目是 vue-cli 构建的,部署在 Nginx 上,打包后 JS/CSS 都是带 hash 的,index.html被误加了Cache-Control: max-age=3600。运营同事在下午 3 点反馈“发布后页面怎么还是老样子”,开发自查后发现接口已经返回了新数据,但页面上的样式和交互都是旧的。
排查链路是这样的:先开 DevTools 看 Network 面板,发现index.html的状态码是 200,但响应头是从缓存里读出来的(from disk cache),说明命中了强缓存。再往下看,HTML 里引用的index-abc123.js已经 404 了——因为服务端只保留了新版本的index-def456.js。结果就是:旧 HTML 引旧 JS,旧 JS 不存在,页面白屏。
修复方案有两层。第一层,Nginx 对index.html做协商缓存配置,让它每次请求都问一下服务器:
location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; }这里的no-cache不是说“不缓存”,它的语义是“使用前必须先向服务器验证是否新鲜”,所以最适合 HTML 这种入口文件。第二层,再配合前端在打包产物名上强制执行 hash 策略,也就是确保静态资源都能用长缓存。
如果你用的是 Nginx,可以参考这样一份基础配置:
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } location / { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }原理就是针对不同路径给不同的缓存策略:资源路径走一年强缓存,页面路径走协商缓存或者不缓存。
4.3 部署中的协商缓存 / 版本不一致排查思路
还有一种情况比较隐蔽:你给 JS/CSS 设置了etag on;和if_modified_since相关配置,但是用户当前访问的 HTML 是旧的,它引用的旧 JS 已经从服务器上删除了。这时候用户刷新多少次都无法恢复,因为强缓存不会问服务器,它直接在本地就把资源用了。
遇到这类“用户那边怎么都是错的,我这边怎么都是好的”问题,我一般按以下顺序排查:
- 让用户硬刷新一次(Ctrl+Shift+R 或 Cmd+Shift+R),确认是否是强缓存导致的。
- 在 Network 面板看
index.html请求的状态码和Size列。如果 Size 列显示(from memory cache)或(from disk cache),说明命中强缓存,继续看响应头里的Cache-Control。 - 用
curl -I https://你的域名/从外部视角看线上 HTML 的实际响应头——这能看到服务器真实返回的策略,绕过本机缓存。 - 对比本地打包产物的 JS 文件名和线上 HTML 引用的 JS 文件名,判断用户是否“带着旧地图找新路”。
处理完线上问题之后,还要在团队规范层面加一道防线:发布前检查 Nginx 配置中的Cache-Control,或者用自动化脚本在发版后去 headless 浏览器里加载一次首页,检查是否存在 404 资源。
4.4 Service Worker 的缓存坑
如果你用的是 PWA 或者自己接入了 Service Worker,那缓存又是另一个层级:Service Worker 可以完全绕过 HTTP 缓存,把资源存在 Cache Storage 里。它最大的坑是——SW 更新策略和页面更新不同步。
我在一个内部工具项目里,因为加了 Service Worker,导致每次发版后总有那么几个用户看到的还是旧版本,甚至新版本已经覆盖了 SW,页面还是要等第二次刷新才正常。
后来把 SW 更新策略改成了clientsClaim+skipWaiting,在install阶段主动清理旧缓存,并在页面里监听updatefound事件做提示:
// service-worker.js self.addEventListener('install', (event) => { self.skipWaiting(); event.waitUntil( caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key))) ) ); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); // 页面内监听更新 navigator.serviceWorker?.addEventListener('controllerchange', () => { window.location.reload(); });如果你没有迫切的离线需求,尤其是内部管理后台,建议默认不引入 Service Worker。因为它带来的缓存复杂度,远比那一点性能提升要麻烦得多。
5. 持久化缓存方案选型:localStorage、sessionStorage、IndexedDB 的边界
组件缓存和 HTTP 缓存讨论完了,再来看另一个大方向:数据持久化缓存。同一份数据,如果 A 页面缓存起来 B 页面要用,或者刷新后还要用,就必须落到浏览器存储里。
5.1 选型依据和简单对比
| 存储方案 | 容量约 | 同步/异步 | 生命周期 | 典型场景 |
|---|---|---|---|---|
| localStorage | 5MB 左右 | 同步 | 永久 | 用户偏好、登录态 token(实际上要谨慎)、低频变化配置 |
| sessionStorage | 5MB 左右 | 同步 | 标签页关闭即失 | 表单草稿、页面间临时传参 |
| IndexedDB | 几百 MB 甚至更多 | 异步 | 永久 | 大列表数据缓存、离线数据、二进制文件 |
| Cookie | 4KB | 同步 | 手动设置过期 | 频繁由服务端读写的少量标识,如 session id |
这里需要特别提醒的一点是:localStorage 虽然容量小,但它的 API 是同步阻塞的。如果你在遍历一个长数组时频繁调用localStorage.getItem,会对主线程产生可感知的卡顿。大数据量场景尽量用 IndexedDB,异步操作完全不影响 UI。
Vue 项目里,很多人习惯用 Vuex/Pinia 做持久化插件,比如给 store 里加一个 subscribe,每次状态变化就全部写入 localStorage。这个做法在小项目里很省事,但是:
- 每次 state 变化都写整个 JSON 序列化结果,成本比较高;
- 如果你把一个很大的列表也放在 store 里并做全量持久化,页面会无端变慢;
- 多个标签页同时对同一个 key 做读写,会互相覆盖。
比较好的做法是给持久化插件加白名单机制,只持久化真正需要跨会话保存的模块:
// Pinia 持久化示例 export const useUserStore = defineStore('user', { state: () => ({ token: '', profile: null, permissionRoutes: [] }), persist: { key: 'user-store', // 只持久化这两个字段,其他不落盘 paths: ['token', 'profile'], storage: localStorage } });更精细的做法是给每个需要落盘的字段设置版本号,这个在实际项目里往往不是必须的,毕竟敏感数据最好不要落 localStorage。
5.2 响应式数据与 Storage 同步陷阱:多标签页一致性
当你在一个系统里同时开着多个标签页——比如一个采购单在左边的 tab 列表页,右边是详情页浮层,用户又开了一个新的浏览器标签页做其他操作——不同标签页之间要分享登录状态、最新数据,localStorage 会触发storage事件,但 Vue 内部的响应式状态是各自标签页独立的。
如果你在页面上监听storage事件来同步 token,有一个很常见的坑:该事件只在别的标签页中触发,当前页不会触发。也就是 A 标签页里改了 localStorage,B 标签页能收到事件,而 A 标签页自己收不到。
处理跨标签页同步的正确姿势是让所有页面都听同一个广播源:
// 登录成功时主动广播 window.localStorage.setItem('auth-event', Date.now().toString()); // 在其他页签监听变化 window.addEventListener('storage', (event) => { if (event.key === 'auth-event') { // 重新读取 token 或触发用户信息刷新 refreshUserInfo(); } });这里的原理是,storage事件在跨文档会话中,只要 key 变化就会触发,即使你没有直接修改业务数据,也可以用一个时间戳打个“响指”,让其他页签感知到“数据版本变了”。
这个场景在 Vue 项目里还经常演化出一个问题:用户在一个页签中退出登录,切回另一个页签时发现页面还停留在登录态,点击按钮才报 401。我的建议是封装一个全局的 auth 同步模块,统一使用上面的广播模式,在收到退出事件时清理 Pinia 状态并跳转登录页。
5.3 加密、敏感信息与缓存的安全边界
最后说一下安全相关的话题。很多团队习惯把 JWT token 放在 localStorage 里,因为取用方便——拦截器里localStorage.getItem('token')就能拿到——但这样做的风险是任何页面脚本 XSS 后都能直接窃取这个 token。
没有银弹替代方案。用 HttpOnly Cookie 存 token 能防脚本读取,但需要处理 CSRF 防护;而且如果你的前端是纯前后端分离、接口域名和前端域名不一样,Cookie 的跨域设置又是个大坑。
我的底线建议是:
- 不把身份证号、手机号、地址等敏感个人信息明文存 localStorage;
- token 和用户信息分开存,token 设短过期时间,用户信息里不要存敏感字段;
- 如果项目对安全要求较高,优先使用 HttpOnly Cookie + CSRF Token 的方案,而不是简单把 token 存 localStorage。
缓存是把双刃剑,它带来的便利和它引入的风险往往成正比。
6. 缓存引发的典型疑难杂症:排查链路与解决思路
这里我把自己这些年遇到过的缓存相关 Bug 整理成一个“问题库”。每一个都是真实场景,处理方式也可以直接复制到你的排查流程里。
6.1 列表页二次进入白屏 / 空白问题
症状:用户首次进入某列表页一切正常,离开后再次进入,页面白屏;或者某些路由反复切换后组件不渲染。
这类问题有几种常见诱因,我把排查链路写在这里:
- 优先确认是否命中 keep-alive 并有组件未正确注册 name。
- 检查列表页内部是否使用了过多全局状态,二次进入时 store 里初值可能是空数组,而组件依赖了一个基于空数组计算的空状态。
- 检查 v-for 是否使用了不稳定的 index 作为 key,组件复用时 DOM 状态被错误复用。
一个比较典型的场景是“地图二次进入空白”。热搜词里有一条“vue 加载百度地图首次打开正常,第二次一片空白”,这个在开发群里被问烂了。根因通常是地图实例初始化需要挂载到真实 DOM 节点,如果组件被 keep-alive 缓存、DOM 被移出但是 JS 里地图实例还持有旧容器引用,二次插入时地图无法正确渲染。常规解法有两种:一是渲染地图的组件不要放进 keep-alive 缓存名单;二是地图在触发渲染后手动调用map.resize()或重新初始化。
6.2 表单数据残留问题
症状:一个填写步骤很多的表单向导,用户走到第三步切去别的页面,回来之后第三部的校验状态还在,但是数据被重置了。
这个问题的根因通常是:组件缓存与校验库内部状态不同步。比如你用 keep-alive 缓存了表单组件,Element Plus / Ant Design Vue 的 Form 组件内部会维护自己的字段值与校验结果。如果你的表单初始值是从异步接口拉取的,第二次激活时onActivated再次赋值,但校验字段的历史状态还在。
我的建议是针对“需要频繁编辑并切走”的长表单,不要用 keep-alive,而是用“草稿自动保存”来实现恢复效果。也就是切走时把表单数据保存到 sessionStorage 或 Pinia,再回来时一键回填。这样的体验比 keep-alive 缓存还要好——因为浏览器刷新后也能恢复。
// 表单离开前自动保存草稿 onDeactivated(() => { sessionStorage.setItem('draft-order-form', JSON.stringify(form.value)); }); onActivated(() => { const draft = sessionStorage.getItem('draft-order-form'); if (draft) { Object.assign(form.value, JSON.parse(draft)); } });6.3 动态路由 / 权限切换后缓存未清理
症状:用户 A 是管理员,登录后缓存了所有路由页面;管理员退出后换一个普通用户 B 登录,打开的页面却还带着管理员菜单的数据权限。如果是前后端分离的权限路由方案,这个问题特别容易在 keep-alive 的路由缓存上踩雷。
根因:路由页面的 include 缓存缓存的不是“页面”而是“组件实例”,实例内部那些受权限控制的按钮、数据请求逻辑,在实例被复用时会持有上一次用户的数据。
解决方案:用户角色变更/退出登录时,用广播事件强制清理所有 keep-alive 缓存。
// 用户状态变化时,重置全局缓存 function resetPageCaches() { localStorage.removeItem('user-store'); sessionStorage.clear(); // 触发全页面路由刷新 window.dispatchEvent(new Event('auth-changed')); } // 在路由入口监听 auth-changed 事件,重新初始化如果项目里接入了动态路由 addRoute,退出登录时必须先把动态添加的路由全部移除,再重置 include 缓存。这一步漏掉的话,下一个登录用户可能会因为路由表叠加而进入错误页面。
6.4 接口数据被浏览器缓存导致的“新数据不显示”
这不算 Vue 自身的问题,但前后端配合不好会浪费大量时间。
场景:切换用户后,头像、昵称还是上一个用户的。排查后确认 Vue 状态是新的,但接口返回的数据结构里有些字段被 HTTP 缓存了。
解决方法是给接口请求统一加一个“防缓存”的 headers,或者在 GET 请求 URL 上拼接时间戳参数。如果你们有完整的请求封装,在拦截器里统一处理最干净:
const service = axios.create({ timeout: 10000 }); // 请求拦截器:过滤 GET 请求的缓存策略 service.interceptors.request.use((config) => { if (config.method === 'get') { config.headers['Cache-Control'] = 'no-cache'; config.headers['Pragma'] = 'no-cache'; } return config; });但这里的防缓存只适合用户状态相关的动态请求,不要给所有 GET 都加这层处理,那样会降低真正常态数据(比如下拉列表字典)的访问速度。好的做法是维护一个“可缓存请求名单”和“禁止缓存请求名单”。
6.5 “缓存的初衷”大讨论:什么时候不该用缓存
不是所有数据都需要缓存。缓存是有代价的,使用它会引入一致性问题——数据可能变旧、内存可能上升、扩展可能变难。下面这几种情况我会主动避免使用组件或持久化缓存:
- 涉及金融交易的金额、订单状态等页面,任何“旧数据”都可能导致用户误操作;
- 实时性要求高的消息列表、物流追踪、K 线图页面;
- 包含大量不宜占内存的图表实例页面,如果必须保留视图状态,优先做状态快照而不是保留整个组件实例。
“不缓存”本身是一种缓存策略,而且在很多时候是最优策略。
7. 缓存运维与可观测性:线上怎么感知缓存异常
这部分说得直白点:问题没发生的时候,缓存机制是透明的,但问题一旦发生,你需要能在最短时间内知道它发生在哪一层。
我推荐在每个 Vue 项目的前端基础库里增加一个“缓存状态面板”的开发调试工具,只在非生产环境开启,可以展示:
- 当前 keep-alive 缓存了哪些组件实例(从组件实例的 $vnode.parent.component.ctx 相关位置读缓存队列);
- 各缓存实例占用的估算资源大小;
- localStorage 各 key 的最近写入时间;
- 当前缓存版本号(每次发版手动递增)。
生产环境不适合把这些面板直接暴露,但需要在构建时把缓存版本号注入到全局变量,配合上报异常。
// 构建时注入版本信息 const appVersion = __APP_VERSION__; // localStorage 中的缓存版本与本版本不一致时,清理旧缓存 const CACHE_VERSION_KEY = 'app-cache-version'; const localVersion = localStorage.getItem(CACHE_VERSION_KEY); if (localVersion !== appVersion) { // 发版后自动清理可能过期的本地缓存 Object.keys(localStorage) .filter((key) => key.startsWith('app-cache:') || key.startsWith('user-')) .forEach((key) => localStorage.removeItem(key)); localStorage.setItem(CACHE_VERSION_KEY, appVersion); }这套做法的核心思想是:把缓存当作有生命周期的资源来管理,它不是你写完就不管的“黑盒”。
再聊一个调试 keep-alive 的小技巧。打开 Vue Devtools,切到组件树,如果某个组件实例上出现了<KeepAlive>包裹的元素,你可以看到它在 inactive 和 active 两个状态之间切换。Vue Devtools 的 Timeline 面板里也能看到组件被缓存和激活的事件流。如果组件反复重建,说明 include 配置可能没匹配上——这时候要检查 name 是否和数组中的值完全相等,注意大小写。
我还习惯在关键的缓存操作里打“性能标记”:
performance.mark('cache-hit-start'); // 读取某个缓存 performance.mark('cache-hit-end'); performance.measure('cache-hit-cost', 'cache-hit-start', 'cache-hit-end');通过 Performance 面板的记录,可以对比命中缓存和未命中缓存时页面渲染的耗时差距,用数据说服产品经理“这个页面值得做缓存”或者“这个缓存没有收益该移除”。
8. 我自己的一点体会
写了这么多,最后想聊聊关于缓存问题的几个核心思维。
第一,在 Vue 里边讨论缓存,本质上是讨论状态的生命周期管理。每个状态从产生、消费、失效到销毁,应该有一个清晰可预期的路径。无论是 computed 里的依赖标记、keep-alive 的实例缓存,还是 localStorage 的持久化,都只是让某个状态活得更久的手段,不能因为用起来方便就把所有状态都一股脑缓存到底。
第二,在处理缓存问题时,定位“谁弄脏了数据”比“怎么清缓存”更重要。缓存只是把问题延后,如果不搞清楚数据是何时、被谁改成旧值的,下一次你还会遇到同样的 Bug。我见过很多次有人因为页面出现旧数据就先清了 localStorage,结果清了之后用户数据丢了,问题依旧复现——因为根因根本是 HTTP 协商缓存策略配错了。
第三,缓存的方案要写在开发之前,不要等踩坑之后再来补救。一个列表页要不要被 keep-alive、编辑表单要不要草稿保存、token 放在哪个存储位置、打包后 HTML 是否允许缓存——这些决策应该在架构评审或者项目启动时就定下来,并且写进团队的开发规范里。如果一个项目里每个人各自为政,有人用 keep-alive 有人不用,localStorage 的 key 命名乱起,出了线上问题真的会消耗大量时间去排查。
最后分享一个我自己的小习惯:每接手一个新项目,第一件事不是在 IDE 里读业务代码,而是先按浏览器 DevTools 的 Application 面板把存储资源过一遍。localStorage 里有哪些关键 key、Cache Storage 里存了哪些资源、Service Worker 是否需要更新,都做到心里有数。把缓存当成代码一样去 review,很多本可能出现在线上的诡异问题,在开发阶段就已经被排掉了。