最佳实践与反模式 — 可读性、测试、竞态的清单章¶
这一章回答一个问题: 知道了所有机制之后,"写得好"和"写得坏"的异步代码,差别到底落在哪几条可检查的项上? 读完你得到一份可以直接对着自己代码逐条打勾的清单:正面四条、反面四条、测试调试各一套、竞态一条。
1. 这一章讲什么¶
前面各章分散地讲机制,本章把它们收成一张清单。结构:
- 正面四条:让异步代码可读、可维护的写法(第 3 节);
- 反面四条:最常见的反模式,逐条"坏 → 好"(第 4 节);
- 测试与调试:Jest 异步测试、debug 库、性能计时(第 5 节);
- 竞态条件:识别与消除(第 6 节)。
2. 顶层全景:清单总表¶
| 条目 | 一句话 | |
|---|---|---|
| 正面 | 模块化/分离关注点 | 每个函数一件事,取数、编排、显示分开 |
| 正面 | 一致的错误处理 | 统一的 try/catch 或链尾 catch,HTTP 错误手动判 |
| 正面 | 命名约定 | async 函数用 fetch/get/load 开头,Promise 变量带 Promise 后缀 |
| 正面 | 优先 async/await | 嵌套 then 改写直线,除非要链的精确位置语义 |
| 反面 | 金字塔末日 | 嵌套回调/嵌套 then,回到第 02 章的病 |
| 反面 | 忽略错误 | 没有 catch 的链,失败静默失踪 |
| 反面 | 滥用 async/await | 给同步函数戴 async、await 非 Promise 的值 |
| 反面 | 丢 Promise/无谓串行 | 忘了 return 让票飞走;独立操作逐个 await |
| 横切 | 测试/调试/竞态 | Jest mock、debug 库、performance.now、数据就位再处理 |
3. 正面四条:好代码长什么样¶
3.1 模块化与分离关注点¶
反例是把一次取数+取帖+取评论全堆在一个函数里(还塞着一个 Promise.all);好的写法是每个请求一个函数,主函数只做编排1:
// 正:三个取数函数各管一件事,主函数拼结果
async function fetchUser(userId) { const r = await fetch(`/api/users/${userId}`); return r.json(); }
async function fetchPosts(userId) { const r = await fetch(`/api/posts?userId=${userId}`); return r.json(); }
async function fetchComments(postId){ const r = await fetch(`/api/comments?postId=${postId}`); return r.json(); }
async function fetchUserDataAndPosts(userId) {
const user = await fetchUser(userId);
const posts = await fetchPosts(userId);
const comments = await Promise.all(posts.map(p => fetchComments(p.id)));
return { user, posts, comments };
}
这条是第 10 章天气应用"三分法"的明文版。收益有三个:可读、可复用(取数函数别处直接拿去用)、可测(能单独测 fetchUser 而不牵动全局)。
3.2 一致的错误处理¶
统一的形状:async 函数内 try/catch,response.ok 手动判(第 05 章 5.1 的纪律),需要上层知道就再抛。原书强调的点是一致——同一个项目里错误处理只长一种样子,读代码的人不用每次重新猜2。
3.3 命名约定¶
原书给了三条具体约定3:
- async 函数用动词开头表明它是异步操作:
fetchUserProfile、getUserData、loadConfig; - 存 Promise 的变量带后缀:
userDataPromise——读到名字就知道它是票不是值,await 之前不能当值用; .then回调的参数名写明它是什么:profile、posts,不用x、data1。
命名约定看着琐碎,实际作用是把"这张票兑现了没有"这个第 03 章的心智负担,从运行时挪到了读代码时——Promise 后缀就是票的可视记号。
3.4 优先 async/await¶
同一段两连请求,Promise 版要三层缩进读链,nested 的 then 里藏着第二个请求;async 版就是两条直线4。判断标准原书没明说但全章贯彻:除非需要链式 catch 的位置语义(第 03 章 5.4 的恢复点),默认用 async/await。
3.5 一致性的三个层次¶
3.2 说"错误处理一致",一致性其实有三个层次,从易到难15:
第一层,项目内同形。 同一个项目里,错误处理只长一种样子:要么全 try/catch(async 世界),要么全链尾 catch(Promise 世界),别混。混用的代码每处都要读者重新切换心智,而"切换"正是 bug 的温床。
第二层,语义分层。 底层函数管"把失败变成错误"(抛),中间层管"分类与重试"(分诊),顶层管"对用户的交代"(界面文案)。三层各说各的话:底层不该打印"请稍后再试"(它不知道界面上是谁),顶层不该决定重试几次(它不知道底层的失败语义)。第 10 章的"事件层/取数层"分工就是这个原则的具体化。
第三层,失败也有名字。 同类的失败用同一种错误(第 04 章的自定义错误类型):网络失败永远叫 NetworkError,数据不对永远叫 ValidationError——看 message 之前先看 name,聚合、统计、看板全靠这一层。三层一致性做全,一个陌生项目的错误处理也能三分钟看懂。
3.6 async/await 优先的边界:什么时候回到链¶
3.4 说"默认 async/await",边界情况有两类,遇到就放心回到 Promise 链16:
其一,链中间的精确恢复点。 "第二步失败用默认值顶上、其他失败照常抛"这种需求(第 03 章 5.10),用 then 两参或链中间 catch 表达最精确;async 版要拆多个 try 块才能等价,反而难读。
其二,以票为值的场合。 想把"还没取的数"传来传去时(比如先发出请求、过几行才决定等不等它),async 版得把票存进变量再 await,链版直接用 then 的返回值——票本来就是链的一等公民。第 04 章 3.2 的"先存票再 all"是典型:那里真正在操作的是票本身,不是数据。
记住这条不是摇摆,而是分工:操作数据用 await(多数情况),操作票用链(少数情况)。
4. 反面四条:坏代码的四个脸谱¶
4.1 金字塔末日¶
getData(function(a){ getMoreData(a, function(b){ … }) })——第 02 章的病在纯回调和 Promise 两种形态下都会复发;出路永远是展平(链)或直线(async/await)5。
4.2 忽略错误¶
// 坏:链上没人接失败
fetch('/api/data').then(r => r.json()).then(data => console.log(data));
// 好:补 ok 检查与 catch
fetch('/api/data')
.then(r => { if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`); return r.json(); })
.then(data => console.log(data))
.catch(error => console.error('There was a problem with the fetch operation:', error));
注意"好"版比第 07 章 3.1 的纪律一多了 response.ok 检查——fetch 不认为 404 是失败,不查 ok 的 catch 只能接到网络级错误6。
4.3 滥用 async/await¶
两种典型:给同步函数戴 async(async () => "some data" 凭空造了一张票,白绕一层)和 await 非 Promise 的值(await data.toString(),await 一个立刻有结果的东西,只是多余的微任务开销)7。判断:这个函数真的需要异步吗?值真的是票吗?两个都"否"就别用记号。
4.4 丢 Promise 与无谓串行¶
丢 Promise:函数里发起了请求却忘了 return,票在函数体外不存在,调用方拿到的 undefined——失败模式是"调用方 then 了个寂寞"8。
无谓串行:三个互不依赖的请求写成三行 await,总耗时相加;正解 Promise.all 一把并行(第 06 章 3.2 的依赖判定在这里变成清单条目)9。
4.5 四个反模式的共同根源¶
回头看 4.1-4.4,四个反模式其实同根:把异步当同步写。地狱金字塔用缩进表达时序(同步的写法),忽略错误用"跑通了就完"(同步的侥幸),滥用 async 给同步逻辑戴帽子(同步的代码强行异步化),丢 Promise/无谓串行看不见"票"这个中间态(同步的思维里没有票)。每一个反模式都是"忘了这中间隔着网络"的具体姿势——所以评审清单才有用:它不审查意图,只检查"中间的票被正确对待了吗"。
4.6 反面清单的速查口诀¶
四句口诀,对应四个反模式,背下来当评审时的第一反应:
- 见到缩进在加深 → 金字塔在复活(展平它);
- 见到链上没有 catch → 失败会失踪(挂上它);
- 见到 async 帽下无 await → 帽子白戴(摘了它);
- 见到三连 await → 问一句:这三步有依赖吗?(没有就 all)。
口诀不替代判断(4.5 说根源,口诀只抓症状),但口诀能把评审的"感觉不对"变成"第几条不对"——可指认,才可修复。
5. 测试与调试¶
5.1 Jest 异步测试¶
原书给了两个测试样板:测成功路径与测失败路径10:
test('fetchUser returns user data', async () => {
global.fetch = jest.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(userData) }));
const user = await fetchUser(1);
expect(user).toEqual(userData);
});
test('fetchUser throws error for failed request', async () => {
global.fetch = jest.fn(() => Promise.resolve({ ok: false }));
await expect(fetchUser(1)).rejects.toThrow('Failed to fetch user');
});
三个要点:mock(用 jest.fn 假造 fetch,测试不真发网络);测试函数本身标 async(测试框架等它跑完再判);失败路径用 .rejects.toThrow 断言"这张票会拒"。
5.2 调试与性能¶
- debug 库(Node.js 侧):
const debug = require('debug')('myapp:fetchData'),日志可按模块开关,比 console.log 适合长期留在代码里11; - performance.now():取能测到毫秒的时刻包住一段异步操作,量耗时(第 10 章 console.time 的手动版)12;
- 浏览器 Performance 面板:录制整段运行,看异步操作的时序分布。
5.3 Jest 测试的机制:mock 到底做了什么¶
5.1 用了 jest.fn() 但没说它是什么,补一句机制,测试才不是黑盒17:
global.fetch = jest.fn(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(userData) })
);
jest.fn(实现) 造一个"假函数":调用它时执行你给的实现,同时记录"被调了几次、带什么参数"——所以测试末尾还能断言 expect(fetch).toHaveBeenCalledWith(...)。这行赋值把全局的 fetch 整个换掉:测试运行时,被测函数 fetchUser 里那句 fetch(...) 调到的就是这个假实现——这就是"mock"(假造):不真发网络、速度极快、行为完全可控(要 404 就给 ok:false)。代价是它测的是"被测函数在 fetch 行为如我所设时的表现",真实网络的千奇百怪(超时、半包、重定向)都不在场——所以 mock 测试过了,不等于联调过了。
5.4 异步测试的两个断言姿势¶
5.1 的两个测试展示了断言的两种姿势:成功用 resolves 系,失败用 rejects 系:
const user = await fetchUser(1);
expect(user).toEqual(userData); // 成功:await 之后直接比
await expect(fetchUser(1)).rejects.toThrow('Failed to fetch user'); // 失败:不能 await 完再比
失败断言为什么不能先 await?await fetchUser(1) 在失败场景下会直接把错误抛进测试函数,测试框架收到的是"测试函数自己挂了",而不是"被测函数挂了"——断言根本没机会执行。expect(...).rejects 这个写法把"期待它拒绝"变成断言对象,框架替你接住错误再比对 message。记法:rejects 配 toThrow(错误的 message),resolves 配 toEqual(返回的值)。
5.5 debug 库与 console.log 的分工¶
5.2 提到 debug 库,它与 console.log 的分工值得说清20:
require('debug')('myapp:fetchData') 造一个带名字的日志函数(名字就是它所属的模块):运行程序时设环境变量(浏览器是 localStorage 开关)决定哪些名字开、哪些关——比如只开 myapp:fetchData 与 myapp:db,其余全静默。与 console.log 的差别就在开关:console.log 要么全留要么全删,del 库是"按名字留"。长线项目的日志策略因此是:开发期 console.log 探路,稳定后换上 debug 调用入库——日志在,噪音不在。Node.js 与浏览器都能用,是这本书里唯一推荐的第三方调试工具。
5.6 performance.now() 与 Date.now() 的差别¶
5.2 用 performance.now() 计时,为什么不用更常见的 Date.now()?因为 Date.now() 测的是挂钟时间——用户可以中途改系统时间、操作系统会做时钟同步,这两个动作都会让差值出现不可能的跳变(负值或暴涨);performance.now() 测的是单调递增的相对时间(从页面/进程启动起算的毫秒数),不受系统时间影响。计时一律用它,差值相减就是耗时:
const start = performance.now();
await someAsyncOperation();
console.log(`耗时 ${performance.now() - start} 毫秒`);
第 10 章 console.time 是它的成对简化版(自动记 start);需要嵌套多段计时时,回到 performance.now() 手动记。
6. 竞态条件:识别与消除¶
原书的最小竞态例子13:
let data;
async function fetchData() {
const response = await fetch('/api/data');
data = await response.json();
}
function processData() { console.log(data); } // 可能拿到 undefined
fetchData();
processData(); // 此刻 fetch 还没回来
病在:processData 执行时,fetchData 的票还没落定,共享变量 data 还是空的。竞态的定义就藏在这里——结果取决于两个异步片段谁先完成,而顺序不受你控制。 解法也简单:把依赖写进调用关系,别写进全局变量——processData(await 取数()) 或让 fetchAndProcessData 一个函数串起两步14。
6.1 竞态的另外两个面孔¶
正文的最小竞态是"读未就位数据"。它在真实项目里还有两个常见面孔,认出来才能防18:
面孔一:后发先至覆盖。 用户连点两次"搜索",第一次的请求慢、第二次的快——按到达顺序,第二次结果先到、先渲染;几秒后第一次的结果姗姗来迟,把界面倒着更新成旧结果。解法在第 09 章 5.3(AbortController 取消旧请求)或给每次请求发序号、只接受最新序号的响应——共同思想是"旧世界的事别来打扰新世界"。
面孔二:共享计数器的丢失更新。 两个异步回调都"读计数器 +1 再写回",交错执行时后写的覆盖先写的(两个 +1 只生效一次)。单线程不代表没有竞态——只要两个片段之间隔着 await,它们的相对顺序就不由你控制。解法是别共享可变状态,把状态收进每个片段的局部。
三个面孔合起来,竞态的定义可以更准:两个片段的执行顺序不受控,而结果依赖顺序。正文解法(把依赖写进调用关系)治的是"顺序"本身;两个新面孔的解法(取消、序号)治的是"让结果不再依赖顺序"——方向相反,选择取决于业务能放弃哪一边。
6.2 清单章的使用说明¶
最后交代本章的正确用法,因为它很容易被误用成"背下来"19:
用法一:当评审单。 提交前把 3.1-4.4 的每条对着 diff 过一遍——清单的价值在"逐条",全绿之前不合并。它比"凭感觉 review"强的地方是可指认:不是"这段不太对",而是"4.3,滥用 async 第二条"。
用法二:当教学单。 带新人时,每条反模式配一个他自己代码里的实例,比讲理论快得多——4.5 说四个反模式同根("把异步当同步写"),这个根用实例讲一遍,清单就从八条口诀变成一句心法。
用法三:当重构单。 老代码的异味按清单逐条排查:先找没有 catch 的链(最致命),再找三连 await(最耗性能),最后看命名(最影响维护)。优先级就是这个顺序——错误正确性 > 性能 > 可读性。
不要的用法:把清单当考试题背。清单是拐杖,不是知识本身;知识是第 01-08 章的机制,本章只是机制的"执勤表"。
7. 作者的判断与证据¶
清单章的判断密度低但清晰:作者把"好"的定义收成可检查项(命名有没有动词、catch 有没有挂、独立请求有没有并行),而不是"感觉整洁"。这让本章可以当评审清单用——这是清单类章节能做到的最好形态。
证据上,每个反模式都有"坏/好"对照代码,且坏代码都是合法可运行的——和第 08 章五个错误同一策略:危险的写法在语法上无罪,对照即证据。
8. 边界与局限¶
- Jest 只给了两个基础样板,setup/teardown、并发测试、fake timers 这些异步测试的深水区未涉;
- 竞态一节只覆盖了"读未就位数据"一种形态;多请求乱序返回(后发先至覆盖先发的结果)这种更常见的界面竞态,正解(取消、序号)要到第 09 章的 AbortController 才拼得出来,本章没有闭环;
- 没有讲代码组织层面的大结构(异步逻辑放哪一层、与状态管理的关系),停在了函数级。
9. 可带走的¶
- 每个函数一件事:取数、编排、显示分离——可读、可复用、可测三收;
xxxPromise后缀是票的可视记号;fetch/get/load开头表明异步;- 反模式四条脸谱:金字塔、裸链(无 catch/无 ok 检查)、滥用记号、丢票+无谓串行;
- Jest 三件套:jest.fn mock fetch、async 测试函数、rejects.toThrow 断言失败;
performance.now()与 console.time 是两条计时路:手动埋点 vs 成对打标;- 竞态的本质是"结果取决于不受控的先后";解法是把依赖写进调用关系,不写成全局变量;
- 清单章的正确用法:对着自己的代码逐条打勾,而不是读完点头。
10. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 模块化正误对照 | Chapter 11: Best Practices for Asynchronous JavaScript | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:13(搜「Modularization」) · text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:31(搜「Good practice」) |
| 一致错误处理 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:56(搜「Consistent Error」) |
| 命名约定 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:88(搜「fetchX」) |
| async/await 可读性 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:116(搜「Using promises」) |
| 反模式:金字塔 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:146(搜「Pyramid of Doom」) |
| 反模式:忽略错误 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:186(搜「Ignoring Errors」) |
| 反模式:滥用记号 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:206(搜「Unnecessary」) |
| 反模式:丢 Promise/无谓串行 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:231(搜「Forgetting to Return」) · text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:251(搜「Sequential Execution」) |
| Jest 测试 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:283(搜「Jest」) · text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:315(搜「rejects」) |
| debug 库与性能 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:346(搜「debug」) · text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:368(搜「performance.now」) |
| 竞态识别与消除 | 同上 | text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:379(搜「Race Conditions」) · text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:389(搜「undefined」) |
-
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 19-54 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:21,搜「Bad practice」;:33,搜「Good practice」)。坏/好两版代码照原书。 ↩ -
出处:同章第 56-82 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:58,搜「Consistent」)。原文:"Consistent and thorough error handling… aids in debugging and maintenance." ↩ -
出处:同章第 84-110 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:88,搜「fetchX」;:90,搜「Promise」)。三条命名约定照原书。 ↩ -
出处:同章第 112-140 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:112,搜「Improved Readability」)。两版 fetchUserData 对照照原书,"eliminates the need for nested .then() calls"。 ↩ -
出处:同章第 144-184 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:146,搜「Pyramid of Doom」)。三种逐级解法(链、async/await)照原书。 ↩ -
出处:同章第 186-204 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:190,搜「Ignoring errors」)。坏/好两版对照照原书,好版含 response.ok 检查。 ↩ -
出处:同章第 206-229 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:210,搜「Unnecessary async/await」)。两种滥用与正解照原书。 ↩ -
出处:同章第 231-249 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:235,搜「Not returning the promise」)。忘了 return 的反模式与正解照原书。 ↩ -
出处:同章第 251-273 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:255,搜「Sequential」)。独立操作串行 vs Promise.all 并行对照照原书。 ↩ -
出处:同章第 277-318 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:295,搜「fetchUser returns」;:315,搜「rejects」)。两个 Jest 测试与 mock 写法照原书。 ↩ -
出处:同章第 320-362 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:346,搜「debug」)。debug 库的用法照原书。 ↩ -
出处:同章第 364-376 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:368,搜「performance.now」)。performance.now 计时骨架照原书。 ↩ -
出处:同章第 378-393 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:381,搜「data」)。let data 共享变量竞态例子照原书。 ↩ -
出处:同章第 395-407 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:397,搜「fetchAndProcessData」)。"ensure that data is available before using it"与串行化解法照原书。 ↩ -
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 56-82 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:58,搜「Consistent」)。"consistent and thorough error handling" 照原书;三层一致性是对其的展开。 ↩ -
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 112-140 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:114,搜「async/await syntax」)。async/await 优先的立场照原书;边界情形是应用层的补充判断。 ↩ -
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 283-318 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:297,搜「jest.fn」)。mock 全局 fetch 的写法照原书。 ↩ -
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 378-407 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:389,搜「undefined」)。最小竞态例子照原书;另两个面孔是对"顺序不受控"定义的应用层展开。 ↩ -
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 7-11 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:7,搜「best practices」)。"best practices, common pitfalls, and essential techniques" 的章旨照原书。 (本章完:正面四条、反面四条、测试调试竞态,全在这了。) 拿它对着代码逐条打勾,比任何讲解都管用。 这也是本章被放在实战项目之后的原因:先犯过错,清单才长眼睛。 下一章:带着这张清单,去看框架里的异步长什么样。 (本章是清单,不是终点;清单上的每条都该在真实代码里挨过一次打。) ↩ -
出处:「Chapter 11: Best Practices for Asynchronous JavaScript」第 346-362 段(
text/14-ch11-chapter-11-best-practices-for-asynchronous-javas.txt:348,搜「debug」)。debug 库的命名空间用法照原书。 ↩