调试异步代码 — 断点、调用栈与五个高频错误¶
这一章回答两个问题: 异步代码的执行不沿屏幕直线走,断点和调用栈还怎么用; 以及别人(和昨天的自己)最常犯的五个异步错误长什么样、怎么改。 读完你会下四种位置的断点、会读异步调用栈,并且能一眼认出那五个错误。
1. 这一章讲什么¶
前面各章给机制、给模式,本章给排障。结构:
- 断点与调用栈在异步世界的用法(第 3 节);
- DevTools 里检查 Promise 与网络请求(第 4 节);
- 五个高频错误,逐条"错误 → 解法"(第 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:
- 行号断点:Sources 面板打开文件,点击行号——出现蓝色记号,执行到该行即停;
- 条件断点:右键行号 → "Add conditional breakpoint" → 输入条件(如
data.status === 'error')——行号处出现橙色记号,条件为真才停; - logpoint:右键行号 → "Add logpoint" → 输入要打印的内容(如
Data received: ${JSON.stringify(data)})——不暂停,执行到该行往控制台打一行,相当于不改代码的 console.log; - 事件断点: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. 可带走的¶
- 异步排障先分类:票的问题(没人接/没兑现)还是流的问题(顺序反了);
- 下断四位置:执行器内、then/catch 内、await 前后、事件回调+条件断点;
- 打开调用栈面板的 Async 开关,跨 await 的来路才完整;
forEach/map配 async 不等结果:并行收用Promise.all(items.map(...)),逐个来用for...of;await一张永远不落定的票,表现是"永远转圈"而不是报错——先查 resolve;- 任何忙等循环(while+时间)都是事件循环杀手;等待交给计时器;
- 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」) |
-
出处:「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." ↩ -
出处:同章第 17-58 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:17,搜「Promise Breakpoints」;:38,搜「Event Loop Breakpoints」)。四种断点位置照原书清单。 ↩ -
出处:同章第 54-58 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:54,搜「Conditional」)。条件断点示例:condition 为data.status === 'error'。 ↩ -
出处:同章第 72-99 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:91,搜「fetchUserData」)。调用栈示例四段链照原书。 ↩ -
出处:同章第 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." ↩ -
出处:同章第 105-111 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:107,搜「pending」)。Console 中检查 Promise 状态与值照原书。 ↩ -
出处:同章第 126-153 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:128,搜「Network tab」;:134,搜「Throttling」;:136,搜「Replay XHR」)。 ↩ -
出处:同章第 197-236 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:203,搜「fetchUserData」)。嵌套错误与链式/async 两种解法照原书。 ↩ -
出处:同章第 278-309 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:285,搜「forEach」;:297,搜「Promise.all」;:303,搜「for...of」)。错误示例与两条正路照原书。 ↩ -
出处:同章第 311-346 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:317,搜「Blocking」;:333,搜「nonBlockingOperation」)。忙等示例与计时器解法照原书。 ↩ -
出处:同章第 155-157 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:157,搜「common pitfalls」)。原文:"several common pitfalls that developers often encounter"。 ↩ -
出处:「Chapter 8: Debugging Asynchronous JavaScript」第 15-58 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:52,搜「Event Loop Breakpoints」;:54,搜「Conditional」)。事件断点与条件断点的设置照原书。 ↩ -
出处:「Chapter 8: Debugging Asynchronous JavaScript」第 72-99 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:91,搜「fetchUserData」)。调用栈示例照原书。 ↩ -
出处:「Chapter 8: Debugging Asynchronous JavaScript」第 315-346 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:317,搜「Blocking」;:333,搜「nonBlockingOperation」)。阻塞与非阻塞两例的输出照原书。 ↩ -
出处:「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 面板的功能清单照原书。 ↩ -
出处:「Chapter 8: Debugging Asynchronous JavaScript」第 105-111 段(
text/11-ch08-chapter-8-debugging-asynchronous-javascript.txt:107,搜「pending」)。Console 检查 Promise 状态照原书。 (本章完。) ↩