打开某个业务页,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 次并发。
关键不在「有没有缓存」,而在缓存生效的时机。
把时间线摊开:
Select_1进入方法,length === 0,发起请求 ASelect_2几乎同时进入,请求 A 还没回来,length仍是0,发起请求 B- ......直到
Select_N - 若干毫秒后,请求陆续返回,缓存终于有值
- 之后的调用才真正命中缓存
也就是说: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 |
| 都没有 | 发起唯一一次请求 |
组件侧不用改 :仍然 onMounted 调 getSharedList()。多个选择器一起喊,Store 只放行一枪。
同一模式套到其它共享列表后,Network 里对应接口从「刷屏」变成「各一次」。旁路直调的组件,也改回走 Store,避免再开小灶。
为什么这个改动划算
- 改动集中:修 Store 一处,全站同类选择器一起受益。
- 不破坏现有组件契约:选择器继续自治,页面不用为了去重做 props 钻井。
- 锁会释放、失败可再试 :
finally无论成败都会清空 Promise;成功后靠缓存挡重复,失败后下次还能再请求。 - 语义清晰:缓存管「结果」,Promise 管「过程」,职责分开。
它治的是「共享只读列表被多组件同时预热」这类问题。若接口带强参数、结果不可共享,或必须每次强刷,不要硬套。
一个值得记住的判断标准
下次再看到「明明做了缓存还是重复请求」,先问一句:
这些调用,是在第一次响应返回之后 发生的,还是在第一次请求还没回来时一起冲进来的?
如果是后者,你缺的不是又一层 if,而是对 in-flight 状态的表达。用一个进行中的 Promise(或请求锁、或 dedupe 中间件)把它说清楚,往往比重构半个页面更有效。
中后台里,部门、字典、分类、组织树这类「几乎全局、几乎只读」的数据到处都是。它们适合集中缓存,也最容易在「组件自治 + 一页堆多个实例」的组合拳下被打穿。把单飞写进共享入口,是一次很小、但很耐用的防御。
小结
- 重复请求的元凶,常常不是「没用缓存」,而是「缓存判断赢不了并发」。
length == 0只覆盖完成后的世界;飞行中的世界需要 Promise 去重。- 优先修共享数据入口,而不是先推翻选择器自治或整页懒挂载。
- 统一走 Store,禁止业务组件对同一 list-all 开旁路。
缓存负责记住答案;单飞负责保证,同一时间只有一个人去问问题。