跳转至

调试异步代码 — 断点、调用栈与五个高频错误

这一章回答两个问题: 异步代码的执行不沿屏幕直线走,断点和调用栈还怎么用; 以及别人(和昨天的自己)最常犯的五个异步错误长什么样、怎么改。 读完你会下四种位置的断点、会读异步调用栈,并且能一眼认出那五个错误。

1. 这一章讲什么

前面各章给机制、给模式,本章给排障。结构:

  1. 断点与调用栈在异步世界的用法(第 3 节);
  2. DevTools 里检查 Promise 与网络请求(第 4 节);
  3. 五个高频错误,逐条"错误 → 解法"(第 5 节——本章最值钱的部分)。

2. 顶层全景:为什么异步难调

同步代码的排障直觉:断点停住 → 看调用栈(谁调用了当前函数)→ 顺着栈往回找。异步代码里这个直觉坏在两处:代码执行的时间顺序不等于屏幕上的空间顺序;而且执行回调时,当初调用它的那个栈已经销毁了,默认的调用栈是断的1。

本章的全部手段,本质都是给这两个断点打补丁。

3. 断点与调用栈:按点下断的四种位置

3.1 四种断点位置

原书按异步结构给了下断点的位置清单2:

断点位置 看什么
Promise 执行器内部 Promise 创建那一刻的状态
.then() / .catch() 回调内部 兑现的值 / 拒因到达时的样子
await 表达式前后各一个 等待前的请求状态、等待后的响应状态
事件回调(setTimeout/XHR)与条件断点 时序类 bug;只有满足条件才停

条件断点值得单说:断点可以带条件(如 data.status === 'error'),只在特定场景停——异步时序类 bug 十次里九次偶发,让断点替你守株待兔3。

3.2 异步调用栈

现代浏览器的调用栈面板有一个 "Async" 开关,打开后,断在 async 函数里也能看到跨 await 的完整来路。原书的例子:断在 fetchUserData 里,栈是 fetchUserData ← (匿名 async) ← displayUserProfile ← (匿名) ← 最初调用——四段链完整可见4。注意栈的局限:它只显示当前执行时的环境;还在队列里候着的回调不在栈上,要等轮到它才有栈5。

4. 检查 Promise 与网络请求

  • Console 里看票:console.log(promise) 直接打印对象,状态与值可见6;
  • Network 面板:涉网调试的主战场——每个请求的时序、状态码、请求头响应头、响应体;还能限速(throttling)模拟慢网,以及 Replay XHR 不重载页面重发某个请求7。

3.3 断点设置的 DevTools 操作步骤

3.1 列了断点位置,具体操作在 Chrome DevTools 里是这样12:

  1. 行号断点:Sources 面板打开文件,点击行号——出现蓝色记号,执行到该行即停;
  2. 条件断点:右键行号 → "Add conditional breakpoint" → 输入条件(如 data.status === 'error')——行号处出现橙色记号,条件为真才停;
  3. logpoint:右键行号 → "Add logpoint" → 输入要打印的内容(如 Data received: ${JSON.stringify(data)})——不暂停,执行到该行往控制台打一行,相当于不改代码的 console.log;
  4. 事件断点:Sources 面板右侧 Event Listener Breakpoints 列表里勾选 Timer(对应 setTimeout/setInterval 的回调)或 XHR/fetch breakpoints 里填 URL 过滤——不需要知道代码在哪,所有匹配的回调触发即停,对"不知道是哪段代码在跑"的排查是神器。

四种断点的分工:行号断点用来"确认执行过没有",条件断点用来"守株待兔",logpoint 用来"只看不打扰",事件断点用来"先抓到再定位"。 异步排障的标准开场:事件断点抓现场,行号断点确认路径,条件断点等复现。

3.4 异步调用栈的四段链逐个读

3.2 的四段栈(fetchUserData ← (匿名 async) ← displayUserProfile ← (匿名) ← 最初调用)逐个读13:

  fetchUserData          ← 当前停在这里(取数的函数)
  async function (匿名)  ← await fetchUserData() 的那一行所在
  displayUserProfile     ← 调用了 fetchUserData 的函数
  async function (匿名)  ← await displayUserProfile() 的那一行
  (匿名)                 ← 最初那次调用(可能是事件回调)

从下往上读是"谁为了谁":最初调用为了 displayUserProfile 为了取数,停在最上层的取数里。每一对"函数 + (匿名 async)"就是一个 await 的跨越——同步栈到 await 就断了,Async 开关把断处重新接上。栈里"(匿名)"这么多,正是第 11 章命名约定要治的病:回调与 async 包装有了名字,栈就好读一半。

4.1 Network 面板四问法

4 节的 Network 面板,用四个问题组织使用15:

