批量请求失败只弹一个 Toast面试官想听五层

250 阅读6分钟

防抖只延迟弹窗,不合并错误。真正的解法是错误队列 + 按类型聚合 + Toast 与请求状态绑定 + 原始错误静默上报——用户只看级别,研发才看细节。

三条 Toast 叠罗汉,用户第一条还没读完

页面初始化,并发打出去 10 个接口。3 个失败,3 条红色 Toast 在屏幕右上角叠罗汉。用户还没读完第一条,第三条已经盖上来了。

面试官的问题很朴素:批量请求失败,怎么保证只弹一个 Toast?

候选人答:加个防抖。

追问链随之而来——三个错误信息完全不同呢?网络超时、服务无响应、权限不足,合并成一条显示什么内容?他说把三条拼一起。那塞十条呢?他卡住了。

题眼不在防抖。它考的是你有没有把「错误」当成一类需要设计的数据,而不是一个需要弹出来的字符串。

防抖只治频率,不治语义

3000ms 窗口里的 10 次弹窗请求合并成 1 次,看起来对了。但内容取的是最后一次触发的 payload,前 9 条错误在合并那一刻就被丢掉了。

用户看到的是「权限不足」,可实际上网络也超时了、服务端也 500 了。他点重试,还是失败,再点,还是失败——因为屏幕上根本没说清楚到底哪儿坏了。

防抖是节流手段,不是错误治理策略。它在这里更像止痛片,病灶没动。真正的流程要复杂一层:

flowchart TD
 A[初始化 10 个请求] --> B[建立空错误队列]
 B --> C[请求逐个 settle]
 C -->|失败| D[写入一条错误记录]
 C -->|成功| E[仅计数]
 D --> F{是否全部完成}
 E --> F
 F -->|否| C
 F -->|是| G{队列长度}
 G -->|0| H[不弹 Toast]
 G -->|大于 0| I[按类型聚合错误]
 I --> J[弹出一条 Toast]
 I --> K[原始错误上报监控平台]

关键差别:弹窗时机从「失败时」挪到了「全部 settle 之后」。 只有等到所有请求都有结果,你才拥有做聚合决策所需的全部信息。

同一道题,五层答法对应五档定级

同样一道题,答出来的是哪个层级,基本就对应了面试定级。先把五层的边界摆清楚:

层级做法解决什么遗留问题
L1 防抖3000ms 窗口合并弹窗弹窗次数变少只留最后一条,信息丢失
L2 分类聚合按错误码、错误类型归类用户看到「级别」不是细节各接口独立弹窗,仍可能刷屏
L3 错误队列全部 settle 后统一弹一次一次弹窗,信息完整没和请求状态联动
L4 状态绑定Toast 实例绑定请求组重试成功自动消失缺可观测性,研发排查靠猜
L5 治理体系分类展示 + 静默上报 + 可观测用户不被打扰,研发能定位实现成本高

L1/L2:先分类,再决定弹不弹

按错误码聚合是第一刀:同一个接口 500,短时间内只弹一次。第二刀按错误类型聚合——网络类(超时、断网)合并成一条「网络不稳定」,服务端 5xx 合并成「服务繁忙,请稍后重试」。

权限类错误必须单独弹。 因为它的处理动作和其他错误完全不同,用户要去做的事是「重新登录」,不是「稍后再试」。把它揉进任何合并提示里,用户都会在原地打转。

核心原则一句话:用户不需要知道每一个错误的细节,只需要知道发生了什么级别的问题、以及现在该干什么。

L3:把弹窗时机挪到全部 settle 之后

完整的队列逻辑不复杂,但时机很关键。维护一个空数组,每个请求失败时 push 一条记录;所有请求完成后(包括成功的)检查队列长度:为空不弹,非空则聚合后弹一条。同时把整个队列原样上报监控平台。

用户看到「部分内容加载失败(3 项)」,研发在 Sentry 里看到 3 条完整的 URL、状态码、耗时。两边看到的东西本来就不该一样。

const classifyError = (err) => {
  if (err.status === 401) return { kind: 'auth' };
  if (err.code === 'ECONNABORTED' || err.message === 'Network Error') {
    return { kind: 'network' };
  }
  if (err.status >= 500) return { kind: 'server' };
  return { kind: 'unknown' };
};

function buildToast(errors) {
  if (errors.some((e) => e.kind === 'auth')) {
    return { type: 'auth', text: '登录已过期,请重新登录' };
  }
  const kinds = new Set(errors.map((e) => e.kind));
  if (kinds.size === 1 && kinds.has('network')) {
    return { type: 'network', text: '网络不稳定,请稍后重试' };
  }
  return { type: 'partial', text: `部分内容加载失败(${errors.length} 项)` };
}

L4:Toast 得跟请求共生死

这层是很多人漏掉的。场景:用户点了「重试」,请求成功了——那之前那条失败 Toast 应该自动消失吗?

正确答案是应该。Toast 不该是「显示一次再隐藏」的一次性物件,而应该绑定到一组请求上,随请求状态变化而更新。 请求中显示加载态,全部成功自动消失,失败则变成带重试按钮的错误态。

stateDiagram-v2
 [*] --> Pending: 请求发起
 Pending --> Loading: 显示加载中
 Loading --> Dismissed: 全部成功自动消失
 Loading --> Failed: 存在失败
 Failed --> Retrying: 用户点击重试
 Retrying --> Loading
 Failed --> Closed: 超时或手动关闭
 Dismissed --> [*]
 Closed --> [*]
class ScopedToast {
  constructor() {
    this.id = toast.loading('加载中…');
  }
  resolveAllOk() {
    toast.close(this.id); // 请求全部成功,自动收起,不留残影
  }
  fail(text, onRetry) {
    toast.update(this.id, {
      type: 'error',
      text,
      action: { text: '重试', onClick: onRetry },
    });
  }
}

L5:到 P7,问题变成“怎么管”

到 P7 这层,讨论的就不再是「怎么弹」而是「怎么管」:

  • 请求级错误聚合:同一批次、同一按钮触发的多个接口,共享一个错误上下文,全部完成后统一决策内容与类型。
  • 错误信息分级展示:全部成功不弹;非关键接口失败静默上报;关键接口部分失败弹一条带重试;全部失败弹一条带刷新。
  • Toast 与请求状态绑定:一个实例对应一组请求,成功即消失,绝不留下孤儿 Toast。
  • 可观测性:队列上报监控,记录每个请求的 URL、状态码、错误信息、耗时,支持按批次聚合分析。

这张表可以直接抄

失败情况用户可见上报监控
全部成功无否
非关键接口部分失败无(静默)是
关键接口部分失败一条:部分内容加载失败是
全部失败一条 + 刷新按钮是
命中鉴权错误单独一条:登录已过期是

最后一问:超时 + 权限过期,弹什么

他最后问的是:10 个请求失败了 3 个,2 个超时、1 个权限过期,你弹的 Toast 是什么内容?

标准答案压缩成一句话:超时合并为「网络不稳定」,权限单独弹「请重新登录」。

一个合并、一个独立,判断依据不是「谁先失败」,而是用户对这两类错误的处置动作是否相同。动作不同,就不能合并。

写在最后

多数团队处理批量请求失败,还停在「加个防抖」或者「把 loading 关掉」的阶段——因为刷屏问题在本地开发环境里根本复现不出来,只有真实网络下的弱网用户才会遇到。

你们线上的批量请求失败 Toast 刷过屏吗?是加了防抖、做了错误队列,还是干脆全部静默上报、一条都不弹?评论区聊聊你们的取舍。

有用的话点个赞。