前端请求缓存写了,同一个接口为什么还是打了十几次?

打开某个业务页,DevTools → Network 一刷新:同一个「拉全量选项」的接口连着出现十几次。参数几乎一样,响应也几乎一样。第一反应通常是:「谁又在乱请求?」

顺着调用链往下翻,却常常发现更尴尬的事实------调用方都在走同一个带缓存的 Store 方法。缓存写了,请求还是炸了。这篇文章讲这类问题怎么排查,以及一个只改十几行、却能管住整站的修法。


页面在干什么

中后台里很常见:搜索区有几个下拉(部门、分类、区域......),页面底部又常驻挂着一堆业务弹窗------新建、编辑、流转......弹窗里嵌着同样的选择器。

公共选择器的写法也很常见:挂载时自己拉数。

js 复制代码
onMounted(() => {
  getOptions()
})

const getOptions = async () => {
  const list = await shares.getSharedList()
  options.value = toTree(list)
}

父页面不必关心数据从哪来,选择器可复用,看起来很干净。问题也藏在这份「干净」里:首屏一挂载,选择器实例的数量,就是潜在请求次数的上限。


「我们明明有缓存」

Store 里已经做过「缓存为空才发请求」:

js 复制代码
const getSharedList = async () => {
  if (sharedList.value.length == 0) {
    const res = await axios.get('/api/xxx/list-all')
    sharedList.value = res.data.data
  }
  return sharedList.value
}

直觉上这很合理:第一次拉,之后复用。线上却不买账------首屏仍然 N 次并发。

关键不在「有没有缓存」,而在缓存生效的时机

把时间线摊开:

  1. Select_1 进入方法,length === 0,发起请求 A
  2. Select_2 几乎同时进入,请求 A 还没回来,length 仍是 0,发起请求 B
  3. ......直到 Select_N
  4. 若干毫秒后,请求陆续返回,缓存终于有值
  5. 之后的调用才真正命中缓存

也就是说:if (length == 0) 只能挡住已经写完缓存之后的调用,挡不住**飞行中(in-flight)**的并发。这不是 Vue、也不是 Pinia 的锅,是异步世界里经典的竞态。

还有一个加重项:个别业务组件绕过 Store,自己再 axios 一遍同一接口。Store 缓存再正确,也护不住这条旁路。


容易走偏的三种改法

排查时,桌上通常会摆出这些方案:

1. 父页面统一请求,props 往下传

能治,但要改所有选择器和所有使用方,和「选择器自取数」的既有模式对着干,改动面大。

2. 弹窗全部改成 v-if 懒挂载

首屏更轻,是好优化,却不是根因解。而且要处理 ref.open() 一类时序,一不小心就变成「点了没反应」。

3. 给选择器加本地防抖 / 本地缓存

每个实例一份本地状态,挡不住「多个实例同时首启」。

真正该动的是:共享数据入口本身,要能处理并发。


修法:In-flight Promise 单飞

思路很老,也很稳------single-flight:同一时刻,同一资源,只允许一架飞机起飞;后来的乘客都坐这一架。

js 复制代码
let sharedListPromise = null

const getSharedList = async () => {
  if (sharedList.value.length > 0) {
    return sharedList.value
  }

  if (!sharedListPromise) {
    sharedListPromise = (async () => {
      const res = await axios.get('/api/xxx/list-all')
      if (res.data.code === 0) {
        sharedList.value = res.data.data
      }
      return sharedList.value
    })().finally(() => {
      sharedListPromise = null // 无论成败都释放锁;失败时下次可再请求,成功时已有缓存不会再进这里
    })
  }

  return sharedListPromise
}

三层判断各管一段:

条件 行为
已有缓存 同步返回,零请求
请求飞行中 复用同一个 Promise
都没有 发起唯一一次请求

组件侧不用改 :仍然 onMountedgetSharedList()。多个选择器一起喊,Store 只放行一枪。

同一模式套到其它共享列表后,Network 里对应接口从「刷屏」变成「各一次」。旁路直调的组件,也改回走 Store,避免再开小灶。


为什么这个改动划算

  • 改动集中:修 Store 一处,全站同类选择器一起受益。
  • 不破坏现有组件契约:选择器继续自治,页面不用为了去重做 props 钻井。
  • 锁会释放、失败可再试finally 无论成败都会清空 Promise;成功后靠缓存挡重复,失败后下次还能再请求。
  • 语义清晰:缓存管「结果」,Promise 管「过程」,职责分开。

它治的是「共享只读列表被多组件同时预热」这类问题。若接口带强参数、结果不可共享,或必须每次强刷,不要硬套。


一个值得记住的判断标准

下次再看到「明明做了缓存还是重复请求」,先问一句:

这些调用,是在第一次响应返回之后 发生的,还是在第一次请求还没回来时一起冲进来的?

如果是后者,你缺的不是又一层 if,而是对 in-flight 状态的表达。用一个进行中的 Promise(或请求锁、或 dedupe 中间件)把它说清楚,往往比重构半个页面更有效。

中后台里,部门、字典、分类、组织树这类「几乎全局、几乎只读」的数据到处都是。它们适合集中缓存,也最容易在「组件自治 + 一页堆多个实例」的组合拳下被打穿。把单飞写进共享入口,是一次很小、但很耐用的防御。


小结

  • 重复请求的元凶,常常不是「没用缓存」,而是「缓存判断赢不了并发」。
  • length == 0 只覆盖完成后的世界;飞行中的世界需要 Promise 去重。
  • 优先修共享数据入口,而不是先推翻选择器自治或整页懒挂载。
  • 统一走 Store,禁止业务组件对同一 list-all 开旁路。

缓存负责记住答案;单飞负责保证,同一时间只有一个人去问问题。

相关推荐
不可能掉发3 小时前
Env Guard:让浏览器一眼分清生产、测试和开发环境
前端·javascript·chrome·测试工具·html·开源软件·个人开发
饼干哥哥3 小时前
Codex 必改的8 个基础配置
前端·人工智能·后端
ClouGence3 小时前
AI Agent 能写测试、跑流程,为什么回归测试还不能完全交给 AI?
前端·测试
码云之上3 小时前
Context Engineering:让 Agent 在当前步骤看到正确的事实
前端·人工智能·前端工程化
嘟嘟07174 小时前
从手写 HashRouter 到 React Router:逐层拆解 SPA 前端路由的完整实现
前端·javascript
我叫蒙奇4 小时前
JS中的隐式类型转换
前端
他们叫我秃子4 小时前
前端开发转 Go 全栈(四):代码写在前面,却要最后执行?我终于搞懂了 defer
前端·后端·go
生戎马 平安京策5 小时前
只学一点点:我的技术学习策略
前端·javascript·react.js
大鹏说大话5 小时前
HTML5 地理定位 Geolocation:获取用户位置的“红线”与最佳实践
前端·html·html5