一问:请求发出去了吗? 面板里没有这条请求 → 问题在代码(条件分支没走到、URL 拼错、没调用);有 → 往下看。 二问:状态码是什么? 404/500 → 服务端的事,断点打进 then 之前看响应体;200 → 往下看。 三问:耗时花在哪? 悬停请求看瀑布分解(TTFB 长是服务器慢,Content Download 长是包大/网慢)——这决定优化方向是后端、缓存还是分多页。 四问:重发还复现吗? 右键请求 → Replay XHR:重发即复现 → 是必现的 bug,放心打断点;重发不复现 → 时序/竞态类(错误四、第 11 章 6.1),改打条件断点或加日志守株待兔。

四问按顺序走,每一问都把问题域砍掉一半。异步排障的大忌是四问没走完就改代码——在"请求根本没发出"的 bug 上优化后端,是真实发生过的浪费。

4.2 Promise 检查的两个时机

4 节的 Console 看票,加一个"什么时候看"的角度16:

let myPromise = fetchData();
console.log(myPromise);   // 此刻多半是 Promise {<pending>}
// 数据到了之后再打一次?不用——点开第一行的小三角,状态会刷新为 fulfilled 与值

Chrome 控制台里,Promise 对象打印后可展开看最终状态(开发者工具会原地更新),不用自己打第二次。这个细节让"看票"成为零成本的顺手动作:任何怀疑票的状态的时刻,先打出来看一眼,比盲猜省时得多。配合第 03 章 5.13 的 pending 实验,这张票的一生在控制台里是全程可视的。

5. 五个高频错误:错误写法与解法

错误一:忘处理错误

// 错:链上没有 catch,失败静默失踪
fetch('/api/data').then(r => r.json()).then(data => processData(data));
// 对:链尾挂 catch(或外层 try/catch)
fetch('/api/data').then(r => r.json()).then(data => processData(data))
  .catch(error => console.error('Error fetching data:', error));

错误二:嵌套地狱复活

有了 Promise 还嵌套写(第 03 章 5.3 的病)——解法永远是展平成链或 async/await8。

错误三:Promise 忘了 resolve

// 错:外层票永远没有结果,await 它的人会永远等下去
function fetchData() {
  return new Promise((resolve, reject) => {
    fetch('/api/data').then(r => r.json()).then(data => {
      processData(data);      // 处理了,但没把结果递出去!
    });
  });
}
// 对一:resolve 出去;对二(更好):直接把内层链递出去
function fetchData() {
  return fetch('/api/data').then(r => r.json()).then(data => processData(data));
}

这个错误的阴险之处:它不报错。没有异常、没有警告,只有 await 那个函数永远等下去,界面永远转圈。排障时第一个该查的就是"这张票到底 resolve 了没有"。

错误四:循环里的竞态(本章主线例子)

想在循环里处理一批数据,直觉写出:

// 错:forEach 的回调里用 async,循环不等它们
function processItems(items) {
  items.forEach(async (item) => {
    const result = await processItem(item);
    console.log(result);
  });
  console.log('All items processed');   // 这一行先打印,上面的 result 还没影
}

走一遍就明白病在哪:forEach 把每个回调同步地挨个启动,每个回调一碰到 await 就退出让路;循环本身不等任何人,'All items processed' 立刻打印,而各个异步处理还在队列里排队(各步耗时是为演示编的)。原书给的两条正路9:

// 正路一:并行——全部同时发,Promise.all 一次齐收
async function processItems(items) {
  const results = await Promise.all(items.map(processItem));
  results.forEach(r => console.log(r));
  console.log('All items processed');
}
// 正路二:串行——for...of 循环,await 在循环体里
async function processItems(items) {
  for (const item of items) {
    const result = await processItem(item);
    console.log(result);
  }
  console.log('All items processed');
}

选哪条,还是第 06 章的依赖判定:互不依赖用正路一(快),必须逐个来用正路二(稳)。forEach/map+ async 的写法不是"错在语法",是错在语义——数组方法不等待,而你可能以为它等。

错误五:手写长循环阻塞事件循环

// 错:一个 5 秒的忙等循环占死唯一的流水线
function blockingOperation() {
  const start = Date.now();
  while (Date.now() - start < 5000) { /* 空转 5 秒 */ }
}
setTimeout(() => console.log('This should run first'), 0);
blockingOperation();
console.log('This runs second');

输出违背直觉:那句 setTimeout(..., 0) 不会先跑——事件循环虽然想取它,但流水线被 while 循环占着,谁都执行不到,5 秒里界面全冻。解法:等待一律交给计时器/异步接口(把忙等换成 setTimeout 或拆成小份),别让任何一段代码长时间独占流水线10。

