本节摘要:用户会断网、服务器会 500、网关会返回一段谁也没料到的 HTML、JSON 会缺字段——实战中的 Ajax 代码,一半在写成功路径,另一半在写失败怎么收场。本节建立一套四层错误分类体系(网络错误、HTTP 错误、业务错误、数据错误),给出统一错误对象的实现、面向用户的提示策略,以及超时与用户中断这两个特殊"错误"的处理。
const data = await (await fetch('/api/orders')).json(); render(data.items);
四行代码里埋着至少五个可爆炸点:断网时 fetch reject;服务器 500 时 json() 解析失败(返回的是错误页 HTML);接口 200 但业务失败时 items 不存在;数据到了但某个元素缺字段;渲染时任何一处假设崩了。每个爆炸点的用户表现高度一致——页面无声地坏了,没有提示、没有日志,用户刷新几次,然后流失。
错误处理的本质不是"多写几个 catch",而是先建立分类体系,再按类别分流处理。没有分类的 catch 是垃圾桶,有了分类的 catch 是分拣线。

**第一层:网络错误。**请求没能完成 HTTP 交换——断网、DNS 解析失败、连接被拒、跨域被浏览器拦截。识别特征:Fetch reject 且 TypeError;XHR 触发 onerror 且 status 为 0。处理策略:可以稍后原样重试(请求本身没毛病,是路断了);提示用户检查网络;有本地缓存时用缓存兜底。
**第二层:HTTP 错误。**请求完成了交换,服务器(或网关)返回了非 2xx 状态码。这一层要按 family 细分(2.2 节):401 引导重新登录(配合令牌刷新,2.4 节);403 提示无权限;404 提示不存在;429 退避重试;5xx 可重试。4xx 类盲目重试无意义——同样的请求会得到同样的拒绝。
**第三层:业务错误。**HTTP 层 200,但业务没成功:余额不足、库存不够、验证码错误。识别特征:约定的 code 字段非成功值。处理策略:不自动重试(重试一样失败),把具体原因提示给用户或落到字段旁(3.2 节),引导修正后重新提交。
第四层:数据错误。响应到达了、也"成功"了,但内容不符合预期——JSON 解析失败(网关切到了维护页返回 HTML)、结构缺字段、类型不对。这层最隐蔽:不抛网络错也不报状态码错误,往往在渲染时才以 undefined 的属性 形式爆出来。处理策略:解析与结构校验前置(拿到数据先验形状),异常时兜底渲染并上报原始响应片段——这层的锅常在前端契约或网关配置,没有原始报文根本查不了。
💡 分类的第一收益是重试策略自动清晰:网络错误可重试、5xx 可退避重试、4xx 不重试、业务错误不重试、数据错误修复后才谈得上重试。一个"统一自动重试所有失败"的系统,要么打爆服务器,要么把用户操作重复执行五遍。
分类要用代码结构承载。自定义错误类携带类别与上下文,请求封装层负责把各来源的失败翻译成统一错误对象:
class ApiError extends Error { constructor(type, message, detail) { super(message); this.type = type; // network | http | business | data this.detail = detail; // status code 业务码 原始响应等 } } async function request(url, options = {}) { let res; try { res = await fetch(url, options); } catch (e) { throw new ApiError('network', '网络异常,请检查连接', { url }); } if (!res.ok) { const status = res.status; if (status === 401) throw new ApiError('http', '登录已过期', { status }); throw new ApiError('http', '服务暂时不可用 ' + status, { status }); } let body; try { body = await res.json(); } catch (e) { throw new ApiError('data', '响应格式异常', { raw: (await res.text()).slice(0, 200) }); } if (body.code !== 0) { throw new ApiError('business', body.message || '操作未成功', { code: body.code, fieldErrors: body.fieldErrors }); } // 结构兜底校验:缺字段给缺省形状 而不是让渲染层爆 undefined return normalize(body.data); } function normalize(data) { if (!Array.isArray(data)) return []; return data.map(item => ({ id: item.id ?? 0, title: item.title ?? '(无标题)', author: item.author ?? '佚名' })); }
两个设计点。翻译层收口:网络、状态码、解析、业务四类失败在 request 内部各就各位地转成 ApiError,业务代码只面对一种错误结构——err.type 一个 switch 就能分流。normalize 做结构兜底:第四层错误的低成本防线,接口字段缺失时给缺省值而非让渲染层裸奔;对数据形状要求高的页面,用结构校验做入口检查更严谨。
错误提示的用户体验三原则:
说人话。TypeError: Failed to fetch 对用户毫无信息量,"网络不给力,请检查后重试"才叫提示。错误的技术细节进日志与上报,不进界面。
说清能不能怎么办。"加载失败"是半截话;"加载失败,点击重试"给了出路;"内容不存在,可能已被删除,返回列表"给了去路。每个错误提示都应该回答"接下来做什么"。
克制。一个页面同时失败五个请求,弹五个 toast 是灾难——聚合提示("部分内容加载失败"加统一重试),或按重要性只提示主区域的失败。全局错误处理函数里做个简单节流即可。
按类别的提示文案模板:
| 错误类别 | 用户看到的 | 附带动作 |
|---|---|---|
| 网络错误 | 网络不给力,请检查连接 | 重试按钮 |
| HTTP 5xx | 服务开小差了,稍后再试 | 自动退避重试 |
| HTTP 401 | 登录已过期 | 跳登录(保留当前路径) |
| HTTP 403 | 你没有查看此内容的权限 | 返回入口 |
| 业务错误 | 具体原因(余额不足等) | 引导修正的入口 |
| 数据错误 | 内容加载异常 | 重试加上报 |
超时。请求挂起三分钟和失败没有区别,用户早跑了。给请求设定时限,到点视为网络错误处理:
function fetchWithTimeout(url, options = {}, ms = 8000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), ms); return fetch(url, { ...options, signal: controller.signal }) .finally(() => clearTimeout(timer)); }
超时阈值按业务定:普通接口 8 到 15 秒,搜索类 3 到 5 秒(用户等不了更久)。超时后的请求应视为可重试的网络类失败。
用户取消。切走页面、关闭弹窗时,在途请求的结果已经没有意义——让它在后台跑完再渲染,轻则白做功,重则把过期内容渲染到已经变化的界面上(3.4 节竞态的近亲)。AbortController 的 abort 主动叫停;被 abort 的请求抛 AbortError,catch 里识别它是"自己人干的",静默处理不上报不提示:
try { const data = await fetchWithTimeout(url); } catch (err) { if (err.name === 'AbortError') return; // 主动取消 不是故障 throw err; }
用户取消与超时共用 AbortController 机制,但语义要分开:取消是正常流程的一部分,超时是故障的一种。把取消也弹"加载失败"提示,是常见的画蛇添足。
用户端的错误你永远看不到,除非它自己走回来。上报的最低配置三件事:全局兜底(window 的 error 与 unhandledrejection 监听,接住散落的异常);关键上下文(错误类别、接口地址、状态码、时间、用户标识、当前页面);采样与节流(同一错误别每秒上报一百次,前端聚合后按次上传)。四层分类在这里体现最后的价值:网络错误关注用户环境分布、HTTP 错误关注接口与网关、业务错误关注规则命中、数据错误关注契约变更——每类错误指向不同的责任方,上报字段设计时就该为排查路径服务。
看层级。UI 层的 catch 通常到此为止(提示用户即完成使命);底层封装(request 函数)必须继续抛出——吞掉下层错误的工具函数是上游排查的地狱。原则:谁能让用户知情,谁负责终结错误。
指数退避起步:1 秒、2 秒、4 秒,最多三到五次,并只在网络错误与 5xx 上重试。带上随机抖动(间隔乘个 0.5 到 1.5 的随机数)防止雪崩——服务刚恢复就被整齐划一的重试打趴。完整实现在 4.4 节。
影响主流程且用户必须决策的才打断(弹窗);局部数据的失败用局部提示;后台静默刷新的失败干脆不打扰。打断是有成本的动作,预算要省着用。
用一个模拟故障把本章的体系走通。场景:移动端用户反馈"订单页经常打不开",但办公室里怎么都复现不了。按本章的框架推进:
第一步看上报。错误样本显示三类失败混杂:网络类占六成(集中在某运营商网段)、HTTP 5xx 占三成(集中在晚高峰)、零星数据类(响应体是网关的维护页 HTML)。第二步按类分治。网络类的结论是环境问题,但要确认产品侧行为:检查代码发现网络错误只有一句"加载失败"没有重试——按 4.4 节给网络类错误加上一次静默重试,六成用户的问题立刻减半(弱网抖动重试一次的成功率相当高)。5xx 类转给服务端(晚高峰容量问题),前端配合退避与"稍后再试"文案。数据类问题最有意思:网关在服务重启窗口会把接口响应替换成维护页——前端在 request 层加了一道"Content-Type 不是 JSON 直接判数据错误"的检查,把这种静默白屏变成明确上报。第三步回归验证:上线后两周,同类上报下降八成,剩下的样本带上了完整上下文(网络类型、时间、原始响应片段),后续排查有据可依。
这个演练展示的是错误处理的完整闭环:上报发现问题、分类定位根因、按类选择手段、验证关闭循环。多数团队卡在第一步(没有上报)或第二步(有了上报不分类,把网络、服务端、契约问题混在一个"失败数"里看板化),于是所有问题都变成"前端再试试"。分类体系的真正产出不是代码,是让每一类错误流向能解决它的地方。
补一个演练里没展开的细节:错误提示的文案也需要迭代。上线后收集用户对提示的反馈——"服务暂时不可用"被用户理解成"我的账号出问题了"、"点击重试"的按钮位置没人看到——这类信息只有真实用户能给你。迭代方法很朴素:把错误提示的曝光与后续行为(重试点击率、页面放弃率)做简单统计,文案改动后对比。文案不是文学创作,是引导用户采取正确动作的界面元素,值得用对待按钮的态度对待它。至此可以给本节一个收束性的自检:随机翻开自己项目的一个页面,捂住网络面板,仅凭界面能否说出它当前处于四态中的哪一态、失败了会走哪层分支、用户会看到什么提示——三问答得利索,说明错误处理已经长进了产品的皮肤里,而不只是躺在代码的 catch 里。把这三个问题也讲给产品经理与设计师听,让他们在画交互稿时就标出失败态的设计——错误处理从来不只是工程师的功课,它是整个产品的体面程度刻度。