
做Nuxt项目做到一定阶段网络请求缓存几乎是绕不开的一道坎。SSR框架天然要面对同一个接口服务端发一次、客户端又发一次的双重状态加上多个页面共用同一批数据线上接口的峰值流量很快就会兜不住。这篇文章把我自己在Nuxt项目里沉淀下来的请求缓存方案完整拆开第一层在框架内置的数据获取机制里做文章第二层在封装的请求函数里加TTL和LRU第三层下沉到Nitro服务端缓存中间还会穿插实际项目里遇到的缓存失效、穿透和数据一致性案例。适合正在用Nuxt 3做SSR项目、并且已经开始在意接口耗时和上游压力的人参考。1. 为什么需要三层缓存先看清重复请求都发生在哪在动手写缓存之前先把需求场景看清楚很重要。很多人在Nuxt项目里一上来就套用Vue SPA时代的全局请求拦截器内存缓存方案结果发现SSR场景下根本对不上。我的经验是先把三类重复请求来源抓出来再决定哪一层缓存解决哪个问题。1.1 客户端组件重复挂载带来的重复请求Nuxt的页面组件和Vue SPA一样有一个容易被忽略的生命周期问题用户从列表页跳到详情页再返回列表页时列表组件并没有被真正缓存而是重新挂载、重新执行setup。如果你的列表数据是在setup里直接useFetch请求的那么每一次返回都会重新请求一次接口。这在本地开发时几乎感知不到因为接口快、数据量小但到了线上、接口延迟到了200ms以上这个问题会被放大成白屏等待和服务器压力。常见做法是使用keep-alive或者pinia把数据提到全局store里这两个方向都可行但都有代价keep-alive需要处理组件激活/失活的钩子store缓存则要自己管理数据和请求状态写起来并不比缓存请求本身省事。更好的思路是直接从请求这一层做缓存让组件挂载多少次都先读缓存。1.2 服务端渲染与客户端水合之间的请求断层这是Nuxt项目里最容易被忽视的层面。SSR时服务端会在渲染过程中执行useFetch并真实请求一次上游接口然后把结果序列化到payload里传给浏览器端。如果你不留意浏览器端的水合阶段是有可能再次请求同一个接口的——特别是当你把请求逻辑放在非组件上下文里比如store的action、Composable的函数里时payload机制帮不上忙客户端根本拿不到服务端请求过的数据于是只能重新请求。我一开始在pinia store里用$fetch请求用户信息SSR完客户端照样稀里哗啦地重新请求查payload才发现里面根本没有这个请求的数据。后来把所有store类请求改成用useAsyncData包一层并且在组件里调用才把水合后的重复请求消掉。这算是一层隐形的缓存不配置任何东西只要请求位置对Nuxt自动帮你接力。1.3 上游接口本身的响应耗时第三个重复点不在Nuxt自身而是上游服务的稳定性。前后端分离的项目里上游接口响应在500ms到1s之间是常态如果多个页面都依赖同一个基础数据比如用户信息、配置项、商品类目树每进一次页面都真实打到上游接口直接变成性能瓶颈。这一层问题和用户当前浏览的页面无关纯粹是无效流量太多需要一层跨用户的共享缓存来挡。基于这三点我后来把缓存分成了三层每层解决一个场景框架内置机制解决页面内和页面间的数据接力自定义请求层解决非组件场景和精细TTL控制服务端缓存解决跨用户的上游压力。这篇文章就是按这三层展开的。2. 第一层useFetch与useAsyncData内置机制怎么帮你省请求Nuxt 3把数据获取封装成了useFetch和useAsyncData这两兄弟自带了一些缓存相关的机制但官方文档写得很分散实际用起来有几个点值得单独讲。2.1 key机制的自动去重useFetch的入参是URL加选项Nuxt会把这两者哈希成一个key。在同一个Nuxt应用进程里同一时间内使用相同key的多个调用会被合并成一次真实请求这就是框架自带的并发去重。举一个我项目里的例子详情页里有三个区块都需要商品信息三个组件各自写了一个useFetch(/api/product/123)如果没有去重并发渲染时上游会被连打三次。有了key机制这次请求只会发一次三个组件拿到同一份数据。这个去重的粒度是按当前请求周期来的组件卸载后缓存即失效所以它只能解决并发重复解决不了返回页面再次进入的重复。要处理后者要么用keep-alive要么用下文会讲的getCachedData。2.2 payload提取SSR到客户端的数据接力框架更值钱的一个能力是payload传递。SSR时请求的数据会带着key一起塞进window.__NUXT__客户端水合时useFetch会优先从payload里按key取数据取到了就不再请求接口。这个机制最大的意义不是快而是让服务端渲染出的HTML和客户端水合时的数据状态保持一致避免闪烁和二次请求。有一个容易被忽略的前提请求必须在组件或页面上下文中执行这样Nuxt的payload序列化和传递机制才能生效。如果请求逻辑在eventHandler、store或普通函数里直接调用数据就进不了payload。我踩过这个坑之后定了一条规矩凡是需要SSR首屏数据的请求一律放到组件script setup里用useFetch或useAsyncData执行store里的$fetch只留给客户端交互触发的场景。2.3 getCachedData显式缓存与刷新时机Nuxt 3.8以后提供了getCachedData选项可以显式返回一份缓存数据让组件在客户端导航时先用缓存渲染、再后台刷新。这是最接近SWR形态的官方方案值得优先用。我的用法是把缓存放在一个模块级Map里const dataCache new Map() const { data, refresh } await useFetch(/api/products, { getCachedData(key) { if (import.meta.server) return undefined return dataCache.get(key) } })getCachedData返回数据后页面会先用缓存渲染如果想在适当时机拿新数据手动调用refresh()即可。需要注意的坑是getCachedData返回的数据不会自动触发重新验证所以设置合适的TTL或者在合适的用户交互里调用refresh是必须的。另外useFetch官方建议key自定义成可读性强的字符串便于多人协作时代码排查目录式的key比如product:detail:${id}比默认哈希更适合接缓存模块。3. 第二层自定义请求缓存的封装实践框架内置机制在组件内很顺手但真实项目里大部分请求不在组件里而是集中在store、业务函数和工具模块中。到了这个层面我会封一个带TTL的请求缓存函数。3.1 为什么还要自己造一层useFetch在组件外没法直接使用——组件外的$fetch不会进入payload也就享受不到框架的SSR数据接力。这时候你需要一套独立于页面生命周期的缓存规则。我自己的判断标准是如果一个接口的数据在5分钟内基本不会变又会被多个页面/多个操作调用那它就值得走自定义缓存如果数据实时性要求高比如库存、支付状态就不加或者只加很短的TTL。自己封装还有一个额外好处可以统一控制缓存容量避免模块级Map无限增长。毕竟浏览器内存不是无限的一个长时间单页会话的用户如果在不断翻不同商品详情Map会越塞越多。3.2 带TTL的Map缓存封装我在composables/useCachedFetch.ts里封装了一个轻量版本核心逻辑是一个Map加一个过期时间戳interface CacheEntryT { data: T expireAt: number } const memoryCache new Mapstring, CacheEntryunknown() const MAX_CACHE_SIZE 500 export function getCacheItemT(key: string): T | null { const entry memoryCache.get(key) if (!entry) return null if (entry.expireAt Date.now()) { memoryCache.delete(key) return null } return entry.data as T } export function setCacheItemT(key: string, data: T, ttlMs: number) { if (memoryCache.size MAX_CACHE_SIZE) { // 简单策略删除最早写入的key const oldestKey memoryCache.keys().next().value if (oldestKey) memoryCache.delete(oldestKey) } memoryCache.set(key, { data, expireAt: Date.now() ttlMs }) } export async function cachedRequestT( key: string, request: () PromiseT, ttlMs 5 * 60 * 1000 ): PromiseT { const cached getCacheItemT(key) if (cached ! null) return cached const data await request() setCacheItem(key, data, ttlMs) return data }这段代码的核心不复杂真正复杂的是SSR期间缓存写在哪、客户端读不读得到。我的处理是把这段代码放进composables目录公共使用虽然模块级Map在服务端每个worker里是独立的但配合后面的Nitro缓存层请求在服务端有兜底。客户端内存Map则负责用户操作产生的数据复用。3.3 非组件场景下怎么结合响应式如果只是接口数据复用上面的工具函数够了。但页面还需要感知请求中/请求完成/出错这些状态所以我通常在store里把请求状态和缓存数据放一起// store/user.ts import { defineStore } from pinia import { cachedRequest } from ~/composables/useCachedFetch export const useUserStore defineStore(user, () { const profile refUserProfile | null(null) const loading ref(false) async function fetchProfile(force false) { if (profile.value !force) return profile.value loading.value true try { profile.value await cachedRequest( user:profile, () $fetch(/api/user/profile), 60 * 1000 ) return profile.value } finally { loading.value false } } return { profile, loading, fetchProfile } })注意这里的坑store里的函数如果挂在服务端执行cachedRequest的Map在服务端内存里多个worker之间数据不共享高频场景需要借助Redis见第4节。另外如果页面里有多个组件同时调用fetchProfilecachedRequest只能保证接口只打一次但没有暴露进行中的状态所以我在store里单独维护了loading。3.4 如果不想自己写轮子TanStack Query如果团队愿意引入第三方库TanStack Queryvue-query是另一个稳定选择。它自带五种状态、staleTime、gcTime、缓存管理器还支持devtools调试。在Nuxt里接入思路是在plugins目录安装tanstack/vue-query然后在组件里用useQuery替代手写请求。它的缓存键是字符串天然支持自定义刷新时机通过staleTime控制比我自己维护Map更通用。不过要注意的是SSR场景下需要显式把QueryClient实例传给客户端否则水合后状态对不上。具体做法是在plugins/vue-query.ts里创建一个单例并通过useState共享export default defineNuxtPlugin((nuxtApp) { const queryClient new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, gcTime: 5 * 60 * 1000 } } }) nuxtApp.vueApp.use(VueQueryPlugin, { queryClient }) })这套方案的收益是省掉了自己维护缓存Map的精力代价是引入依赖和API风格的学习成本。小项目手写足够大型团队建议走Query。4. 第三层Nitro服务端缓存与Redis接入客户端缓存能大幅减少单个用户重复请求但不同用户之间的请求还是会穿透到上游。这一层需要下沉到Nitro服务端它和Nux页面代码是同一套工程配置起来并不复杂。4.1 routeRules一行配置的SWR缓存我最先用的是Nuxt的routeRules在nuxt.config.ts里给接口路由配置缓存routeRules: { /api/products/**: { cache: { maxAge: 120, swr: true } }, /api/config: { cache: { maxAge: 300, swr: true } } }这种方式对GET类接口最方便一行配置不用改业务代码。它的底层是Nitro内置的swr存储按请求路径和查询参数做key第一次请求后缓存响应后续在maxAge内直接返回缓存响应并且异步重新验证。用的时候要注意cache规则是基于Nitro应用内建的进程重启缓存就没了响应里的Set-Cookie、用户态接口千万不能配置这条规则因为缓存是全局共享的一旦把某个用户的数据缓进去其他用户就拿错了。我一般只对不需要登录的公共接口使用并且要和前后端同事确认响应不包含个性化内容。4.2 在自定义API处理器里手动控制缓存页面级缓存粒度太粗接口内部如果依赖外部透传参数建议在server/api处理器里手动读缓存。我做过一个电商类项目商品列表接口内部要拼多个上游数据还有复杂的权限过滤就不能粗暴整页缓存。处理方案是在server端封装一个缓存工具// server/utils/requestCache.ts import { useStorage } from #imports export async function cachedServerRequestT( key: string, handler: () PromiseT, ttlMs: number ): PromiseT { const cache useStorage(cache) const hit await cache.getItem{ data: T; expireAt: number }(key) if (hit hit.expireAt Date.now()) { return hit.data } const data await handler() await cache.setItem(key, { data, expireAt: Date.now() ttlMs }) return data }然后在接口处理器里用它包住真正取数的逻辑// server/api/products/index.get.ts export default defineEventHandler(async (event) { const query getQuery(event) const cacheKey product:list:${query.category ?? all}:${query.page ?? 1} return cachedServerRequest( cacheKey, async () { const upstream await $fetch(http://upstream/products, { query }) return upstream }, 2 * 60 * 1000 ) })useStorage(cache)默认落盘到本地文件系统生产环境我会把它换成Redis或者借助CDN做前置缓存。4.3 接入Redis作为共享存储多实例部署时本机文件缓存只能保证单机。我在线上环境通常是Nginx前面挂CDNNuxt服务容器里配置Redis驱动。Nitro支持通过nitroConfig覆盖存储驱动nitro: { storage: { cache: { driver: redis, url: process.env.REDIS_URL } } }Redis统一之后缓存穿透的问题也会集中出来配合上一节的手动封装我一般会在缓存工具里加空值缓存逻辑当handler返回null或undefined时也写入一个短期比如30秒的占位缓存避免大量请求同时打到上游。有同学会问routeRules的缓存能用Redis吗可以但需要配置storage里的cache驱动指向Redisswr行为才会跨实例共享。5. 缓存失效、穿透与一致性的实战处理缓存带来性能提升的同时也在制造新的问题数据更新之后怎么让缓存失效、热点key击穿、过期时间设置不合理导致的脏数据。这一节单独讲算是这套方案的安全护栏。5.1 数据更新后主动让缓存失效最朴素也最好用的方式是写操作后删除缓存。比如后台改商品信息后在提交接口成功之后调用useStorage(cache).removeItem(key)。这个逻辑在server端做客户端无需感知。如果有多实例发布时需要走Redis的DEL命令并且所有实例都要能访问同一个Redis所以跨实例时必须用共享存储不能依赖本地文件。对客户端内存Map我采用两个规避手段一是TTL不要设得太长二是提供一个刷新入口。用户相关的数据头像、昵称TTL只给30秒页面展示类数据给2到5分钟这样就算有脏数据也在可接受范围内。如果业务上绝对不能接受脏数据那这个接口就不应该被缓存或者需要引入版本号机制写操作后递增版本读请求带着版本号对比缓存。5.2 穿透、击穿与雪崩的简化处理穿透请求的key在缓存和上游都不存在每次都打上游。做法就是上面提到的空值缓存同时限制非法参数尽量在Nginx或server层直接拦截。例如请求一个不存在的商品ID缓存里没有上游也没有如果参数可以被恶意遍历穿透压力会很大。击穿热点key在过期瞬间有大量并发请求同时打到上游。小项目通常用Promise去重就够const inflight new Mapstring, Promiseunknown() export async function cachedRequestWithLockT( key: string, request: () PromiseT, ttlMs: number ): PromiseT { const cached getCacheItemT(key) if (cached ! null) return cached if (!inflight.has(key)) { const promise request() .then((data) { setCacheItem(key, data, ttlMs) return data }) .finally(() { inflight.delete(key) }) inflight.set(key, promise) } return inflight.get(key) as PromiseT }雪崩是大量key在同一时间过期我给的方案是过期时间加随机偏移量比如ttl Math.floor(Math.random() * 30) * 1000这样不会产生同一秒集体失效的峰值。5.3 缓存key的设计与粒度缓存key是容易踩坑的地方。我的命名规范是业务域:资源:标识符:参数摘要比如product:list:category-home-v1。要包含参数摘要但不要塞完整URL参数太长可以先hash。另外分页请求的缓存设计要小心我倾向于把列表第一页和搜索关键词两类数据做短TTL而详情页类数据做长TTL排序靠用户行为的页面基本不缓存。对应到routeRules路径级别的通配符已经隐含了key设计/api/products/**会以完整URL作为key的一部分。而自定义缓存里key完全由你控制这给了更大的灵活度也给了更高的犯错空间。我见过有人把用户ID拼进缓存key导致缓存完全失效的也见过有人反过来不加用户维度导致串数据的。这条建议写进项目文档里比靠人肉记要靠谱得多。6. 实测效果与过程中的踩坑记录最后交代一下实际项目的数据变化。一个电商中台项目上线这套方案后最直观的变化是首页接口返回从平均400ms降到80ms以内压测时上游服务端压力降低了约70%。当然这个数字和业务形态强相关仅供参考真正想说的是我过程中踩过的几个坑。第一坑只做客户端缓存没做服务端缓存。前期优化完水合后单个用户重复请求少了但并发一上来上游还是被同一批基础数据打爆。后来补上Nitro层和Redis才算真正兜住。所以我的建议是客户端缓存负责体验服务端缓存负责压力两者配合而不是互相替代。第二坑routeRules配在带cookie的接口上用户B拿到了用户A的缓存数据上线半小时就发现了。排查时是通过响应头里多出来的缓存标识发现的经验是每次调整缓存策略后先用两个不同的浏览器账号验证一遍再用curl带不同header打一次接口确认响应隔离没问题。第三坑滥用getCachedData不做刷新。缓存让页面数据看着好看但实际上一直显示的是旧数据用户反馈我保存了但页面上没变化。后来给每个缓存项加了版本号写操作后递增版本getCachedData里比较版本号不匹配就返回undefined强制走新请求。这个方案比盲目调短TTL更精确。如果你准备在自己的项目里落地这套缓存方案我的建议是先把三次重复请求来源梳理清楚再决定是哪一层缓存真正解决问题服务端缓存始终是兜底客户端缓存负责体验两者互补最后缓存相关的key、TTL和刷新规则一定要写在项目文档里因为三个月后回来看代码的人大概率不是你而是别人。