判断(我们的,不是书里的): 这五个错误可以归成两类:票的问题(错误一、三——票没人接、票没兑现)和流的问题(错误二、四、五——执行顺序和直觉相反)。排障时先问"是票错了还是流错了",定位速度快一半:票的问题看链与 resolve,流的问题回到第 01 章的输出顺序推演。 如果错,会错在: 两类有交集——错误四的竞态既是流的问题(顺序反了)也是票的问题(map 出来的票没人收集),死板的二分偶尔会把人指向错误的那半边;它仍是好的第一问,不是万灵套路。

5.1 错误一的变体:catch 里的错误又抛丢了

错误一(忘处理错误)有个更隐蔽的变体,清单上没有但值得防:

fetchData()
  .then(data => processData(data))
  .catch(error => console.error('出错了:', error));
  // ↑ 处理了?没有——log 之后错误是"吞"了,上层永远不知道失败了

catch 里只 log 不接管后续,等于宣布"错误到此为止"——上层拿着 undefined 继续跑。第 07 章 5.8 的"先打印再决定留不留"就是给这个变体的处方:打印之后,要么恢复(return 默认值),要么再抛,没有第三种合法结局。

5.2 错误四的正路二补充:for...of 的串行语义

错误四的正路二(for...of + await)有一个要点值得钉死:for...of 循环体里的 await,每轮都等完才进下一轮——这正是"串行"的代码级定义。对比正路一(map + all 的并行)与正路二,选择的判据照旧是第 06 章 3.2 的依赖判定,只是场景换成了循环:循环的各次迭代互不依赖 → 正路一(快,N 个同时);每次迭代要用上一次的结果(比如分多页的"下一页位置")→ 正路二(稳,一次一页)。第 10 章电影库的"Fetch Sequentially"按钮就是正路二的产品形态。

5.3 错误五的时间线:5 秒里发生了什么

错误五(阻塞循环)用第 01 章的零件表走一遍,阻塞的机理就无可抵赖(时间为演示编)14:

  t=0.0  setTimeout 登记("0ms 后叫我")→ 进宿主接口
  t=0.0  blockingOperation 进调用栈,while 循环开始空转
  t=0.0  定时器到点 → 回调被放进【任务队列】……然后只能等
  t=2.5  循环还在转;用户点击、滚动 → 事件回调进队……也只能等
  t=5.0  循环结束 → "This runs second" 打出 → 调用栈空
  t=5.0  事件循环终于能取任务:先打 "This should run first"(等了 5 秒)
  t=5.0  然后是积压的点击、滚动……界面在这 5 秒里一帧未画

对照非阻塞版的输出顺序(This runs first → This runs second → Non-blocking complete),差距一目了然:等待交给计时器后,调用栈永远不被占用,队列里的每个人都能轮到。"阻塞"的精确含义:一段不结束同步执行的代码,让任务队列里的所有人陪等——它不违法(语法完全正确),它只是独占。

5.4 五个错误的"症状→病→药"总表

把五个错误压缩成一张速查表,排障时先对症状:

错误 症状 病根 药
一 忘处理错误 偶发"没反应",控制台可能有警告 失败无人接 链尾 catch / 外层 try
二 嵌套地狱 代码向右长成三角形 用缩进表达顺序 展平成链或 async
三 忘 resolve 永远转圈,不报错 票没落定 检查每个 resolve;能直返就直返
四 循环竞态 "All processed" 先打印,结果后到 数组方法不等 await all 收票 / for...of 串行
五 阻塞循环 界面整片冻住 N 秒 while 忙等占栈 计时器 / 拆小份 / Worker

症状列是"用户视角",病根列是"机制视角"(都能落回第 01 章的零件),药列是"处方"。排障的可重复流程:先对症状表,再按病根查机制,最后按处方开药——避免一上来就改代码。

6. 作者的判断与证据

本章判断集中在"防御优先":五个错误都被写成"最常见"(作者的原话是 common mistakes),意味着作者认为这些坑的命中率高于一般直觉——尤其错误三(不报错、只挂起)和错误四(语法全对、语义全反),它们值得放进每个代码评审清单11。

证据形态:每个错误都是"错误写法 + 解法"的对照,错误写法全部是可以直接运行的合法 JavaScript——这正是它们危险的证据。

7. 边界与局限

  • 调试手段以 Chrome 为准;"Async" 开关在不同浏览器面板里的名字与位置不同;
  • 没有讲 Node.js 侧的调试器( inspect 模式、ndb 等),服务端异步排障只沾到错误一;
  • 竞态错误的解法只覆盖"循环处理一批",更细的竞态(共享数据、多请求乱序返回取最后一个)没有展开——取最后响应的那种竞态,正确的药是取消或序号比对,本章未提;
  • 没有讲日志之外的 tracing 工具(OpenTelemetry 等线上追踪),观测停在"console + 断点 + Network"。

