编程语言研究书库

错误处理与优雅降级 — 失败发生时的三层防线

错误处理与优雅降级 — 失败发生时的三层防线

这一章回答三个问题: 异步代码的错误处理有哪些写法纪律(以及一个著名的反模式); 请求失败时,应用怎样"带病运行"而不是直接趴下; 以及线上出错后,靠什么把现场找回来。 读完你能给任意一段取数代码配上"失败 → 回退 → 上报"的完整链路。

1. 这一章讲什么

第 03-05 章教了错误机制(catch、try/catch、错误传播),本章教错误策略。结构对应三层防线:

  1. 写法纪律:Promise 与 async/await 各自怎么写才不埋雷(第 3 节);
  2. 兜底策略:失败时给用户看什么——暂存的旧数据、默认值、老接口、离线页(第 4 节);
  3. 观测手段:开发时怎么定位,上线后怎么收集(第 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)值得一次完整的走查,因为它是"兜底发生在失败之前"的唯一形态9:

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 的例子由两个事件回调拼成,各管一段10:

// 安装阶段:预先把关键文件存进缓存
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(原始原因)11。栈信息尤其重要:异步代码的执行不连续,没有栈就只剩一句干巴巴的报错。

5.2 断点与异步栈

第 04 章的 DevTools 按法在本章化为一句原则:在 await 前后下断点,配合异步调用栈看完整来路12。

5.3 上线后:Sentry 与自研上报

开发时的手段上线后都不在场,要靠上报:接入 Sentry(错误监控服务)后,Sentry.captureException(error) 能把错误实时送进看板,附频率与影响面统计13;不想依赖第三方,就自己把错误(消息、栈、出错现场、发生时刻)POST 回自己的服务器存档14。

  错误现场 → 本地 catch(加上下文)──→ Sentry.captureException
                                ──→ 或 POST /api/log-error(自研)

注意自研例子里一个体贴的细节:上报本身也是异步请求,它自己也可能失败——catch 里再包一层,上报失败只记 console,不再往外抛,否则"记录错误"会变成新的错误源14。

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. 可带走的

  1. 三层防线:写法层(不埋雷)、兜底层(带病运行)、观测层(找回现场),缺一层都不完整;
  2. 反模式:别把已返回 Promise 的函数再包一层 new Promise——票递票,别套票;
  3. 缓存回退链:成功时顺手存 → 失败时先翻缓存 → 连缓存都没有才抛;
  4. 默认值是栏目级的兜底;渐进增强是能力级的兜底——后者在失败发生之前;
  5. 'fetch' in window 这类能力探测,是兜底的另一种形态;
  6. Error 四属性:name、message、stack、cause;异步排障先看 stack;
  7. 上报链路别忘记:上报自己也会失败,失败只记日志不再抛。

9. 原文地图

主题原书章原文位置
章旨与三层内容Chapter 7: Error Handling in Asynchronous Codetext/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」)

Footnotes

  1. 出处:「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 的骨架照原书。 ↩

  2. 出处:同章第 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"。 ↩

  3. 出处:同章第 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"。 ↩

  4. 出处:同章第 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"。 ↩

  5. 出处:同章第 129-149 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:131,搜「Cached Data」;:139,搜「cachedData」)。成功存 localStorage、失败读缓存、无缓存才抛错的链条照原书。 ↩ ↩2

  6. 出处:同章第 153-176 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:155,搜「Default Values」)。name/email/avatar 字段级默认值照原书。 ↩ ↩2

  7. 出处:同章第 180-222 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:188,搜「fetch」)。'fetch' in window 能力探测与 XHR 回退照原书。 ↩ ↩2

  8. 出处:同章第 224-253 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:226,搜「Service Workers」)。install 时预缓存、fetch 拦截失败回 offline.html 的骨架照原书。 ↩ ↩2

  9. 出处:「Chapter 7: Error Handling in Asynchronous Code」第 180-222 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:188,搜「fetch」)。能力探测与 XHR 回退的两条路径照原书。 ↩

  10. 出处:「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 拦截照原书。 ↩

  11. 出处:同章第 259-299 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:293,搜「Error name」)。name/message/stack/cause 四属性照原书。 ↩

  12. 出处:同章第 301-341 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:303,搜「Asynchronous Stack Traces」;:325,搜「Breakpoints」)。 ↩

  13. 出处:同章第 345-369 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:349,搜「Sentry」)。Sentry.init 与 captureException 接入照原书。 ↩

  14. 出处:同章第 371-405 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:375,搜「logError」)。自研上报含 message/stack/context/timestamp,上报失败只记 console。 ↩ ↩2

  15. 出处:「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 照原书。 ↩

  16. 出处:「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 照原书。 ↩

  17. 出处:「Chapter 7: Error Handling in Asynchronous Code」第 7-15 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:7,搜「best practices」)。章首列出的三块内容(写法最佳实践、优雅降级与回退、调试与追踪)即三层防线的原书依据。 ↩

  18. 出处:「Chapter 7: Error Handling in Asynchronous Code」第 373-405 段(text/10-ch07-chapter-7-error-handling-in-asynchronous-code.txt:376,搜「errorLog」)。四字段与上报失败只记 console 照原书。 ↩

  19. 出处:「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 章实战项目的隐藏骨架。 也因此,评审异步代码的第一优先级永远不是"功能对不对",而是"失败路径走没走过"——功能对了是下限,失败体面了才是完成。 这一章的每一节都值得对照自己的项目做一次体检。 (本章完:写法、兜底、观测,一层都不能省。) ↩

On this page