跳转至

为什么 JavaScript 必须是异步的 — 问题、进化线与现代场景

这一章讲三件事: 为什么「等待」是网页程序的头号敌人;JavaScript 的三代异步写法各自解决了上一代的什么痛点;以及这些写法在真实项目里长什么样。 它是全书地图:后面十二章每一章,都是把本章点到名字的那个机制单独拆开讲透。

1. 这一章讲什么

JavaScript 有个先天的拧巴:它一次只能执行一件事(原书的说法是"single-threaded",单线程——一条执行流水线,而不是好几条),可它活在的地方——浏览器、服务器——却到处都是"等":等网络、等磁盘、等用户点按钮1。

引言的任务就是把这个拧巴讲清楚,并给出全书的总路线:

  1. 先说明为什么不能让程序傻等(第 2 节);
  2. 再沿时间线走一遍三代机制的进化(第 3 节)——每一代都不是白来的,是被上一代的烂摊子逼出来的;
  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:

  1. 回调地狱(callback hell,又叫"末日金字塔"):步骤一多,回调就得套回调,代码一层层往右缩,读的时候要在屏幕上左右横跳;
  2. 错误处理啰嗦:每一步都可能失败,每一步都得自己写失败分支,漏一个就是静默失败;
  3. 控制权易手(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:

  1. API 请求:用 Fetch 这个浏览器内置的网络请求接口取用户数据,这是异步的绝对主力;
  2. 文件操作:Node.js 环境读配置文件——读盘很慢,必须异步,否则几百兆的文件能让服务卡死;
  3. 数据库操作:查订单,附带 finally 收尾断开连接(连接用完要还,不管成功失败);
  4. 定时器:setTimeout 本身就是异步的,包一层 Promise 就能用 await 写"番茄钟"这种顺序逻辑;
  5. 用户输入:搜索框边打字边查——为了不每敲一键就发一次请求,书里给搜索函数套了防抖(debounce:停手 300 毫秒没新输入才真正发请求);
  6. 动画:用 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 的花……每一杯的实际制作时间没有变短,变的是任何一杯都没让别人干等。这就是"非阻塞"的全部真相:

  同步世界:做完 A → 做 B → 做 C         总耗时 = A+B+C
  异步世界:A 等奶时做 B,B 等奶时做 A    总耗时 ≈ max(A,B,C)
  (同一个咖啡师,同一双手,只是不再空等)

所以"异步更快"这句话要加脚注:快的是总等待的消除,不是每件事本身变快。单件事的耗时(一杯咖啡的制作)在异步世界里分毫未减——有时候还略增(叫号系统本身有开销,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. 可带走的

  1. JavaScript 一次只执行一件事,而它的世界里充满"等"——异步就是让"等"不挡路的全部手段;
  2. 同步与异步的差别用天气应用量化:三个各 1 秒的请求,同步约 3 秒且界面冻住,异步约 1 秒且界面活着;
  3. 三代机制是接力赛,不是选秀:回调解决"能不等",Promise 解决"能组合",async/await 解决"读得懂";
  4. 回调的三个坑——嵌套地狱、错误分支啰嗦、控制权易手——就是后两代机制的存在理由;
  5. setTimeout 的输出顺序实验是理解异步的第一块基石:写在回调后面的代码先执行;
  6. "别冻"与"别闲"是检验一切异步机制的两句口诀——任何新 API 都先问它帮了哪句;
  7. 每代机制都欠新债:回调欠结构,Promise 欠可读,async/await 欠取消——债的方向就是下一章的方向。
  8. 等的对象越不可控越该异步,越确定越该直写——第 2.2 节的判据能挡掉一半滥用;
  9. 引言没有实测数字,时间线只作结构示意;真实耗时永远以测量为准(第 10 章 console.time)。
  10. 六种场景背下来:API、文件、数据库、定时器、用户输入、动画——判断一段代码该不该异步,先对号; 一个快速的反向判据也一样管用:这段代码里有没有"等"?等的对象越不可控(网络、磁盘、人),越该异步; 完全没有"等"、只是算得慢,那问题不在异步,在第 09 章的 Worker。
  11. 等外物用异步,重计算用 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」)


  1. 出处:「Introduction」第 7 段(text/03-fm-introduction.txt:7,搜「revolutionized」)。原文说异步编程"revolutionized the way we write and structure our code",并把本章定位为深入探讨异步的基本概念、演变与实际应用。 ↩

  2. 出处:「Introduction」第 39 段(text/03-fm-introduction.txt:39,搜「weather application」)。原文设想一个天气应用要从多个 API 取数据——当前天气、五天预报、空气质量。 ↩

  3. 出处:「Introduction」第 53-67 段(text/03-fm-introduction.txt:53,搜「async function getWeatherData」;:67,搜「remains responsive」)。异步版用 Promise.all 同时发出三个请求,并说明应用在等待期间保持响应,且自带错误处理。 ↩

  4. 出处:「Introduction」第 19-21 段(text/03-fm-introduction.txt:19,搜「bustling coffee shop」;:21,搜「waiting area」)。原文的比方是:同步场景里每个顾客从点单到取餐全程占着位置;异步场景里顾客点完单去等候区,吧台同时处理多份订单。 ↩

  5. 出处:「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」)。六条理由的英文小标题即表中所列。 ↩

  6. 出处:「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." ↩

  7. 出处:「Introduction」第 81-87 段(text/03-fm-introduction.txt:81,搜「Starting the timer」)。示例输出顺序为 Starting → Timer started →(2 秒后)Timer finished,原文指出这正因为 setTimeout 是异步函数、不阻塞后续代码。 ↩

  8. 出处:「Introduction」第 89-95 段(text/03-fm-introduction.txt:91,搜「Callback Hell」;:93,搜「Error Handling」;:95,搜「Inversion of Control」)。三项挑战的原文标题即此。 ↩

  9. 出处:「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." ↩

  10. 出处:「Introduction」第 103-109 段(text/03-fm-introduction.txt:105,搜「Chaining」;:107,搜「Better Error Handling」;:109,搜「Composition」)。三项优势即原文列出的 Chaining、Better Error Handling、Composition。 ↩

  11. 出处:「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 兑现。 ↩

  12. 出处:「Introduction」第 145-159 段(text/03-fm-introduction.txt:159,搜「more concise」)。原文说 async/await 版本"not only more concise but also reads almost like synchronous code"。 ↩

  13. 出处:「Introduction」第 163-173 段(text/03-fm-introduction.txt:165,搜「Async Iterators」;:167,搜「RxJS」;:169,搜「Web Workers」;:171,搜「Top-level await」)。四项即原文"Modern Asynchronous Patterns"一节列出的四者。 ↩

  14. 出处:「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」)。 ↩

  15. 出处:「Introduction」第 396-510 段(text/03-fm-introduction.txt:404,搜「event loop」)。"What You'll Learn in This Book"一节逐项列出:事件循环与同步/异步之别、回调、Promise、async/await、高级模式、实战与最佳实践、调试与性能、框架中的异步、未来方向。 ↩

  16. 出处:「Introduction」第 113-135 段(text/03-fm-introduction.txt:113,搜「fetchUserData」)。Promise 版的链式取数例子照原书。 ↩

  17. 出处:「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 章原文。 ↩

  18. 出处:「Introduction」第 185-211 段(text/03-fm-introduction.txt:190,搜「response.ok」)。fetchUserData 例子的 response.ok 检查与 response.json() 照原书。 ↩

  19. 出处:「Introduction」第 163-173 段(text/03-fm-introduction.txt:165,搜「Async Iterators」;:167,搜「RxJS」;:169,搜「Web Workers」;:171,搜「Top-level await」)。四项现代形态照原书分节。 ↩

  20. 出处:「Introduction」第 281-309 段(text/03-fm-introduction.txt:285,搜「sleep」;:289,搜「pomodoro」)。番茄钟例子与 sleep 原型照原书。 ↩

  21. 出处:「Introduction」第 311-346 段(text/03-fm-introduction.txt:320,搜「handleSearch」;:334,搜「debounce」)。防抖搜索的链路与实现照原书。 ↩

  22. 出处:「Introduction」第 348-390 段(text/03-fm-introduction.txt:352,搜「requestAnimationFrame」;:354,搜「animateElement」)。动画例子的回调结构与返回的票照原书。 ↩

  23. 出处:「Introduction」第 23-37 段(text/03-fm-introduction.txt:25,搜「Enhanced User Experience」;:31,搜「Scalability」)。六条理由的原文标题即 3.1 表中所列;"事故"描述是每条理由的应用层展开。 ↩

  24. 出处:「Introduction」第 394-510 段(text/03-fm-introduction.txt:400,搜「Foundations」;:486,搜「Frameworks」)。八项学习内容的许诺照原书小节。 ↩

  25. 出处:「Introduction」第 113-135 段(text/03-fm-introduction.txt:113,搜「fetchUserData」;:123,搜「then」)。链式示例照原书。 ↩

  26. 出处:「Introduction」第 113-121 段(text/03-fm-introduction.txt:114,搜「new Promise」)。造票模板与 resolve 的用法照原书;reject 的说明结合同章 101 段的定义。 ↩

  27. 出处:「Introduction」第 137-159 段(text/03-fm-introduction.txt:145,搜「async function getUserData」;:159,搜「more concise」)。两版对照与原书评语照原书。 ↩

  28. 出处:「Introduction」第 51-67 段(text/03-fm-introduction.txt:63,搜「handleError」)。异步版的错误处理"allowing for graceful recovery"照原书。 ↩

  29. 出处:「Introduction」第 89-97 段与第 91 段(text/03-fm-introduction.txt:91,搜「Callback Hell」;:101,搜「structured」;:175,搜「evolution」)。各代挑战的原文照录;"新债"表为归纳。 ↩

  30. 出处:「Introduction」第 39-69 段(text/03-fm-introduction.txt:49,搜「block」)。同步版本的缺陷描述("blocking the execution until it completes")照原书;何时该同步为反向应用。 ↩

  31. 出处:「Introduction」第 19-21 段(text/03-fm-introduction.txt:21,搜「waiting area」)。"handle multiple tasks concurrently" 的咖啡比方照原书;"吧台只有一个咖啡师"是对单线程的还原。 ↩

  32. 出处:「Introduction」第 75-97 段(text/03-fm-introduction.txt:77,搜「primary mechanism」;:87,搜「2-second delay」)。回调"remained the primary method… for many years"与其简单用法照原书。 本章是全书地图:每一个后面会反复出现的概念(票、链、队列、退避、兜底),都在这一章先露过脸。 读完全书再回来读这一章,收获会是初读时的三倍。 (本章完:为什么异步、怎么进化、用在哪——三个问题各有一整章在后头等。) ↩

  33. 出处:「Introduction」第 177-392 段(text/03-fm-introduction.txt:181,搜「API Calls」;:279,搜「Timers」)。六个场景的篇幅与出现频率是对原书分节的归纳。 ↩