防抖只延迟弹窗,不合并错误。真正的解法是错误队列 + 按类型聚合 + Toast 与请求状态绑定 + 原始错误静默上报------用户只看级别,研发才看细节。
三条 Toast 叠罗汉,用户第一条还没读完
页面初始化,并发打出去 10 个接口。3 个失败,3 条红色 Toast 在屏幕右上角叠罗汉。用户还没读完第一条,第三条已经盖上来了。
面试官的问题很朴素:批量请求失败,怎么保证只弹一个 Toast?
候选人答:加个防抖。
追问链随之而来------三个错误信息完全不同呢?网络超时、服务无响应、权限不足,合并成一条显示什么内容?他说把三条拼一起。那塞十条呢?他卡住了。
题眼不在防抖。它考的是你有没有把「错误」当成一类需要设计的数据,而不是一个需要弹出来的字符串。
防抖只治频率,不治语义
3000ms 窗口里的 10 次弹窗请求合并成 1 次,看起来对了。但内容取的是最后一次触发的 payload,前 9 条错误在合并那一刻就被丢掉了。
用户看到的是「权限不足」,可实际上网络也超时了、服务端也 500 了。他点重试,还是失败,再点,还是失败------因为屏幕上根本没说清楚到底哪儿坏了。
防抖是节流手段,不是错误治理策略。它在这里更像止痛片,病灶没动。真正的流程要复杂一层:
关键差别:弹窗时机从「失败时」挪到了「全部 settle 之后」。 只有等到所有请求都有结果,你才拥有做聚合决策所需的全部信息。
同一道题,五层答法对应五档定级
同样一道题,答出来的是哪个层级,基本就对应了面试定级。先把五层的边界摆清楚:
| 层级 | 做法 | 解决什么 | 遗留问题 |
|---|---|---|---|
| L1 防抖 | 3000ms 窗口合并弹窗 | 弹窗次数变少 | 只留最后一条,信息丢失 |
| L2 分类聚合 | 按错误码、错误类型归类 | 用户看到「级别」不是细节 | 各接口独立弹窗,仍可能刷屏 |
| L3 错误队列 | 全部 settle 后统一弹一次 | 一次弹窗,信息完整 | 没和请求状态联动 |
| L4 状态绑定 | Toast 实例绑定请求组 | 重试成功自动消失 | 缺可观测性,研发排查靠猜 |
| L5 治理体系 | 分类展示 + 静默上报 + 可观测 | 用户不被打扰,研发能定位 | 实现成本高 |
L1/L2:先分类,再决定弹不弹
按错误码聚合是第一刀:同一个接口 500,短时间内只弹一次。第二刀按错误类型聚合------网络类(超时、断网)合并成一条「网络不稳定」,服务端 5xx 合并成「服务繁忙,请稍后重试」。
权限类错误必须单独弹。 因为它的处理动作和其他错误完全不同,用户要去做的事是「重新登录」,不是「稍后再试」。把它揉进任何合并提示里,用户都会在原地打转。
核心原则一句话:用户不需要知道每一个错误的细节,只需要知道发生了什么级别的问题、以及现在该干什么。
L3:把弹窗时机挪到全部 settle 之后
完整的队列逻辑不复杂,但时机很关键。维护一个空数组,每个请求失败时 push 一条记录;所有请求完成后(包括成功的)检查队列长度:为空不弹,非空则聚合后弹一条。同时把整个队列原样上报监控平台。
用户看到「部分内容加载失败(3 项)」,研发在 Sentry 里看到 3 条完整的 URL、状态码、耗时。两边看到的东西本来就不该一样。
js
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 不该是「显示一次再隐藏」的一次性物件,而应该绑定到一组请求上,随请求状态变化而更新。 请求中显示加载态,全部成功自动消失,失败则变成带重试按钮的错误态。
js
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 刷过屏吗?是加了防抖、做了错误队列,还是干脆全部静默上报、一条都不弹?评论区聊聊你们的取舍。
有用的话点个赞。