错误处理与优雅降级 — 失败发生时的三层防线¶
这一章回答三个问题: 异步代码的错误处理有哪些写法纪律(以及一个著名的反模式); 请求失败时,应用怎样"带病运行"而不是直接趴下; 以及线上出错后,靠什么把现场找回来。 读完你能给任意一段取数代码配上"失败 → 回退 → 上报"的完整链路。
1. 这一章讲什么¶
第 03-05 章教了错误机制(catch、try/catch、错误传播),本章教错误策略。结构对应三层防线:
- 写法纪律:Promise 与 async/await 各自怎么写才不埋雷(第 3 节);
- 兜底策略:失败时给用户看什么——暂存的旧数据、默认值、老接口、离线页(第 4 节);
- 观测手段:开发时怎么定位,上线后怎么收集(第 5 节)。
2. 顶层全景:三层防线图¶
① 写法层(防埋雷) ② 兜底层(带病运行) ③ 观测层(找回现场)
───────────── ───────────── ─────────────
链尾挂 catch 缓存数据回退 战略性 console 日志
别绕包已有 Promise 栏目级默认值 Error 四属性(name/message/stack/cause)
try/catch 按组包 渐进增强(旧浏览器旧接口) 异步栈 + 断点
全局 unhandledRejection Service Worker 离线页 Sentry / 自研上报
3. 写法纪律:三个"别这样"¶
3.1 纪律一:链尾必挂 catch¶
任何 .then 链以 .catch 收尾,保证链上任一步的拒绝都有人接1。这承接第 04 章的纪律一,本章把它放进 async/await 的对照写法里再巩固一遍。
3.2 纪律二:别绕包已有的 Promise(著名反模式)¶
原书点名的反模式:把一个本来就已经返回 Promise 的函数,再手工包进 new Promise 里2:
// 反模式:多此一举的绕包
function fetchData() {
return new Promise((resolve, reject) => {
axios.get('/api/data')
.then(response => resolve(response.data))
.catch(error => reject(error));
});
}
// 正解:直接把票递出去
function fetchData() {
return axios.get('/api/data').then(response => response.data);
}
为什么坏?三个具体的害处:代码变长只是表面,真正的代价是错误信息在转手时容易走样(比如漏接某些异常形态),而且多一层毫无信息量的壳,调试时栈里多一排噪音。判断口诀:手上已经是票,就别再造一张票去装它。
3.3 纪律三:try/catch 按相关操作分组,别一行一包¶
每个 await 都裹一层 try/catch,会把直线代码切碎。原书的做法:把逻辑相关的一组操作包在一个 try 里,共享一个 catch3——粒度对应"这一组失败了怎么办",而不是对应到语法行。
4. 兜底策略:失败时给用户看什么¶
这层的主线思想原书写得很直白:错误处理的目标是应用能继续(哪怕功能打折),并给用户有意义的反馈4。三种回退,力度从轻到重:
4.1 缓存(暂存的旧数据)回退:旧数据也是数据¶
取新数据失败时,退而求其次用上次成功时的缓存(缓存接口是浏览器的 localStorage,键名照原书)5:
async function fetchData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
localStorage.setItem('cachedData', JSON.stringify(data)); // 成功时存一份
return data;
} catch (error) {
console.warn('Failed to fetch fresh data, using cached data');
const cachedData = localStorage.getItem('cachedData');
if (cachedData) return JSON.parse(cachedData); // 失败时用旧数据顶
throw new Error('No data available'); // 连缓存都没有才认输
}
}
这条链值得背:成功路径顺手存,失败路径先翻缓存,缓存也没有才把错误往上抛。 用户看到 5 分钟前的天气,远比看到报错弹窗好。
4.2 默认值:栏目级兜底¶
数据取不到时,给每个栏目(名字、邮箱、头像各是一个栏目)一个像样的默认("Unknown User"、默认头像)6。和 4.1 的差别:4.1 回退的是"上一次的完整答案",4.2 回退的是"编一个能看的答案"。前者有旧数据才有效,后者任何时候都能撑住界面。
4.3 渐进增强与离线页¶
再往外一层,是"这个浏览器/这个网络根本不支持新玩法"怎么办:
- 渐进增强:核心功能用所有浏览器都有的老接口写(例子是不支持 Fetch 的浏览器退回 XMLHttpRequest),新特性只是锦上添花7;
- 离线支持:用 Service Worker(一种能拦截浏览器请求的常驻脚本)把关键页面缓存住,断网时给用户一个能打开的离线页8。
这两条的共同点:兜底不发生在错误抛出之后,而发生在能力探测之时——'fetch' in window 为假就不走那条会失败的路。
判断(我们的,不是书里的): 三层兜底其实是同一个决策——"数据源的可信度排序"。4.1 信旧数据,4.2 信默认值,4.3 信老接口;排序的依据是"用户看到假的/旧的/残缺的,比看到什么都没有强多少"。这个视角的好处是它能推广到书没讲的地方:比如配置中心挂了可以信打包时的默认值,地图瓦片加载失败可以降低清晰度。把兜底理解为"数据源优先级列表",比记住三个模式走得更远。 如果错,会错在: 旧数据/默认值有误导风险(显示旧库存会让人下单买不到),排序时必须把"错的代价"也放进秤里——兜底不是免费的,4.1-4.3 都默认了"陈旧胜于缺席",这个默认在交易类场景里会反过来。
4.4 渐进增强的完整走查¶
4.3 的能力探测('fetch' in window)值得一次完整的走查,因为它是"兜底发生在失败之前"的唯一形态13:
function loadComments() {
const container = document.getElementById('comments');
container.innerHTML = 'Loading comments...';
if ('fetch' in window) {
fetchCommentsAsync(container); // 新浏览器:走 Fetch
} else {
fetchCommentsXHR(container); // 老浏览器:退回 XMLHttpRequest
}
}
执行流程分两层判断。第一层是能力:浏览器有没有 fetch——这是加载那一刻就定了的,与网络无关,所以这个分支永远不会"中途切换"。第二层是结果:无论走哪条路,请求都可能失败——fetch 版用 try/catch,XHR 版用 onload/onerror 两个回调,失败文案完全一致("Failed to load comments. Please try again later.")。两条路的成功路径不同(response.json() vs JSON.parse(xhr.responseText)),失败体验相同——渐进增强的分叉点在能力,汇合点在体验。
这个模式的现代版形态更广:不是"有没有 fetch",而是"有没有这个 API"的任意探测('AbortController' in window、'serviceWorker' in navigator),甚至"用户有没有授权"的权限探测。共同结构都是一句话:先问能不能,再决定走哪条,两条路的失败殊途同归。
4.5 Service Worker 离线页的两段式¶
Service Worker 的例子由两个事件回调拼成,各管一段14:
// 安装阶段:预先把关键文件存进缓存
self.addEventListener('install', event => {
event.waitUntil(
caches.open('my-app-cache').then(cache => {
return cache.addAll(['/', '/index.html', '/styles.css', '/app.js', '/offline.html']);
})
);
});
// 拦截阶段:每个网络请求先问缓存
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => {
return response || fetch(event.request).catch(() => {
return caches.match('/offline.html'); // 缓存没有 + 网络失败 → 离线页
});
})
);
});
把它读成一条优先级链:缓存命中 → 网络 → 离线页。用户的每次请求都沿这条链从上往下走,走到哪格算哪格。install 回调里的 cache.addAll 是"播种"——离线页方案能工作的前提是这些文件当初在线时已经存好;fetch 回调里的 caches.match 优先,是为了"重复访问秒开"(缓存优先策略),代价是内容更新要等新 Service Worker 激活——更新策略是书没有展开的另一章。
对"兜底"主题,这个例子的启示是:兜底也可以离线预置——离线页不是错误发生时"编"出来的(像默认值),而是事先准备好的半成品。它与 4.1 缓存回退的差别就在于此:一个存"数据"(动态的),一个存"页面"(静态的)。
5. 观测手段:把现场找回来¶
5.1 开发时:日志(程序留下的运行记录)与错误对象¶
原书给的做法:在 await 前后放战略性日志(写明"开始取了/取到了/转完了"),出错时打印 Error 对象的四个属性——name(错误种类)、message(描述)、stack(出错时的调用栈)、cause(原始原因)9。栈信息尤其重要:异步代码的执行不连续,没有栈就只剩一句干巴巴的报错。
5.2 断点与异步栈¶
第 04 章的 DevTools 按法在本章化为一句原则:在 await 前后下断点,配合异步调用栈看完整来路10。
5.3 上线后:Sentry 与自研上报¶
开发时的手段上线后都不在场,要靠上报:接入 Sentry(错误监控服务)后,Sentry.captureException(error) 能把错误实时送进看板,附频率与影响面统计11;不想依赖第三方,就自己把错误(消息、栈、出错现场、发生时刻)POST 回自己的服务器存档12。
注意自研例子里一个体贴的细节:上报本身也是异步请求,它自己也可能失败——catch 里再包一层,上报失败只记 console,不再往外抛,否则"记录错误"会变成新的错误源12。
5.4 战略性日志:放几行、写什么¶
5.1 说"在 await 前后放日志",具体放几行、写什么,值得一个可执行的清单15:
async function fetchAndProcessData() {
try {
console.log('Fetching data...');
const data = await fetchData();
console.log('Data fetched successfully:', data);
console.log('Processing data...');
const processedData = await processData(data);
console.log('Data processed successfully:', processedData);
return displayResults(processedData);
} catch (error) {
console.error('An error occurred:', error);
console.error('Error stack:', error.stack); // 栈在异步代码里尤其不可省
}
}
三条规则。其一,一段异步操作两行:进入时一行、完成时一行——不对称(只打进不打完成)的日志会在"它卡住了"与"它失败了"之间制造无尽猜测。其二,完成日志带数据——"取到了"之后打印拿到的内容本身,多少请求死于"取到了但取错了";其三,catch 里必打 stack——异步代码的执行不连续,没有栈的错误信息等于没有。stack 可能很长,但排障时按图索骥的效率与它的长度成正比。
这套日志在开发期是探雷器,上线前应当收敛(或用 debug 库按开关控制,第 11 章 5.2)——满屏 console.log 的代码和没有日志的代码一样难排障,因为信号被噪音淹了。
5.5 Sentry 接入后的世界¶
5.3 的接入片段只有几行,但接进去之后世界变成什么样,值得展开16:Sentry.captureException(error) 之后,错误带着消息、栈、发生页面、浏览器版本、发生时间进看板;看板能按"这个错误影响了多少用户、从哪个版本开始、在哪个浏览器集中"聚合。第 4 章 4.2 的自定义错误类型在这里兑现价值:NetworkError 与 ValidationError 在看板里是两条独立的线,修复优先级可以分别排。注意接入片段里的 tracesSampleRate: 1.0——性能追踪全量取样,小项目无妨,量大的项目要调低(每次取样都上传数据)。
5.6 三层防线的依赖关系:谁兜谁的底¶
第 2 节的三层防线不是并列的三个主题,它们有严格的依赖关系,画出来是一根单向管道17:
写法层埋的雷 → 兜底层接住 → 观测层记住
(没挂 catch) (缓存/默认值) (日志/上报)
写法层没埋雷 → 兜底层闲着 → 观测层仍记(成功的也要记账?不必)
写法层埋了雷且 → 漏到观测层 → 兜底与观测之间没有下一层了
兜底层接不住
两个推论。其一,观测层是唯一的"无限兜底"——无论上面哪层失效,它都在;但它是用"错误已经发生"换的,代价最大,所以前两层的存在意义就是把到达观测层的流量压到"只剩意外"。其二,兜底层依赖写法层先把错误"变成可兜的形态"——绕包反模式(3.2)把错误改头换面,缓存回退就可能接到一颗走了样的错;链上没挂 catch,错误根本没到兜底层就消失(浏览器)或杀进程(Node)。三层是同一根管道上的三道闸门,不是三个可选项。
5.7 自研上报的四栏清单¶
5.3 的自研上报往服务器发的错误日志有四个栏目,逐个说为什么是它18:
const errorLog = {
message: error.message, // 错误的描述(哪句话出了问题)
stack: error.stack, // 出错的调用栈(在哪一行、谁调起来的)
context: context, // 当时的业务现场(哪个用户、哪个操作)
timestamp: new Date().toISOString() // 发生时刻(排序、聚合都要用)
};
message 与 stack 来自错误对象本身——没有它们,日志只是一句"出错了";context 是补出来的——它在 catch 的调用处随手可得({ userId, operation: 'fetchUserProfile' }),却常常是排障最值钱的一格("只有这个用户的这条操作挂"与"所有人都挂"是完全不同的病);timestamp 用 ISO 格式(带时区的统一格式),是为了跨时区的日志能按时刻排序。上报失败时不外抛的设计(5.3 已述)在这里再加一层理由:上报函数自己也在 try/catch 里,而它抛错的话,这个 catch 没有更外层接——链到此为止,只能吞。
5.8 Error 对象的四个属性逐个讲¶
5.1 列了四属性,逐个说清楚在异步排障里各自回答什么问题19:
| 属性 | 回答什么 | 异步场景里的特别之处 |
|---|---|---|
name |
这是哪一类错误(Error、TypeError、还是你自定义的 NetworkError) | 自定义错误类型(第 04 章 4.2)的识别入口 |
message |
人话描述(哪句话、哪个状态码) | 跨层传递时最容易被改的字段——上层包装时务必保留原 message |
stack |
出错时的调用栈:哪一行、被谁调起来 | 异步代码的救命绳;await 每一环都在栈里留名(第 04 章的异步栈) |
cause |
原始错误(包装后的上一环) | 多层转手里防止根因丢失(第 09 章 5.4 的 Error Cause 把它标准化) |
打印这四个属性的写法就是 5.1 的样子。一个组合习惯:catch 里先打四个属性,再决定是否再抛——打日志这个动作要在决定"留不留"之前做,否则再抛出去之后,这一层就再也没有机会记录现场了(上层看到的错误是干净的,它的栈里没有你这层的痕迹)。
6. 作者的判断与证据¶
本章把"错误处理"从语法话题升格为产品话题,判断集中在两处:一是缓存回退与默认值被正面教授(不是进阶技巧而是默认动作)56;二是"优雅降级"(graceful degradation)与"渐进增强"被作为成对概念引入——能力在前、兜底在后78。
证据形态:每条例子的代码都可运行,且失败分支是真实的(请求真的 404 时走哪条路),不是摆设。
7. 边界与局限¶
- 缓存回退没讲缓存的时效(陈旧多久算不可用),也没讲缓存的容量与清理;
- Service Worker 只给了"装一个缓存壳"的最小例,预缓存策略、更新机制都没展开;
- Sentry 只给了接入片段,错误抽取比例、隐去用户隐私等生产配置未提;
- 重试与兜底的关系(先重试还是先缓存)本章没串——那在 4.1 的链里其实是"先抛错再回退",重试策略要由第 06 章的代码提供。
8. 可带走的¶
- 三层防线:写法层(不埋雷)、兜底层(带病运行)、观测层(找回现场),缺一层都不完整;
- 反模式:别把已返回 Promise 的函数再包一层
new Promise——票递票,别套票; - 缓存回退链:成功时顺手存 → 失败时先翻缓存 → 连缓存都没有才抛;
- 默认值是栏目级的兜底;渐进增强是能力级的兜底——后者在失败发生之前;
'fetch' in window这类能力探测,是兜底的另一种形态;- Error 四属性:name、message、stack、cause;异步排障先看 stack;
- 上报链路别忘记:上报自己也会失败,失败只记日志不再抛。
9. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 章旨与三层内容 | Chapter 7: Error Handling in Asynchronous Code | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:7(搜「gracefully」) |
| catch 收尾 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:24(搜「catch」) |
| 绕包反模式 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:33(搜「Anti-pattern」) · text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:44(搜「directly」) |
| try/catch 分组 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:107(搜「Avoiding Unnecessary」) |
| 缓存回退 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:131(搜「Cached Data」) · text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:139(搜「cachedData」) |
| 默认值 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:155(搜「Default Values」) |
| 渐进增强 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:180(搜「Progressive Enhancement」) |
| Service Worker 离线 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:226(搜「Service Workers」) |
| Error 四属性 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:293(搜「Error name」) |
| 异步栈断点 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:303(搜「Asynchronous Stack Traces」) |
| Sentry | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:349(搜「Sentry」) |
| 自研上报 | 同上 | text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:375(搜「logError」) |
-
出处:「Chapter 7: Error Handling in Asynchronous Code」第 17-29 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:24,搜「catch」)。fetchData→processData→displayResults 链尾挂 catch 的骨架照原书。 ↩ -
出处:同章第 31-49 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:33,搜「Anti-pattern」;:44,搜「directly」)。原文:"Anti-pattern: Unnecessary Promise wrapping",正解"Return the Promise directly"。 ↩ -
出处:同章第 107-123 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:107,搜「Avoiding Unnecessary」)。原文:"avoid wrapping every single await statement in a separate try/catch block. Instead, group related operations together"。 ↩ -
出处:同章第 11 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:11,搜「recover from failures」)与第 127 段(:127,搜「continue to function」)。原文:"ensure that your application can continue to function, albeit with reduced functionality"。 ↩ -
出处:同章第 129-149 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:131,搜「Cached Data」;:139,搜「cachedData」)。成功存 localStorage、失败读缓存、无缓存才抛错的链条照原书。 ↩↩ -
出处:同章第 153-176 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:155,搜「Default Values」)。name/email/avatar 字段级默认值照原书。 ↩↩ -
出处:同章第 180-222 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:188,搜「fetch」)。'fetch' in window能力探测与 XHR 回退照原书。 ↩↩ -
出处:同章第 224-253 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:226,搜「Service Workers」)。install 时预缓存、fetch 拦截失败回 offline.html 的骨架照原书。 ↩↩ -
出处:同章第 259-299 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:293,搜「Error name」)。name/message/stack/cause 四属性照原书。 ↩ -
出处:同章第 301-341 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:303,搜「Asynchronous Stack Traces」;:325,搜「Breakpoints」)。 ↩ -
出处:同章第 345-369 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:349,搜「Sentry」)。Sentry.init 与 captureException 接入照原书。 ↩ -
出处:同章第 371-405 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:375,搜「logError」)。自研上报含 message/stack/context/timestamp,上报失败只记 console。 ↩↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 180-222 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:188,搜「fetch」)。能力探测与 XHR 回退的两条路径照原书。 ↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 224-253 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:229,搜「install」;:243,搜「fetch」)。install 预缓存与 fetch 拦截照原书。 ↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 261-284 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:267,搜「Fetching data」;:279,搜「Error stack」)。日志的放置与 error.stack 照原书。 ↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 345-369 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:351,搜「Sentry」;:356,搜「tracesSampleRate」)。Sentry 接入与 captureException 照原书。 ↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 7-15 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:7,搜「best practices」)。章首列出的三块内容(写法最佳实践、优雅降级与回退、调试与追踪)即三层防线的原书依据。 ↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 373-405 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:376,搜「errorLog」)。四字段与上报失败只记 console 照原书。 ↩ -
出处:「Chapter 7: Error Handling in Asynchronous Code」第 286-299 段(
text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:293,搜「Error name」)。四属性的打印照原书。 本章的三层防线与第 06 章的模式库是一体两面:模式库回答"怎么成功",本章回答"失败之后世界还怎么转"——两边都齐了,异步代码才算写完。 下一章给这套防线配上排障工具箱:断点、异步栈与五个高频错误的对照表。 读完本章之后,任何一段取数代码都应该能当场答出三问:失败时用户看到什么?旧数据从哪来?错误去哪查? 三问答不上来的代码,无论功能多完整,都还算半成品。 (本章是全书"工程味"最重的一章:机制之外,它讲的是失败美学。) 把这三层防线装进项目,就是第 10 章实战项目的隐藏骨架。 也因此,评审异步代码的第一优先级永远不是"功能对不对",而是"失败路径走没走过"——功能对了是下限,失败体面了才是完成。 这一章的每一节都值得对照自己的项目做一次体检。 (本章完:写法、兜底、观测,一层都不能省。) ↩