很多异步问题,最初都被描述成“用户点得太快”。于是我们加防抖、禁用按钮,或者在请求期间显示一个 loading。页面看起来安静了,问题却没有真正消失:先发出的请求仍可能后返回,用户也可能在等待期间切换了工作区;更麻烦的是,另一个客户端可能已经改过同一份数据。
这类问题的核心不是请求太多,而是异步任务完成时,它是否仍有资格修改当前状态。
最近整理一个同时包含管理后台和移动端的项目时,我把分散在页面里的竞态处理收成了四层:最新请求令牌、上下文身份校验、写操作串行队列,以及服务端版本校验。它们处理的是四种不同的问题,不能互相替代。
本文代码经过简化和脱敏,变量、接口与数据结构都是通用示例。
先看一个防抖解决不了的问题
假设页面根据用户选择的资源加载列表:
- 用户选择资源 A,发出请求 A;
- 很快切换到资源 B,发出请求 B;
- 请求 B 先返回,页面显示 B;
- 请求 A 后返回,又把页面覆盖成 A。
把搜索框设置为 300ms 防抖,只能减少请求数量,不能保证响应顺序。禁用下拉框虽然可以阻止切换,却是拿交互体验替系统正确性买单。正确的约束应该是:
只有属于当前页面上下文、并且仍是最新一轮的任务,才允许提交结果。
这句话看似简单,落到复杂页面上至少有四道边界。
第一层:用令牌确定“最新一轮”
对读取请求,最实用的策略通常不是让旧 Promise 消失,而是让旧结果失去写入资格。一个很小的序列令牌就够了:
export function createLatestRequestGuard() {
let sequence = 0
return {
begin() {
sequence += 1
return sequence
},
isCurrent(token: number) {
return token === sequence
},
invalidate() {
sequence += 1
}
}
}
每次发起请求先取得令牌。新的请求会递增序列,之前的令牌自然失效;页面卸载或主动清空时,也可以调用 invalidate()。
const requestGuard = createLatestRequestGuard()
async function loadRows() {
const token = requestGuard.begin()
const result = await api.queryRows()
if (!requestGuard.isCurrent(token)) {
return
}
setRows(result.list)
}
这里的目标不是取消网络传输,而是保护状态提交。AbortController 能中断支持取消的 fetch、响应体读取或流,适合节省资源;令牌守卫则负责保证“即使取消没有生效,旧结果也不能落地”。两者是互补关系,不是二选一。
还有一个容易遗漏的细节:旧请求失败后,也不应该在新页面上弹出“加载失败”。错误提示同样是一次状态提交,也要经过令牌校验。
第二层:校验结果属于哪个上下文
只比较请求先后仍然不够。页面可能在请求期间切换了工作区、租户、项目或选中的记录,而新的上下文未必立即触发同一种请求。令牌仍是最新的,结果却已经不属于当前页面。
所以发起请求时要冻结关键身份,提交前再比较一次:
async function loadRows() {
const token = requestGuard.begin()
const requestedWorkspaceId = workspaceIdRef.current
const requestedResourceId = resourceIdRef.current
if (!requestedWorkspaceId || !requestedResourceId) {
setRows([])
return
}
const result = await api.queryRows({
workspaceId: requestedWorkspaceId,
resourceId: requestedResourceId
})
const contextChanged =
workspaceIdRef.current !== requestedWorkspaceId ||
resourceIdRef.current !== requestedResourceId
if (!requestGuard.isCurrent(token) || contextChanged) {
return
}
setRows(result.list)
}
令牌回答“这是不是最后一次请求”,身份校验回答“它是不是当前资源的请求”。我倾向于把这两项都保留,因为它们表达了不同的不变量。只依赖组件是否挂载、闭包里的旧 state,或者在回调里猜测当前上下文,最终都会留下模糊地带。
身份字段也不是越多越好。选择真正决定数据归属的最小集合即可,例如 workspaceId + resourceId。如果把筛选文案、分页对象等所有值都塞进判断,守卫本身会变成另一个难以维护的状态机。
第三层:把同一资源的写操作串行化
读取通常可以“最新结果获胜”,写入却不能简单丢掉旧操作。用户连续点击两次“增加”,两次意图都可能有效;真正危险的是它们同时基于同一个旧快照计算。
一种稳妥的做法,是为同一业务资源维护 Promise 尾队列:
function createMutationQueue() {
let tail = Promise.resolve()
let generation = 0
return {
reset() {
generation += 1
tail = Promise.resolve()
},
enqueue<T>(task: () => Promise<T>): Promise<T | undefined> {
const ticket = generation
const run = tail.then(async () => {
if (ticket !== generation) return undefined
return task()
})
tail = run.then(
() => undefined,
() => undefined
)
return run
}
}
}
这里有三个刻意的设计:
- 队列只保证同一资源内的顺序,不应该把整个应用锁成一条全局队列;
- 前一个任务失败后,尾队列仍回到 resolved,后续任务不会被永久阻塞;
- 切换资源时递增
generation,尚未执行的旧任务会被丢弃。
串行队列解决的是本客户端的顺序问题。它不是分布式锁,也无法阻止另一台设备或服务端任务同时修改数据。
第四层:用版本号拒绝过期写入
跨客户端并发必须由服务端裁决。前端在读取资源时拿到版本号,写入时把自己基于的版本一起提交:
interface CartSnapshot {
version: number
lines: Array<{ id: string; quantity: number }>
}
interface ChangeQuantityCommand {
lineId: string
quantity: number
expectedVersion: number
}
async function changeQuantity(command: ChangeQuantityCommand) {
const nextSnapshot = await api.changeQuantity(command)
applyAuthoritativeSnapshot(nextSnapshot)
}
服务端只有在 expectedVersion 等于当前版本时才接受修改,否则返回冲突。HTTP 自身也有类似的条件请求语义:If-Match 可以用实体标签阻止 lost update。业务版本号不一定要直接映射成 ETag,但背后的原则相同——写入者必须说明“我是基于哪个版本做的决定”。
前端收到冲突后,不要在本地把数量强行加一,也不要偷偷改成服务端最新版本重试。正确策略取决于业务:
- 可安全重放的简单操作,可以刷新后重新计算并明确重试;
- 涉及价格、库存、权益或审批的操作,应展示冲突并让用户确认;
- 服务端已经返回完整新快照时,以该快照替换本地状态,而不是字段级猜测合并。
这一层还有一个常被忽视的前提:用于写入的版本必须来自已经应用到当前界面的快照。如果 WebSocket 已通知版本 12,而页面仍展示版本 11,就不应该拿 12 作为“最新版本”绕过冲突;那会让用户提交一个并非基于其可见状态的修改。
四层分别守住什么
| 层次 | 要阻止的问题 | 不能替代什么 |
|---|---|---|
| 最新请求令牌 | 慢响应覆盖新响应 | 不能识别资源归属 |
| 上下文身份 | 旧资源结果写入新页面 | 不能处理写入顺序 |
| 串行写队列 | 本客户端写操作乱序 | 不能处理跨客户端并发 |
| 服务端版本校验 | 过期快照覆盖新事实 | 不能改善前端交互编排 |
把四层都叫作“防重复提交”,会让设计讨论失去精度。读请求竞态、上下文漂移、本地写入顺序和分布式并发,本来就是四类问题。
loading 也应该服从同一套模型
一旦允许排队,loading: boolean 往往不再可靠。第一个请求完成就把 loading 设为 false,可能会掩盖仍在等待的第二个请求。更稳定的模型是记录当前 generation 下的 pending 数:
let pending = 0
function begin() {
pending += 1
}
function settle() {
pending = Math.max(0, pending - 1)
}
const busy = pending > 0
切换资源时,旧 generation 的任务即使最终进入 finally,也不能减少新 generation 的计数。否则旧任务会把新页面的 loading 提前关闭。
如何验证,而不是靠快速点击碰运气
竞态测试最怕依赖真实网络速度。我通常用可控制 resolve 顺序的 Promise,固定验证下面几组场景:
- 请求 A 先发、请求 B 后发,B 先返回,最终只能看到 B;
- 请求发出后切换资源,即使它成功返回也不能写入或弹错;
- 连续两个写操作严格按入队顺序执行,前一个失败不阻断后一个;
- 切换资源后,旧队列尚未执行的任务全部失效;
- 服务端版本冲突时不做静默覆盖,并能恢复到权威快照;
- WebSocket 新快照与 HTTP 旧响应交错时,版本只能单调前进。
这些用例比“按钮快速点十次没有报错”更接近我们真正想守住的不变量。
最后:先定义所有权,再选择工具
我不建议把四层一开始就做成一套庞大的通用框架。先在具体页面里写清楚三件事:状态属于谁、什么情况下结果过期、冲突由谁裁决。出现稳定重复后,再抽出令牌守卫、队列和版本策略。
防抖适合控制输入频率,取消适合节省无效工作,令牌与身份校验负责前端状态所有权,服务端版本负责最终一致性。把边界说清楚以后,代码反而不会复杂;真正复杂的,是让几种语义混在一个 loading 和一个 try/catch 里。
延伸阅读: