为什么 JavaScript 必须是异步的 — 问题、进化线与现代场景¶
这一章讲三件事: 为什么「等待」是网页程序的头号敌人;JavaScript 的三代异步写法各自解决了上一代的什么痛点;以及这些写法在真实项目里长什么样。 它是全书地图:后面十二章每一章,都是把本章点到名字的那个机制单独拆开讲透。
1. 这一章讲什么¶
JavaScript 有个先天的拧巴:它一次只能执行一件事(原书的说法是"single-threaded",单线程——一条执行流水线,而不是好几条),可它活在的地方——浏览器、服务器——却到处都是"等":等网络、等磁盘、等用户点按钮1。
引言的任务就是把这个拧巴讲清楚,并给出全书的总路线:
- 先说明为什么不能让程序傻等(第 2 节);
- 再沿时间线走一遍三代机制的进化(第 3 节)——每一代都不是白来的,是被上一代的烂摊子逼出来的;
- 最后把六个最常见的使用场景摆出来(第 4 节),让你知道学完这本书能干什么。
2. 顶层全景:一个输入,两种走法¶
本章的主线例子是引言里的天气应用:要显示一座城市的天气,得问三个服务器接口——当前天气、五天预报、空气质量2。
输入:「显示纽约的天气」
同步写法: 异步写法:
① 取当前天气 ──等它返回──┐ ① 三个请求同时发出,谁也不等谁
② 取五天预报 ──等它返回──┤ ← 界面冻住 ② 界面照常响应
③ 取空气质量 ──等它返回──┤ ③ 三个都到了,一次性显示
④ 拼起来显示 ←──┘
图说:同一件事,同步是"排成一列挨个等",异步是"一口气全发出去,齐了再收"。
用具体的数走一遍(这些耗时是为演示编的,不是实测值):假设三个接口各要 1 秒。
- 同步写法:1 秒 + 1 秒 + 1 秒 = 3 秒后界面才动;而这 3 秒里页面是死的,点哪都没反应。
- 异步写法:三个请求同时在路上,总共还是约 1 秒;等待期间界面完全活着。
两种写法的代码并排看,差距一眼可见。同步版:
function getWeatherData() {
const currentWeather = fetchCurrentWeather(); // 返回前,后面一句都执行不到
const forecast = fetchForecast();
const airQuality = fetchAirQuality();
displayWeatherInfo(currentWeather, forecast, airQuality);
}
异步版:
async function getWeatherData() {
try {
const [currentWeather, forecast, airQuality] = await Promise.all([
fetchCurrentWeather(),
fetchForecast(),
fetchAirQuality()
]);
displayWeatherInfo(currentWeather, forecast, airQuality);
} catch (error) {
handleError(error);
}
}
异步版多出来的东西只有三样:async/await 两个记号、Promise.all(把三个请求捆成一批)、还有包在外面的 try/catch(任何一个请求失败都能接住)。这三样就是这本书的三个主角,后面第 05、03、04 章分别讲透3。
这一章往后,每个机制都拿这条天气主线来落一步:回调解决"等"(第 02 章),Promise 解决"组合"(第 03、04 章),async/await 解决"读"(第 05 章)。
把两条路径的输出时间线并排再压一遍(时刻是为演示编的):
同步时间线: 异步时间线:
0.0s 发出请求1,干等 ───────┐ 0.0s 请求1、2、3 同时发出
1.0s 请求1 返回,发请求2 ───┤ 1.0s 请求1 到达 ─┐
2.0s 请求2 返回,发请求3 ───┤ 1.2s 请求3 到达 ├─ 都到了才拼页面
3.0s 请求3 返回,显示页面 ←─┘ 1.5s 请求2 到达 ┘ → 1.5s 显示页面
(全程界面冻结,点哪都没反应) (全程界面活着;总耗时取决于最慢的那个请求)
两条时间线的差距就是异步的两笔收益:总耗时从"相加"变成"取最大",等待期间界面从"冻住"变成"活着"。后面所有章节教的机制,最终都在改进这两件事之一。
2.2 同步代码并没有错:什么时候不用异步¶
全书都在教异步,但有一句公道话要说在前面:同步不是错的,傻等才是。判断标准还是那个字——"等"30:
- 计算(排序、统计、拼字符串):不等任何外物,同步写最直接,套 async 反而绕;
- 内存里的数据(读一个已经加载好的对象):纳秒级,不值得异步的仪式;
- 启动期的串行初始化(先读配置才能建连接):有依赖、且必须完成才能继续——同步 or await 串行,都可以。
引言的天气应用之所以"必须异步",是因为它在等不可控的外物(三个服务器的响应时间谁也说不准);而初始化之所以"可以同步",是因为它等的是自己上一步的必然结果。一句话:等的对象越不可控,越该异步;等的对象越确定,越该直写。 这个判据在第 06 章会升级成"依赖判定"(3.2),在那里它决定的是一个接一个还是同时发。
2.1 天气主线的错误分支:同步与异步的第二个差距¶
第 2 节只对比了耗时与界面。异步版还多一件同步版给不了的东西:任何一环失败都能被单独接住。把两个版本各加一个失败场景(假设"空气质量"接口挂了)走一遍28:
同步版:请求1 等 1s → 请求2 等 1s → 请求3 报错 → 异常向上抛
用户看到:整个函数崩了,什么都没有——前 2 秒白等
异步版:三个请求同时在途 → 请求3 的票拒绝 → Promise.all 整体拒
→ catch 接住 → 可以:报错,或用前两个结果顶上(残缺的展示胜过空白)
同步代码里失败只有"抛异常"一条路,抛了就是整条调用链陪葬;异步代码里失败是"一张票被拒",接不接、在哪接、接了干什么,都是代码自己说了算。这个差距就是 3.1 六条理由里"错误处理与兜底"的来源——异步不只是让等待不挡路,还让失败可以分级处理。第 07 章的三层防线(旧数据/默认值/离线页)全建在这个能力上。
3. 核心原理¶
3.1 为什么「等」是必须解决的问题¶
引言用了一个咖啡店比方:同步的世界像只有一种窗口的咖啡店——排在你前面的每个人都要从点单到取餐全程占着窗口,你只能干等4。异步的世界则像拿号等餐:点完单去旁边坐着,餐好了有人叫号,窗口全程不闲。
落到网页上,引言列了六条理由,每条都可以翻译成一句"等不起"5:
| 理由 | 「等不起」翻译 |
|---|---|
| 用户体验 | 请求数据的几秒钟里,页面如果整个冻住,用户只会认为它坏了 |
| 性能 | 互不依赖的请求一个一个发,总耗时是相加;同时发,总耗时取决于最慢那个 |
| 硬件利用 | 等待期间处理器空转,就是浪费;异步让等待的时间去干别的活 |
| 可扩展性 | 服务器要同时服务几千个用户,挨个等谁都撑不住 |
| 实时能力 | 聊天、协作编辑这类功能要求"边等边收发",根本没法顺序写 |
| 出错有兜底 | 请求失败是常态(网络说断就断),异步写法自带处理失败的路子 |
注意前两条经常被混为一谈,其实不同:"不冻界面"是体验问题,"总耗时变短"是效率问题。 一个慢请求即使只发一个,异步也能保住界面不冻;而三个快请求顺序发,省不了时间。这是两个独立的收益,后面判断"该不该一起发"时会反复用到(第 06 章)。
3.1a 六条理由的逐条深挖:每条对应一类真实事故¶
3.1 的六条理由表压得很扁,这里把每条背后那类"等不起"的事故摊开,读完会对"为什么必须异步"有体感23:
用户体验的那类事故叫"假死":用户点了一下查询,页面整个冻住,进度条不动、按钮不弹——八成用户在这时候会再点几下,然后关掉页面。异步的第一个承诺就是"永远能点"。
性能的那类事故叫"排队买票":十个互不依赖的接口挨个发,总耗时是十倍;同时发,总耗时约等于最慢那一个。一个不并行化的仪表板,加载时间从 1 秒变 8 秒,没有任何一行代码是错的。
硬件利用的那类事故在服务器端更直观:同步等待期间,处理器空转;一个要同时服务几千人的服务,如果每个请求都傻等磁盘,等于雇了一千个柜员却只有一个窗口。
可扩展性是前三条的合计:用户从一百涨到十万时,同步架构的响应时间随之变慢,异步架构能把同样的硬件撑大两个数量级——这也是 Node.js(服务端 JavaScript)存在的全部理由。
实时能力的事故最直白:聊天、协作文档、股票行情,这些功能的共同点是无法"攒齐再发"——消息是一条一条来的,必须一边等一边收。
错误与兜底的事故叫"一损俱损":同步链里任何一个环节失败,整个调用栈崩掉,前面成功的部分全部陪葬;异步写法从结构上就要求你考虑"这一步失败怎么办"(第 07 章整章在答)。
六条理由其实可归并成两句:对用户——别冻;对机器——别闲。 后面所有机制,都能追问"它在这两句里帮了哪句"。
3.2 第一代的答案:回调,以及它的三个坑¶
既然不能等,最早的办法是:启动一个耗时操作时,顺手交一个函数过去,等操作完了让它替你收尾。这个被交过去的函数就叫回调(callback,原书定义为"传给另一个函数、在那个函数完成后执行的函数")6。
console.log("Starting the timer...");
setTimeout(function() {
console.log("Timer finished!");
}, 2000);
console.log("Timer started, continuing execution...");
输出顺序是:Starting the timer... → Timer started, continuing execution... →(2 秒后)Timer finished!。注意第二行写在回调之后,却先执行——这就是非阻塞:启动定时器那一行不等结果,接着往下跑7。
回调管用,但引言点了它的三个坑,这三个坑就是后面两代机制的存在理由8:
- 回调地狱(callback hell,又叫"末日金字塔"):步骤一多,回调就得套回调,代码一层层往右缩,读的时候要在屏幕上左右横跳;
- 错误处理啰嗦:每一步都可能失败,每一步都得自己写失败分支,漏一个就是静默失败;
- 控制权易手(inversion of control):你把函数交给别人(比如某个库),什么时候调、调几次、传什么进来,就不由你说了算——行为可能出乎预料。
3.2a 回调年代的日常:它能活十几年,靠的不是优雅¶
回调地狱这么糟,为什么 JavaScript 的前十年全靠它?因为在应用小的时候,它确实够用32:
- 点一个按钮发一个请求:回调的完整形态就两层,谈不上地狱;
- 定时隐藏一个提示:三行 setTimeout,优雅得很;
- 事件监听(点击、滚动):回调至今仍是这件事的标准写法(第 02 章 6.7)。
地狱只在"多步流程"出现时降临——而 2010 年代的网页恰恰从"页面+点缀"长成了"应用":一个动作背后是五个接口、两次校验、一步跳转。回调的形状跟不上应用的成长,不是回调变坏了,是问题长大了。进化链的每一代都是"旧工具没坏,只是不再合身"——记住这一点,就不会迷信下一代:今天的 async/await 也在等它的问题长大(第 09 章的 Observable 与"异步的来龙去脉追踪",就是已经长出来的部分)。
3.3 第二代的答案:Promise,把「未来的结果」变成对象¶
ES6(2015 年那版 JavaScript 标准)引入了 Promise。把它想成一张取餐小票:点单时不给你披萨,给你一张票;票有三种状态——做着的(待定)、取到餐的(已兑现)、出问题的(已拒绝);这张票本身是个东西,可以传来传去、可以拿去兑换9。
相对回调,Promise 带了三样新能力10:
- 链式(chaining):上一步的票可以接下一步,代码重新变成从上往下读的一列;
- 统一的错误处理:整条链最后挂一个
.catch(),任何一步失败都会汇到那里; - 组合(composition):
Promise.all这类方法能把好几张票捆成一张——天气应用的三个请求就是靠它同时发出的。
fetchUserData(123)
.then(user => {
console.log("User data:", user);
return fetchUserPosts(user.id); // 返回下一张"票"
})
.then(posts => console.log("User posts:", posts))
.catch(error => console.error("An error occurred:", error));
3.3a Promise 的三个能力,逐个演示¶
3.3 说 Promise 带来链式、错误处理、组合三样能力,引言给了一段代码把三样全用上,逐个拆25:
function fetchUserData(userId) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const user = { id: userId, name: "John Doe" };
resolve(user);
}, 1000);
});
}
fetchUserData(123)
.then(user => {
console.log("User data:", user);
return fetchUserPosts(user.id); // 能力一:链式——return 下一张票
})
.then(posts => {
console.log("User posts:", posts);
})
.catch(error => { // 能力二:统一错误处理
console.error("An error occurred:", error);
});
这段代码里的每一行都在回答回调的一个痛点:return fetchUserPosts(user.id) 取代嵌套——下一步不再写在上一层的肚子里,而是接着上一行往下排;.catch 取代每层手写的 if (error)——不管链上哪一步失败,错误自己滑到链尾这一格。能力三(组合)在这段里还没现身,但已经有了伏笔:fetchUserData 返回的是票而不是数据,票能存能传,就为"把好几张票捆一捆"留好了接口——天气主线的 Promise.all 就是能力三的第一秀。
3.3b 从这段代码再退一步:Promise 是怎么造出来的¶
同一段代码里还藏着造票的模板,new Promise((resolve, reject) => { ... }) 这一行的读法26:
new Promise是造票动作,造出来的是"一张待定的票";- 括号里那个函数叫执行器——它在造票的同一刻立刻执行(不是等一秒后才执行),里面通常包着一件异步的事(这里是 setTimeout);
- 执行器拿到两个出口:
resolve(办成,把结果盖章在票上)与reject(办砸,把原因盖章在票上); - 一秒后
resolve(user)被调用,票从"待定"变成"已兑现",拿着这张票的.then就会收到那个 user 对象。
reject 在这段示例代码里没用到,但它是票能"报错"的原因——没有它,异步操作的失败就只能靠抛异常(而异步回调里抛的异常没人接得住,这正是回调时代错误处理难的根本原因)。一个出口管成功、一个出口管失败,票把"结果"和"失败"都变成了可以传递的数据——这句话是整个 Promise 革命的压缩包,第 03 章把它展开成整章。
3.4 第三代的答案:async/await,把票换成直白的句子¶
Promise 把结构理顺了,但代码里还是满屏 .then,读起来仍不像"先干这、再干那"。ES2017(2017 年那版标准)加了 async/await 两个记号,把同样的意思写成几乎和同步代码一模一样11:
async function getUserData(userId) {
try {
const user = await fetchUserData(userId); // 停在这一行,等票兑现
console.log("User data:", user);
const posts = await fetchUserPosts(user.id);
console.log("User posts:", posts);
} catch (error) {
console.error("An error occurred:", error);
}
}
getUserData(123);
三个要点先记住,细节在第 05 章:async 标在函数上,这个函数就永远返回一个 Promise;await 只能用在 async 函数里,意思是"停在这里等这张票";错误回到所有语言都熟悉的 try/catch 写法12。
3.5 还没完:引言点到的四个新方向¶
引言最后列了四个"正在进行时"的方向,它们各自在第 09、12、13 章展开13:
| 方向 | 一句话 | 详见 |
|---|---|---|
| 异步迭代——数据一次吐一点、逐条取的写法——与生成器 | 数据源一次吐一点(比如不断到来的消息),可以一条条 for 着取 |
第 09、13 章 |
| Observable 与 RxJS | 把"随时间到达的一串值"也做成可以运算的对象,事件的代数 | 第 09、12 章 |
| Web Worker | 开真正的第二条线程干重活——异步解决"等",线程解决"算" | 第 09、12 章 |
| top-level await | 在模块顶层(不在任何函数里)直接写 await,省去包一层的仪式 |
第 09 章 |
3.5a 四代之后的现代形态:引言点到名的四件新东西¶
3.5 的方向表之外,引言还给了一段"进化仍在继续"的清单,这里把四件逐一压成一句人话19:
- 异步迭代器与生成器:数据不是一次到齐、而是一点一点来(比如不断收到的消息),可以像翻页一样一条条 await 着取——第 09 章 5.2 的
for await...of是它的语法脸; - Observable(RxJS):把"随时间陆续到达的一串值"做成能运算的对象——搜索框"边打字边查、还能随时停"这种需求,用它三行写完,用 Promise 要写一堆计时器;
- Web Worker:真正的第二条流水线——异步管"等",它管"算",重计算(滤镜、大表格、加密)交给它,界面照常流畅;
- top-level await:在模块最外层直接写 await,省掉"包一层 async 函数"的仪式——第 09 章 5.1 展开。
四件东西的共同点:它们补的都是 Promise/async-await 主干之外的角——主干(第 02-08 章)学完,这些角按需再装。
3.6 三代并排:同一个任务,三种写法¶
把接力链压缩成一张对照表。任务还是那个任务:先取用户,再取这个用户的帖子16。
回调版(第 02 章的写法):
fetchUserData(userId, (error, user) => {
if (error) { /* 处理 */ return; }
fetchUserPosts(user.id, (error, posts) => {
if (error) { /* 又要处理一次 */ return; }
// 终于拿到 posts
});
});
Promise 版(第 03 章的写法):
fetchUserData(userId)
.then(user => fetchUserPosts(user.id))
.then(posts => { /* 拿到 posts */ })
.catch(error => { /* 一处兜住所有失败 */ });
async/await 版(第 05 章的写法):
async function load(userId) {
try {
const user = await fetchUserData(userId);
const posts = await fetchUserPosts(user.id);
// 拿到 posts,代码从上往下读
} catch (error) { /* 一处兜住所有失败 */ }
}
三张脸摆在一起,三代机制的差别不需要任何术语就能看清:
| 回调版 | Promise 版 | async/await 版 | |
|---|---|---|---|
| 顺序怎么表达 | 缩进(嵌套) | return(链) | 行的先后(直线) |
| 错误在哪接 | 每层手写 | 链尾一处 | catch 一处 |
| 代码形状 | 三角形 | 一列 | 一段 |
| 读起来的感觉 | 追剧情 | 对账单 | 说明书 |
书的后面九章,就是把这三行表格的每一格拆开来教:回调为什么这么难读(第 02 章)、链凭什么能串(第 03 章)、直线底下发生了什么(第 05 章)。
3.6a 每代机制欠下的新债¶
进化链不是"新的淘汰旧的"的单线胜利,每一代还清旧债的同时都欠下新债,认清这些债才知道"为什么还要有下一代"29:
| 代际 | 还清的旧债 | 欠下的新债 |
|---|---|---|
| 回调 | 等待挡路 | 嵌套、错误分支啰嗦、控制权易手 |
| Promise | 嵌套与组合 | 代码仍是 .then 链,读起来要"追链";没有取消;错误恢复的位置含义要记两套 |
| async/await | 可读性、错误回到 try/catch | await 天生串行(要同时发出、齐收,得显式说);循环里逐个 await 的性能陷阱;仍然没有取消 |
这张表解释了书的后半本在忙什么:第 04 章的组合方法在还"要同时发得显式说"的债,第 09 章的 AbortController 在还"没有取消"的债,第 08 章错误四在还"循环里逐个 await"的债。债还不清,因为需求在长——这也是为什么这本书的终点是"未来方向"而不是"完结":异步的进化史至今没有写完。
3.7 进化线的时间坐标¶
引言把这段进化按年代排好了,这里压成一条时间线,后面每一章都会落回它17:
| 年代 | 发生的事 | 本书哪章讲 |
|---|---|---|
| 语言早年 | 回调是唯一选项,应用一大就陷进回调地狱 | 第 02 章 |
| 2015(ES6) | Promise 进标准,"未来的结果"第一次成为对象 | 第 03、04 章 |
| 2017(ES2017) | async/await 进标准,异步代码写成直线 | 第 05 章 |
| 2020(ES2020) | Promise.allSettled 进标准,"个个有交代" |
第 04 章 |
| 2021(ES2021) | Promise.any 进标准,"第一个成功" |
第 04 章 |
| 2022(ES2022) | top-level await 进标准,模块顶层能写 await | 第 09 章 |
| 进行中 | Observable、"异步来龙去脉追踪"等仍在提案或库中 | 第 09、12 章 |
注意这张表的节奏:前三代隔了好几年,后四件全是 2020 年之后补齐的。 这说明 Promise/async/await 的主干在 2017 年就定型了,之后的标准化全在补边角(取消、顶层 await、错误链)。读这本书时记住这个节奏,就知道哪些是"主干必须懂",哪些是"边角按需查"。
3.4a async/await 的句子化:同一功能的呼吸感对比¶
3.4 给了 async 版的样子,把引言里前后两段代码并排读一遍,"读起来像同步"是什么意思就有体感了27:
// Promise 版:动作与回调交替出现
fetchUserData(userId)
.then(user => {
console.log("User data:", user);
return fetchUserPosts(user.id);
})
.then(posts => {
console.log("User posts:", posts);
})
.catch(error => console.error("An error occurred:", error));
// async/await 版:动作按发生顺序排列
async function getUserData(userId) {
try {
const user = await fetchUserData(userId);
console.log("User data:", user);
const posts = await fetchUserPosts(user.id);
console.log("User posts:", posts);
} catch (error) {
console.error("An error occurred:", error);
}
}
getUserData(123);
逐行对比:两版的语句数几乎相同,但 Promise 版的"什么时候做什么"要沿着 .then 的链去追,async 版的"什么时候做什么"就是从上到下的阅读顺序。引言对两版的评语一针见血:async/await 版"not only more concise but also reads almost like synchronous code"——代码的形状终于跟时间的形状一致了。要提醒的是"几乎"二字:底层跑的还是 Promise(async 函数永远返回票),await 只是把"等票"写成了"停一行"。所以第 05 章第一句就强调:async/await 没让代码同步执行,它只是让代码读起来同步——这个差别在"同时发"与错误处理的细节里会反复救你的命。
4. 这六个场景,就是异步的主战场¶
引言用六段样板代码展示了异步的典型用途,每段的结构都一样:发起 → 等待 → 处理,差别只在等待的是什么14:
- API 请求:用 Fetch 这个浏览器内置的网络请求接口取用户数据,这是异步的绝对主力;
- 文件操作:Node.js 环境读配置文件——读盘很慢,必须异步,否则几百兆的文件能让服务卡死;
- 数据库操作:查订单,附带
finally收尾断开连接(连接用完要还,不管成功失败); - 定时器:
setTimeout本身就是异步的,包一层 Promise 就能用await写"番茄钟"这种顺序逻辑; - 用户输入:搜索框边打字边查——为了不每敲一键就发一次请求,书里给搜索函数套了防抖(debounce:停手 300 毫秒没新输入才真正发请求);
- 动画:用
requestAnimationFrame(浏览器每画一帧前回调一次的接口)做淡入,一帧一帧推进。
下面把前三个场景逐一走一遍——它们是后文所有章节主线例子的来源。
场景一走查:API 请求。 引言的取用户例子有一个细节值得现在就知道:fetch 拿到响应后,服务器返回的其实是一段文本,还要再调一次 response.json() 才能变成能用的对象;而且 HTTP 层面的失败(404、500)fetch 不认为是错误,要自己查 response.ok18。这两件事每个真实项目都会撞上,第 05 章会专门讲。
场景二走查:文件操作。 Node.js 读配置文件的例子展示的是"异步的接力":读文件 → 把文本转成对象 → 启动应用。三步里前两步都可能失败(文件不存在、内容不是合法格式),所以整段包在 try/catch 里。关键认识:磁盘比网络慢得多——读一个几兆的配置文件,同步写法能让服务器白等几十毫秒,而这几十毫秒里它本可以处理别的请求。
场景三走查:数据库操作。 查订单的例子多了一个新结构 finally:不管查询成功还是失败,await db.disconnect() 都要执行——数据库连接是稀缺东西,借了必须还,不能因为出错就赖着不还。finally 就是"成败都跑"的那一段,第 03 章细讲。
场景四到六的共同点:定时器是异步的"原住民"。 番茄钟、防抖搜索、逐帧动画,三个场景都不涉及网络,全靠 setTimeout 和 requestAnimationFrame 这两个计时接口。它们说明一件事:异步不等于"网络请求",任何"到点再叫我"的需求都是异步——这正是第 01 章事件循环的主场。
判断(我们的,不是书里的): 这六段里藏着一条选型线——等待外物(网络、磁盘、用户)用异步,消耗处理器(大量计算)用 Web Worker。 新手最容易犯的错是把重计算也塞进异步回调里,那救不了界面:计算不拆成小份交回队列,它占着唯一的执行流水线,谁都别想跑。这条线在 3.5 的 Worker 一节才真正闭合。 如果错,会错在: 如果这个分类里还有第三种负载(比如大量内存拷贝)两条路都治得不好,这条二分法就会漏掉它;但就入门而言它覆盖九成场景。
4.1a 场景四走查:番茄钟与"循环里的 await"¶
引言的番茄钟例子(pomodoro)是"循环 + await"的第一次露面,值得提前走一遍20:
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function pomodoro(workDuration, breakDuration) {
while (true) {
console.log('Work session started');
await sleep(workDuration); // 工作时长,期间流水线照常服务别人
console.log('Break time!');
await sleep(breakDuration); // 休息时长
}
}
这个 10 行小函数藏着两个后面反复出现的技术:其一,sleep 就是第 06 章 5.1 那个"setTimeout 包一层 Promise"的原型;其二,while(true) 里每轮两个 await——循环每转一圈就出让流水线两次,这是"长驻流程不卡界面"的标准姿势。它的缺陷也明显:无限循环没有出口(Ctrl+C 或关页面才停),没有错误处理——作为演示是完美的最小形态,作为产品还要加第 06、07 章的那些环。
4.1b 场景五走查:防抖搜索的时间线¶
搜索框例子是引言六个场景里最"工程"的一个,把它完整走一遍21:
async function handleSearch(event) {
const query = event.target.value;
if (query.length < 3) return; // 太短不搜
resultsContainer.innerHTML = 'Searching...';
const results = await searchAPI(query);
displayResults(results);
}
function debounce(func, delay) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId); // 作废上一个还没响的定时
timeoutId = setTimeout(() => func.apply(this, args), delay);
};
}
const debouncedSearch = debounce(handleSearch, 300);
searchInput.addEventListener('input', debouncedSearch);
链路是这样的:每次按键触发 input 事件 → debounce 包了一层,先把上一个还没响的 300ms 定时作废,再重新起一个新的 → 用户停手满 300ms,定时器才放 handleSearch 真正执行。效果时间线(时刻为演示编):用户在 t=0、150、280 毫秒连敲三键——前两个定时器都在响起前被作废,t=580(280+300)才发出唯一一次搜索。
防抖解决的是一个非常具体的浪费:一次搜索没敲完就发请求,既烧服务器又抖动界面。它的实现不依赖任何异步高级特性,就是一个"每次重设定时器"的小机关——但它常与 async 组合(这里的 handleSearch 就是 async),所以出现在引言的异步清单里。第 12 章 5 节会见到它在 RxJS 里的形态:debounceTime(300) 一个算子,把这一整套机关收成一道工序。
4.1c 场景六走查:动画为什么是异步问题¶
淡入动画的例子用 requestAnimationFrame(浏览器每画一帧前回调一次的接口)做逐帧推进22:
async function animateElement(element, targetOpacity, duration) {
const startOpacity = parseFloat(getComputedStyle(element).opacity);
const startTime = performance.now();
function animate(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1); // 0 → 1
element.style.opacity = startOpacity + (targetOpacity - startOpacity) * progress;
if (progress < 1) {
requestAnimationFrame(animate); // 没播完 → 约 16ms 后下一帧再叫我
}
}
requestAnimationFrame(animate);
return new Promise(resolve => setTimeout(resolve, duration));
}
两个层次。回调层次:animate 被 requestAnimationFrame 一帧帧地回调,每帧按"已播进度"重算透明度——这是事件循环在驱动动画,与 setTimeout 驱动番茄钟同构。Promise 层次:函数还返回了一张票(包着 setTimeout 的 duration 时长),调用方可以 await animateElement(...) 等它播完再播下一个——引言用它串起"元素逐个淡入"的队列。同一个函数,既是回调的消费者,又是票的制造者——这是三代机制共存的活样本,也是"async 函数永远返回 Promise"(第 05 章 3.1)的直观展示:return 的票跟动画的回调各走各的,互不相干。
4.2 六场景的分层:必会、常见、特殊¶
六个场景不必等量齐观,按出现频率分三层33:
必会层(天天见):API 请求与定时器。 几乎每一页动态网页都同时用着这两者:数据是取来的,交互反馈是计时器给的。书里给它们的篇幅也最多——第 03-06 章的所有主线例子,本质都是这两个场景的变体。
常见层(常遇见):用户输入与文件操作。 搜索防抖、上传下载、读写配置——每个完整应用都有,但写法可以直接套用第 06 章的样板,不必每次重想。
特殊层(按需查):数据库与逐帧动画。 数据库操作在服务端(Node.js)才是日常,前端只在 IndexedDB 一类场景碰到;动画则有自己的工具链(CSS 动画、Web Animations API),requestAnimationFrame 手写只是底层。这两层知道"书里有、用时回来翻"就够。
分层的意义是安排精力:先把必会层练到闭眼能写,再让常见层有模板可抄,特殊层保持"知道存在"。这也对应第 13 章速查卡的组织方式——卡片不平均分配篇幅,而是按"你会多常用它"分配。
5. 作者的判断与证据¶
引言是宣言性质的一章,没有实验证据,它的"证据"是两类:
一类是可复现的小代码。 3.2 节那个定时器例子就是:任何人把五行代码贴进浏览器控制台,都能看到输出顺序和直觉相反。这类证据门槛低但说服力强,因为读者自己动手验证了。
一类是类比。 交响乐团(每件乐器不用等前一件奏完)、咖啡店(拿号不占窗口)。类比的作用是把"多个操作同时在途"这个抽象词锚到日常经验上。
引言还有一处隐性论证值得点破:它反复用天气应用这类"一个界面等好几个接口"的场景,而这恰恰是回调最不擅长、Promise 最擅长的形状。引言其实是用场景选择提前给后面的进化链投了票——如果你读完第 02 章觉得回调"其实也够用",回到引言再看一眼天气应用的三接口例子,就会明白作者为什么需要 Promise。
引言还对全书的产出做了许诺:读完能懂事件循环、能用三代写法、能处理常见场景、能调试和调优、能做实战项目15。把许诺逐条对回后十二章,基本都兑现了——兑现表见总纲末尾。
5.1a 引言的八项学习许诺,与后十二章的对应账¶
引言末尾给了一张"你将学到什么"清单,八项许诺——把它与后十二章逐一对账,是检验这本书有没有注水最快的办法24:
| 引言的许诺 | 兑现于 |
|---|---|
| 事件循环与同步/异步执行的区别 | 第 01 章整章 |
| 回调与回调地狱的应对 | 第 02 章 |
| Promise 的链式、错误处理与高级方法 | 第 03、04 章 |
| async/await 简化异步代码 | 第 05 章 |
| 常见异步场景的实战与调试 | 第 06、08、10 章 |
| 性能调优与错误追踪 | 第 07、11 章 |
| 框架中的异步模式 | 第 12 章 |
| 异步的未来方向 | 第 09 章 |
八项全部有主,没有空许的诺。两项值得多说:其一,"调试"在引言里只是一行字,实际兑现为第 08 章整章加第 04、11 章的调试节——分量比许诺的重;其二,"框架中的异步"许诺得大(React/Vue/Angular/Next.js 四个名字),兑现得最轻(第 12 章一节入门片段),这是全书的许诺与兑现之间唯一明显失衡的一处,我们已在书卡 notCovered 里如实写明。
5.2 证据的另一面:引言没给数字¶
引言的论证全靠类比与示例代码,没有一个实测数字——三个接口各 1 秒是设想的,不是测的。这符合入门书的定位(真实接口会过期、需要 key),但读者要清醒:"异步约 1 秒"的前提是三个接口真的独立、服务器真的同时处理。真实世界会打破这两个前提:接口之间可能有隐藏的依赖(空气质量接口要用天气预报的数据),服务器可能对同一个调用方限速(第 06 章限流器)。所以第 2 节的时间线请当"结构示意"读——形状是对的,数字永远要实测。
5.3 与咖啡店的最后一笔账¶
第 2 节的咖啡店类比还有最后一笔账要还:异步并不等于"同时在做"31。吧台只有一个咖啡师(单线程),他同一时间只能做一杯——异步的魔法在叫号系统,不在人手:他接了 A 的单开始做,A 的奶在加热时他叫 B 的单,B 的奶在加热时他去拉 A 的花……每一杯的实际制作时间没有变短,变的是任何一杯都没让别人干等。这就是"非阻塞"的全部真相:
所以"异步更快"这句话要加脚注:快的是总等待的消除,不是每件事本身变快。单件事的耗时(一杯咖啡的制作)在异步世界里分毫未减——有时候还略增(叫号系统本身有开销,await 与回调的调度也要花指令)。这个认知到第 06 章重试四版账上会再次用上:退避让总耗时变长,换来的是成功率——时间花在哪儿,永远是设计决策,不是免费午餐。
6. 边界与局限¶
- 引言的异步天气例子用了
Promise.all,但没解释它为什么能让三个请求同时发出、以及任何一个失败会发生什么——这两个问题分别在第 03、04 章回答; - "单线程"在这里是结论不是推导,事件循环怎么让单线程"同时"推进多件事,要等第 01 章才拆;
- 天气主线还有一个故意没答的问题:
Promise.all捆起来的三个请求,任何一个失败时整体会怎样?残缺的两个结果能不能用? 这个问题把"组合"与"错误"两个主题焊在一起,第 04 章 3.1 的"一拒全拒"规则和allSettled就是它的两个相反答案; - 引言的六个场景各自只给了一段代码,没有讲它们之间怎么串——真实应用是"取数→处理→再取数→展示"的混编,串法是第 06 章的全部内容;
- 咖啡店类比有一个故意的简化:现实咖啡店的吧台有好几个咖啡师(多条流水线),而 JavaScript 自始至终只有一个"咖啡师"(单线程)——异步不是人多,而是一个咖啡师靠叫号系统谁也不耽误。这个简化到第 09 章 Web Worker 才会被修正:"真要多个咖啡师,得另开一间店(线程)",而且两间店之间不能直接递杯子,只能传纸条(发消息)。
- 引言预告的"框架里的异步"(React/Vue)只在第 12 章给了入门片段,深度有限;
- 引言提到的 RxJS/Web Worker 同样只到"认识"的程度,这是入门书的定位,不是疏漏。
7. 可带走的¶
- JavaScript 一次只执行一件事,而它的世界里充满"等"——异步就是让"等"不挡路的全部手段;
- 同步与异步的差别用天气应用量化:三个各 1 秒的请求,同步约 3 秒且界面冻住,异步约 1 秒且界面活着;
- 三代机制是接力赛,不是选秀:回调解决"能不等",Promise 解决"能组合",async/await 解决"读得懂";
- 回调的三个坑——嵌套地狱、错误分支啰嗦、控制权易手——就是后两代机制的存在理由;
setTimeout的输出顺序实验是理解异步的第一块基石:写在回调后面的代码先执行;- "别冻"与"别闲"是检验一切异步机制的两句口诀——任何新 API 都先问它帮了哪句;
- 每代机制都欠新债:回调欠结构,Promise 欠可读,async/await 欠取消——债的方向就是下一章的方向。
- 等的对象越不可控越该异步,越确定越该直写——第 2.2 节的判据能挡掉一半滥用;
- 引言没有实测数字,时间线只作结构示意;真实耗时永远以测量为准(第 10 章 console.time)。
- 六种场景背下来:API、文件、数据库、定时器、用户输入、动画——判断一段代码该不该异步,先对号; 一个快速的反向判据也一样管用:这段代码里有没有"等"?等的对象越不可控(网络、磁盘、人),越该异步; 完全没有"等"、只是算得慢,那问题不在异步,在第 09 章的 Worker。
- 等外物用异步,重计算用 Worker;别把重计算塞进异步回调里骗自己。
最后把本章与后十二章的接口交代一遍,读后面任何一章迷路了都可以回本节对坐标:
- 读到"回调怎么就不行了"迷惑时,回来看 3.2 的三笔债——每笔债对应后文一个机制;
- 读到"Promise 为什么非要不可变性"迷惑时,回来看 3.3 的票模型——票之所以敢传来传去,正因为盖了章就不会变;
- 读到"async/await 有什么了不起"迷惑时,回来看 3.6 的三代对照表——差别不在机器,在读者;
- 判断任何一个新场景"该不该异步、该用哪个机制"时,回来看第 2 节的判定树和 3.5 的疆界图。
8. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 单线程与异步的角色 | Introduction | text/03-fm-introduction.txt:11(搜「main thread」) · text/03-fm-introduction.txt:9(搜「orchestrating」) |
| 咖啡店类比 | Introduction | text/03-fm-introduction.txt:19(搜「bustling coffee shop」) |
| 六条理由 | Introduction | text/03-fm-introduction.txt:25(搜「Enhanced User Experience」) · text/03-fm-introduction.txt:31(搜「Scalability」) · text/03-fm-introduction.txt:37(搜「Fetch」) |
| 天气应用主线例子 | Introduction | text/03-fm-introduction.txt:41(搜「getWeatherData」) · text/03-fm-introduction.txt:55(搜「Promise.all」) |
| 回调定义与 setTimeout 例子 | Introduction | text/03-fm-introduction.txt:77(搜「primary mechanism」) · text/03-fm-introduction.txt:81(搜「Starting the timer」) |
| 回调的三个坑 | Introduction | text/03-fm-introduction.txt:91(搜「Callback Hell」) · text/03-fm-introduction.txt:95(搜「Inversion of Control」) |
| Promise 与三项能力 | Introduction | text/03-fm-introduction.txt:101(搜「ECMAScript 6」) · text/03-fm-introduction.txt:105(搜「Chaining」) |
| async/await(ES2017) | Introduction | text/03-fm-introduction.txt:139(搜「ECMAScript 2017」) |
| 四个新方向 | Introduction | text/03-fm-introduction.txt:165(搜「Async Iterators」) · text/03-fm-introduction.txt:167(搜「RxJS」) · text/03-fm-introduction.txt:169(搜「Web Workers」) · text/03-fm-introduction.txt:171(搜「Top-level await」) |
| 全书学习路线许诺 | Introduction | text/03-fm-introduction.txt:404(搜「event loop」) |
-
出处:「Introduction」第 7 段(
text/03-fm-introduction.txt:7,搜「revolutionized」)。原文说异步编程"revolutionized the way we write and structure our code",并把本章定位为深入探讨异步的基本概念、演变与实际应用。 ↩ -
出处:「Introduction」第 39 段(
text/03-fm-introduction.txt:39,搜「weather application」)。原文设想一个天气应用要从多个 API 取数据——当前天气、五天预报、空气质量。 ↩ -
出处:「Introduction」第 53-67 段(
text/03-fm-introduction.txt:53,搜「async function getWeatherData」;:67,搜「remains responsive」)。异步版用Promise.all同时发出三个请求,并说明应用在等待期间保持响应,且自带错误处理。 ↩ -
出处:「Introduction」第 19-21 段(
text/03-fm-introduction.txt:19,搜「bustling coffee shop」;:21,搜「waiting area」)。原文的比方是:同步场景里每个顾客从点单到取餐全程占着位置;异步场景里顾客点完单去等候区,吧台同时处理多份订单。 ↩ -
出处:「Introduction」第 25-37 段(
text/03-fm-introduction.txt:25,搜「Enhanced User Experience」;:27,搜「Improved Performance」;:29,搜「Efficient Resource Utilization」;:31,搜「Scalability」;:33,搜「Real-time Capabilities」;:35,搜「Error Handling and Resilience」;:37,搜「Modern Web APIs」)。六条理由的英文小标题即表中所列。 ↩ -
出处:「Introduction」第 77 段(
text/03-fm-introduction.txt:77,搜「passed as an argument」)。原文定义:"A callback is simply a function that is passed as an argument to another function and is executed once the operation completes." ↩ -
出处:「Introduction」第 81-87 段(
text/03-fm-introduction.txt:81,搜「Starting the timer」)。示例输出顺序为 Starting → Timer started →(2 秒后)Timer finished,原文指出这正因为setTimeout是异步函数、不阻塞后续代码。 ↩ -
出处:「Introduction」第 89-95 段(
text/03-fm-introduction.txt:91,搜「Callback Hell」;:93,搜「Error Handling」;:95,搜「Inversion of Control」)。三项挑战的原文标题即此。 ↩ -
出处:「Introduction」第 101 段(
text/03-fm-introduction.txt:101,搜「ECMAScript 6」)。原文:"Promises emerged as a powerful abstraction… A Promise represents a value that may not be available immediately but will be resolved at some point in the future." ↩ -
出处:「Introduction」第 103-109 段(
text/03-fm-introduction.txt:105,搜「Chaining」;:107,搜「Better Error Handling」;:109,搜「Composition」)。三项优势即原文列出的 Chaining、Better Error Handling、Composition。 ↩ -
出处:「Introduction」第 139-141 段(
text/03-fm-introduction.txt:139,搜「ECMAScript 2017」)。原文:"the introduction of async/await syntax in ECMAScript 2017 (ES8)",并说明async标出返回 Promise 的函数、await在 async 函数内等待 Promise 兑现。 ↩ -
出处:「Introduction」第 145-159 段(
text/03-fm-introduction.txt:159,搜「more concise」)。原文说 async/await 版本"not only more concise but also reads almost like synchronous code"。 ↩ -
出处:「Introduction」第 163-173 段(
text/03-fm-introduction.txt:165,搜「Async Iterators」;:167,搜「RxJS」;:169,搜「Web Workers」;:171,搜「Top-level await」)。四项即原文"Modern Asynchronous Patterns"一节列出的四者。 ↩ -
出处:「Introduction」第 181-388 段。六个场景的分节标题:API Calls and Network Requests(
text/03-fm-introduction.txt:181,搜「API Calls」)、File Operations(:213,搜「File Operations」)、Database Operations(:243,搜「Database Operations」)、Timers and Intervals(:279,搜「Timers」)、Event Handling(:311,搜「Event Handling」)、Animation and Graphics(:348,搜「Animation」)。防抖代码在:334(搜「debounce」)。 ↩ -
出处:「Introduction」第 396-510 段(
text/03-fm-introduction.txt:404,搜「event loop」)。"What You'll Learn in This Book"一节逐项列出:事件循环与同步/异步之别、回调、Promise、async/await、高级模式、实战与最佳实践、调试与性能、框架中的异步、未来方向。 ↩ -
出处:「Introduction」第 113-135 段(
text/03-fm-introduction.txt:113,搜「fetchUserData」)。Promise 版的链式取数例子照原书。 ↩ -
出处:「Introduction」第 99-175 段(
text/03-fm-introduction.txt:101,搜「ECMAScript 6」;:139,搜「ECMAScript 2017」;:165,搜「Async Iterators」)。各代机制的出现时间与引言的叙述一致;allSettled(ES2020)、any(ES2021)、top-level await(ES2022)的年份见第 04、09 章原文。 ↩ -
出处:「Introduction」第 185-211 段(
text/03-fm-introduction.txt:190,搜「response.ok」)。fetchUserData 例子的 response.ok 检查与 response.json() 照原书。 ↩ -
出处:「Introduction」第 163-173 段(
text/03-fm-introduction.txt:165,搜「Async Iterators」;:167,搜「RxJS」;:169,搜「Web Workers」;:171,搜「Top-level await」)。四项现代形态照原书分节。 ↩ -
出处:「Introduction」第 281-309 段(
text/03-fm-introduction.txt:285,搜「sleep」;:289,搜「pomodoro」)。番茄钟例子与 sleep 原型照原书。 ↩ -
出处:「Introduction」第 311-346 段(
text/03-fm-introduction.txt:320,搜「handleSearch」;:334,搜「debounce」)。防抖搜索的链路与实现照原书。 ↩ -
出处:「Introduction」第 348-390 段(
text/03-fm-introduction.txt:352,搜「requestAnimationFrame」;:354,搜「animateElement」)。动画例子的回调结构与返回的票照原书。 ↩ -
出处:「Introduction」第 23-37 段(
text/03-fm-introduction.txt:25,搜「Enhanced User Experience」;:31,搜「Scalability」)。六条理由的原文标题即 3.1 表中所列;"事故"描述是每条理由的应用层展开。 ↩ -
出处:「Introduction」第 394-510 段(
text/03-fm-introduction.txt:400,搜「Foundations」;:486,搜「Frameworks」)。八项学习内容的许诺照原书小节。 ↩ -
出处:「Introduction」第 113-135 段(
text/03-fm-introduction.txt:113,搜「fetchUserData」;:123,搜「then」)。链式示例照原书。 ↩ -
出处:「Introduction」第 113-121 段(
text/03-fm-introduction.txt:114,搜「new Promise」)。造票模板与 resolve 的用法照原书;reject 的说明结合同章 101 段的定义。 ↩ -
出处:「Introduction」第 137-159 段(
text/03-fm-introduction.txt:145,搜「async function getUserData」;:159,搜「more concise」)。两版对照与原书评语照原书。 ↩ -
出处:「Introduction」第 51-67 段(
text/03-fm-introduction.txt:63,搜「handleError」)。异步版的错误处理"allowing for graceful recovery"照原书。 ↩ -
出处:「Introduction」第 89-97 段与第 91 段(
text/03-fm-introduction.txt:91,搜「Callback Hell」;:101,搜「structured」;:175,搜「evolution」)。各代挑战的原文照录;"新债"表为归纳。 ↩ -
出处:「Introduction」第 39-69 段(
text/03-fm-introduction.txt:49,搜「block」)。同步版本的缺陷描述("blocking the execution until it completes")照原书;何时该同步为反向应用。 ↩ -
出处:「Introduction」第 19-21 段(
text/03-fm-introduction.txt:21,搜「waiting area」)。"handle multiple tasks concurrently" 的咖啡比方照原书;"吧台只有一个咖啡师"是对单线程的还原。 ↩ -
出处:「Introduction」第 75-97 段(
text/03-fm-introduction.txt:77,搜「primary mechanism」;:87,搜「2-second delay」)。回调"remained the primary method… for many years"与其简单用法照原书。 本章是全书地图:每一个后面会反复出现的概念(票、链、队列、退避、兜底),都在这一章先露过脸。 读完全书再回来读这一章,收获会是初读时的三倍。 (本章完:为什么异步、怎么进化、用在哪——三个问题各有一整章在后头等。) ↩ -
出处:「Introduction」第 177-392 段(
text/03-fm-introduction.txt:181,搜「API Calls」;:279,搜「Timers」)。六个场景的篇幅与出现频率是对原书分节的归纳。 ↩