骨架屏永远不消失——异步序列计数器被多处递增的经典坑
某个数据分析页上有一块 AI 分析区域,带骨架屏 loading。用户切换时间周期后,骨架屏怎么都不消失——明明网络请求成功了,接口返回 200,数据也有,但 UI 就是停在加载态。
排查了半天,发现问题根本不在接口,而是「序列号守卫模式」的实现方式出了问题:计数器被多个调用方各自递增,导致合法响应被当成过期响应静默丢弃。
现象
- 切换筛选条件(时间周期、Tab)后,某个区块的骨架屏不消失
- 打开 Network 面板,接口请求成功,数据正常返回
- 偶尔快速切换时,UI 会短暂显示旧数据后又回到骨架屏
- 刷新页面偶尔能恢复,但不稳定
根因
开发者意识到「快速切换时旧响应可能覆盖新数据」,所以引入了序列号守卫:每次发请求前递增计数器,响应回来时校验序列号是否匹配,不匹配就丢弃。
但计数器被递增了两次甚至三次:
// 错误示范
loadData() {
this._aiSeq++ // 调用方 #1 递增
this.loadAiAnalysis() // loadAiAnalysis 内部也递增
},
refreshAi() {
this._aiSeq++ // 调用方 #2 递增
this.loadAiAnalysis() // 又递增一次
},
loadAiAnalysis() {
this._aiSeq++ // 第三处递增
var seq = this._aiSeq // seq 捕获的是 +3 后的值
api.getAiData().then((res) => {
if (seq !== this._aiSeq) return // 永远不匹配!
this.setData({ loading: false, data: res.data })
})
}loadData() 调用时:_aiSeq 先被 loadData 递增到 1,再被 loadAiAnalysis 递增到 2,seq 捕获的是 2。之后 refreshAi 触发,_aiSeq 被递增到 3、4……最终 seq === 2 永远无法等于 this._aiSeq,所有响应都被丢弃,骨架屏永远卡住。
序列号守卫的前提是「每发一次请求,计数器精确递增一次」。多处调用方各自递增,会让 seq 捕获到一个错误的基线值,导致合法响应被永久丢弃——loading 状态再也清不掉。
正解
单一所有者原则:序列计数器只能在异步函数内部递增,调用方不得触碰它。
// 正确做法:单一所有者
loadData() {
// 不要碰 _aiSeq
this.loadAiAnalysis(false)
},
refreshAi() {
// 不要碰 _aiSeq
this.loadAiAnalysis(true)
},
loadAiAnalysis(forceRefresh) {
this._aiSeq++ // 唯一递增点
var seq = this._aiSeq
this.setData({ aiLoading: true })
api.getAiData(forceRefresh).then((res) => {
if (seq !== this._aiSeq) return // 丢弃过期响应
this.setData({ aiLoading: false, aiData: res.data })
}).catch(() => {
if (seq !== this._aiSeq) return // catch 路径也要守卫
this.setData({ aiLoading: false, aiError: true })
})
}几个实现细节:
- 计数器存为实例属性(
_aiSeq: 0),不要放进响应式data,避免触发不必要的重渲染 .then()和.catch()两条路径都要加守卫,漏掉.catch()会导致请求失败时骨架屏同样卡住- React 项目用
useRef存计数器,不要用useState,避免闭包陈旧值问题 - 开发阶段可以加日志
console.log('stale discarded', seq, this._aiSeq)辅助排查
调用方只负责调用,不递增计数器。异步函数在函数体开头做「递增 + 捕获」两步,之后所有响应都对比这个快照值。这样无论有几个调用路径,每次请求都有唯一、正确的序列号。
何时需要这个模式
| 场景 | 是否需要序列守卫 | |------|----------------| | 单次 API 调用,不会重复触发 | 不需要,普通 loading flag 够用 | | 筛选条件变化重新请求同一接口 | 需要 | | 多个代码路径调用同一异步函数 | 需要,并严格执行单一所有者 | | 防抖搜索输入框 | 防抖本身可能足够,视场景决定 |
一句话外卖
序列号守卫的灵魂是「捕获时的值 === 当前值」,而这个等式只在计数器仅被递增一次的前提下成立——让异步函数自己管好自己的计数器,调用方一律免触。