8. 可带走的

  1. 异步排障先分类:票的问题(没人接/没兑现)还是流的问题(顺序反了);
  2. 下断四位置:执行器内、then/catch 内、await 前后、事件回调+条件断点;
  3. 打开调用栈面板的 Async 开关,跨 await 的来路才完整;
  4. forEach/map 配 async 不等结果:并行收用 Promise.all(items.map(...)),逐个来用 for...of;
  5. await 一张永远不落定的票,表现是"永远转圈"而不是报错——先查 resolve;
  6. 任何忙等循环(while+时间)都是事件循环杀手;等待交给计时器;
  7. Network 面板三件套:看时序、限速模拟慢网、Replay XHR 重发。

9. 原文地图

主题 原书章 原文位置
异步排障之难 Chapter 8: Debugging Asynchronous JavaScript text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:7(搜「non-linear」)
断点位置清单 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:17(搜「Promise Breakpoints」) · text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:52(搜「Event Loop Breakpoints」)
条件断点 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:54(搜「Conditional」)
异步调用栈与栈样例 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:64(搜「Async」) · text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:91(搜「fetchUserData」)
检查 Promise 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:107(搜「Promise State」)
Network 面板 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:128(搜「Network tab」)
错误一/二 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:159(搜「Forgetting」) · text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:197(搜「Nested」)
错误三(忘 resolve) 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:238(搜「Misunderstanding」) · text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:250(搜「not resolving」)
错误四(循环竞态) 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:280(搜「Race Conditions」) · text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:297(搜「Promise.all」)
错误五(阻塞循环) 同上 text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:311(搜「Ignoring the Event Loop」) · text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:317(搜「Blocking」)


  1. 出处:「Chapter 8: Debugging Asynchronous JavaScript」第 7-15 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:7,搜「non-linear」)。原文:"The non-linear execution flow and the potential for race conditions make it difficult to track down issues." ↩

  2. 出处:同章第 17-58 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:17,搜「Promise Breakpoints」;:38,搜「Event Loop Breakpoints」)。四种断点位置照原书清单。 ↩

  3. 出处:同章第 54-58 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:54,搜「Conditional」)。条件断点示例:condition 为 data.status === 'error'。 ↩

  4. 出处:同章第 72-99 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:91,搜「fetchUserData」)。调用栈示例四段链照原书。 ↩

  5. 出处:同章第 70 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:70,搜「microtask queue」)。原文:"Asynchronous operations may be queued in the microtask queue or the event loop, which won't appear in the call stack until they're executed." ↩

  6. 出处:同章第 105-111 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:107,搜「pending」)。Console 中检查 Promise 状态与值照原书。 ↩

  7. 出处:同章第 126-153 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:128,搜「Network tab」;:134,搜「Throttling」;:136,搜「Replay XHR」)。 ↩

  8. 出处:同章第 197-236 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:203,搜「fetchUserData」)。嵌套错误与链式/async 两种解法照原书。 ↩

  9. 出处:同章第 278-309 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:285,搜「forEach」;:297,搜「Promise.all」;:303,搜「for...of」)。错误示例与两条正路照原书。 ↩

  10. 出处:同章第 311-346 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:317,搜「Blocking」;:333,搜「nonBlockingOperation」)。忙等示例与计时器解法照原书。 ↩

  11. 出处:同章第 155-157 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:157,搜「common pitfalls」)。原文:"several common pitfalls that developers often encounter"。 ↩

  12. 出处:「Chapter 8: Debugging Asynchronous JavaScript」第 15-58 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:52,搜「Event Loop Breakpoints」;:54,搜「Conditional」)。事件断点与条件断点的设置照原书。 ↩

  13. 出处:「Chapter 8: Debugging Asynchronous JavaScript」第 72-99 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:91,搜「fetchUserData」)。调用栈示例照原书。 ↩

  14. 出处:「Chapter 8: Debugging Asynchronous JavaScript」第 315-346 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:317,搜「Blocking」;:333,搜「nonBlockingOperation」)。阻塞与非阻塞两例的输出照原书。 ↩

  15. 出处:「Chapter 8: Debugging Asynchronous JavaScript」第 127-153 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:130,搜「Request Inspection」;:134,搜「Throttling」;:136,搜「Replay XHR」)。Network 面板的功能清单照原书。 ↩

  16. 出处:「Chapter 8: Debugging Asynchronous JavaScript」第 105-111 段(text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:107,搜「pending」)。Console 检查 Promise 状态照原书。 (本章完。) ↩