不只是防抖:复杂前端异步竞态的四层治理

不只是防抖:复杂前端异步竞态的四层治理

很多异步问题,最初都被描述成“用户点得太快”。于是我们加防抖、禁用按钮,或者在请求期间显示一个 loading。页面看起来安静了,问题却没有真正消失:先发出的请求仍可能后返回,用户也可能在等待期间切换了工作区;更麻烦的是,另一个客户端可能已经改过同一份数据。

这类问题的核心不是请求太多,而是异步任务完成时,它是否仍有资格修改当前状态

最近整理一个同时包含管理后台和移动端的项目时,我把分散在页面里的竞态处理收成了四层:最新请求令牌、上下文身份校验、写操作串行队列,以及服务端版本校验。它们处理的是四种不同的问题,不能互相替代。

本文代码经过简化和脱敏,变量、接口与数据结构都是通用示例。

先看一个防抖解决不了的问题

假设页面根据用户选择的资源加载列表:

  1. 用户选择资源 A,发出请求 A;
  2. 很快切换到资源 B,发出请求 B;
  3. 请求 B 先返回,页面显示 B;
  4. 请求 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,固定验证下面几组场景:

  1. 请求 A 先发、请求 B 后发,B 先返回,最终只能看到 B;
  2. 请求发出后切换资源,即使它成功返回也不能写入或弹错;
  3. 连续两个写操作严格按入队顺序执行,前一个失败不阻断后一个;
  4. 切换资源后,旧队列尚未执行的任务全部失效;
  5. 服务端版本冲突时不做静默覆盖,并能恢复到权威快照;
  6. WebSocket 新快照与 HTTP 旧响应交错时,版本只能单调前进。

这些用例比“按钮快速点十次没有报错”更接近我们真正想守住的不变量。

最后:先定义所有权,再选择工具

我不建议把四层一开始就做成一套庞大的通用框架。先在具体页面里写清楚三件事:状态属于谁、什么情况下结果过期、冲突由谁裁决。出现稳定重复后,再抽出令牌守卫、队列和版本策略。

防抖适合控制输入频率,取消适合节省无效工作,令牌与身份校验负责前端状态所有权,服务端版本负责最终一致性。把边界说清楚以后,代码反而不会复杂;真正复杂的,是让几种语义混在一个 loading 和一个 try/catch 里。

延伸阅读:

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。