跳到主内容

4

异步模型

Promise / async-await / 微任务 / 事件循环——AI 时代异步代码 review 的参照系

必学22 分钟

这一节讲 JavaScript 的异步模型——Promise / async-await / 微任务调度。这是 AI 写代码最容易出错的领域之一:错误处理漏掉、async 函数里 forEach 不等待、串行 await 应该并行——这些都需要你看代码逻辑才能识别。

§4.1 讲了 JS 语言机制(作用域 / 闭包 / 原型链 / this)。这一节往"运行时行为"再推一层——JS 是单线程的,但通过事件循环 + Promise / async-await 实现非阻塞

本节核心论点

  1. 异步错误处理是 AI 最常漏掉的部分——AI 写 async 函数经常忘 try / catch.catch()
  2. 微任务 vs 宏任务的执行顺序决定 race condition——React 18 后的批处理、并发渲染都依赖这个理解
  3. Promise.all / allSettled / race / any AI 易混用——选错会导致"一个失败全失败"或"等所有完成才返回"

怎么读这一节(建议):

你是直接读
新人 / 转行者§0 简史 → §1 单线程 → §2 Promise → §4 async-await
写过 Promise 但理解不深§2.3 错误传播 → §5 微任务 vs 宏任务
遇到 race condition 想 debug§5 微任务 vs 宏任务 → §6 取消请求
要带 AI 写异步代码§8 AI 最小集 → §9 翻车库
被异步报错搞崩§9 翻车库(直接搜症状)

通读约 50-60 分钟;按上述路径挑读 15-20 分钟。§2 Promise§5 微任务 vs 宏任务是两个核心。

与第 2 章 §2.4 事件循环的边界:第 2 章讲浏览器侧事件循环(task queue / requestAnimationFrame / 渲染时机),本节讲JavaScript 语言侧异步原语(Promise / async-await / 微任务)。两者交叉处会回链,不重复。


0. 演进史:回调 → Promise → async-await

JavaScript 异步语法的演进是最干净的"语言层逐步收敛"案例——每一代解决上一代的根本痛点。

年代主流写法解决了什么留下了什么
~2000回调函数(callback)让单线程能不阻塞回调地狱(callback hell):嵌套 5 层深
2010jQuery Deferred、Bluebird 库链式调用替代嵌套各家 API 不统一;标准缺失
2015Promise(ES6) 标准化链式调用 + 错误传播 + 标准化仍有 .then 嵌套;错误处理需要小心
2017async / await(ES2017)写起来像同步、运行起来像异步try/catch 错误处理仍是常见漏点
2022顶层 await(ES2022)ESM 模块顶层可直接 await—— 这一代基本没明显遗憾

回调地狱样本

// ❌ 2010 年代代码:嵌套 5 层
fs.readFile("a.txt", (err, dataA) => {
  if (err) return handleError(err);
  fs.readFile("b.txt", (err, dataB) => {
    if (err) return handleError(err);
    fs.readFile("c.txt", (err, dataC) => {
      if (err) return handleError(err);
      saveResult(dataA + dataB + dataC, (err) => {
        if (err) return handleError(err);
        console.log("done");
      });
    });
  });
});

现代 async-await 等价代码

// ✅ 2017+ 写法:扁平、可读
try {
  const [a, b, c] = await Promise.all([
    fs.promises.readFile("a.txt"),
    fs.promises.readFile("b.txt"),
    fs.promises.readFile("c.txt"),
  ]);
  await saveResult(a + b + c);
  console.log("done");
} catch (err) {
  handleError(err);
}

两个结论值得记住

  1. 现代 JS 不写回调——除了少数 API(如 addEventListener / setTimeout)仍用回调形式,业务代码全部 Promise + async-await
  2. 错误处理是"形式简化但责任不变"——回调地狱里的 if (err) 都消失了,但你必须主动加 try/catch.catch,否则错误会被吞没

1. 异步的"为什么":JavaScript 是单线程

理解异步前先理解JavaScript 为什么需要异步——它不是"性能优化",是单线程模型的必然选择。

1.1 单线程 + 事件循环

JavaScript 引擎(V8 / SpiderMonkey)只有一个执行线程——同时只能跑一段代码。这意味着:

// 如果 fetchData 是同步的(hypothetical):
const data = fetchData(); // 阻塞 2 秒
renderUI(data);
 
// 这 2 秒里,浏览器无法响应任何用户操作——点击、滚动、输入全部冻结

这显然不可接受。JS 的解法是:让 IO 操作交给浏览器 / 运行时去做(不占用 JS 线程),完成后通过事件循环把结果回传给 JS

谁在做在做什么
JS 引擎(单线程)执行 JS 代码;调用 fetch(url) 后立刻返回(不阻塞)
浏览器 / Node.js在另外的线程实际发起网络请求 / 读文件
浏览器 / Node.js请求完成,把回调和结果放进任务队列
JS 引擎(事件循环)主线程空闲时,从队列里取出回调执行

关键洞察

  • JS 引擎本身不做 IO——fetch / setTimeout / fs.readFile 都是浏览器 / Node.js 提供的"宿主 API",不是 JS 语言的一部分
  • 异步不是并行——JS 仍然是单线程,只是把"等待"的时间让给其他代码
  • 回调 / Promise / async-await 都是同一个机制的不同语法包装——底层都是"事件循环 + 任务队列"

权威参考:HTML 规范:事件循环处理模型(英文,权威规范)。

1.2 同步 vs 异步:阻塞与非阻塞

// 同步:阻塞,按顺序执行
console.log("1");
const data = syncReadFile("a.txt"); // 假设这里阻塞 1 秒
console.log("2");
// 输出顺序:1(等 1 秒)→ 2
 
// 异步:非阻塞,不等待
console.log("1");
asyncReadFile("a.txt", (data) => console.log("2"));
console.log("3");
// 输出顺序:1 → 3 → 2(IO 完成后回调)

经验法则

  • CPU 密集任务用同步代码(数学计算 / 数组排序)——用异步包装反而慢
  • IO 任务用异步代码(网络请求 / 文件读写 / 数据库查询)——避免阻塞 UI 或主线程

关于 Web Workers:让 CPU 密集任务跑在另一个线程的能力(MDN: Web Workers API)。本节聚焦"标准单线程 + 异步 IO"模型——这是日常业务代码 99% 的场景。Web Workers / SharedArrayBuffer / Atomics 等多线程话题不在本节展开。


2. Promise 基础

Promise 是 ES6 引入的异步操作的标准化容器——表示一个"将来会有值"的对象。

2.1 Promise 的三个状态

状态含义一次性
pending(进行中)初始状态——还没成功也没失败中间态
fulfilled(已成功)通过 resolve(value) 改变状态,绑定一个值不可逆
rejected(已失败)通过 reject(error) 改变状态,绑定错误不可逆

状态转换:pendingfulfilledpendingrejected一旦改变就不可逆

关键约束

  • 状态只能从 pending 变到 fulfilledrejected——一旦改变就不可逆
  • fulfilled 状态的 Promise 永远绑定一个值
  • rejected 状态的 Promise 永远绑定一个错误
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    if (Math.random() > 0.5) {
      resolve("success");
    } else {
      reject(new Error("failed"));
    }
  }, 1000);
});
 
promise
  .then((value) => {
    console.log(value); // 'success'
  })
  .catch((error) => {
    console.error(error); // Error: failed
  });

2.2 链式调用:.then / .catch / .finally

每个 .then / .catch 返回新的 Promise——这是链式调用的基础:

fetch("/api/user")
  .then((response) => response.json()) // 返回 Promise<user>
  .then((user) => fetch(`/api/posts?userId=${user.id}`)) // 返回 Promise<response>
  .then((response) => response.json()) // 返回 Promise<posts>
  .then((posts) => {
    console.log(posts);
  })
  .catch((error) => {
    console.error("请求链中任何一步出错都会到这里");
  })
  .finally(() => {
    console.log("无论成败都执行(如关闭 loading)");
  });

规则

  • .then 接收两个参数:onFulfilledonRejected——但常用 .then(onFulfilled).catch(onRejected) 分开写
  • .catch.then(undefined, onRejected) 的简写
  • .finally 不接收值,仅做清理工作(关闭 loading / 解锁按钮等)

2.3 错误传播:最近的 catch 捕获

这是 Promise 链最重要也最容易误解的机制——错误会沿着链向后传播,被最近的一个 .catch 捕获:

fetchUser()
  .then((user) => fetchPosts(user.id)) // 这步出错
  .then((posts) => render(posts)) // 跳过
  .then((html) => insertToDom(html)) // 跳过
  .catch((err) => console.error(err)); // 捕获到 fetchPosts 的错

关键洞察:你不需要在每个 .then 后面写 .catch——一个统一的 .catch 在链尾就能处理所有上游错误。

错误传播的几个细节

1. catch 之后链可以恢复

fetchUser()
  .then((user) => fetchPosts(user.id))
  .catch((err) => {
    console.warn("fetchPosts failed, using empty array");
    return []; // 返回值让链恢复
  })
  .then((posts) => render(posts)); // 仍会执行(posts 是空数组)

2. catch 自己也能抛错

fetchUser()
  .catch((err) => {
    throw new Error(`Wrapped: ${err.message}`); // 抛出新错误
  })
  .catch((err) => {
    // 这里捕获到 wrapped 错误
  });

3. 未捕获的 Promise 错误会被吞没

// ❌ 反例:没写 .catch,错误被吞没
fetchUser().then((user) => render(user));
//          ↑ 如果 fetchUser 失败,控制台会报 "Uncaught (in promise)"
//             但代码不会崩溃,错误处理被遗漏

修复:永远在 Promise 链尾加 .catch(或用 async-await + try/catch)。

设计判断暗线:错误传播(error propagation)

Promise 的链式 .catch 设计借鉴了异常处理的层次结构:

模型错误处理方式
回调地狱每个回调里手动 if (err) handleError(err)
Promise 链一个 .catch 在链尾捕获所有上游错误
async-awaittry/catch 块包住 await 表达式
异常(Java)同步代码的 try/catch 抛递机制

关键设计:错误默认向后传播——这让"快乐路径"代码不被错误处理污染。但代价是:漏写一个 .catch,错误就被吞没

权威参考:MDN: Promise

2.4 Promise 的不可变性

一旦 Promise 进入 fulfilledrejected 状态,值 / 错误就永远不变

const promise = Promise.resolve("A");
 
promise.then((v) => console.log(v)); // 'A'
promise.then((v) => console.log(v)); // 'A' —— 同一个值
promise.then((v) => console.log(v)); // 'A' —— 多次访问无副作用

这跟 EventEmitter / Observable 不同——后两者每次订阅都触发新事件,Promise 是"一次性容器"。

实战影响:你不能"重新触发"一个 Promise——需要重新调用工厂函数:

// ❌ 反例:以为能"重新跑"
const p = fetchData();
await p;
await p; // 不会重新请求,直接拿之前的值
 
// ✅ 正例:每次创建新 Promise
const fetchAgain = () => fetchData();
await fetchAgain();
await fetchAgain(); // 每次都是新请求

3. Promise 静态方法对比

Promise 提供了四个组合多个 Promise 的静态方法——AI 写代码时最容易混用的就是这组。

3.1 四个方法的语义对比

方法何时 resolve何时 reject返回值类型
Promise.all所有都成功任一失败(立即 reject)成功值数组(按顺序)
Promise.allSettled所有都完成(不管成败)永不 reject{status, value/reason} 数组
Promise.race第一个完成(无论成败)第一个失败的(如果它先完成)第一个完成的值 / 错误
Promise.any第一个成功所有都失败(AggregateError)第一个成功的值

3.2 选型决策图

你想要什么?
 
├─ 需要所有结果,任一失败就停
│  └─ Promise.all
│     例:并发加载多张图片 / 多个 API,任一失败页面就报错

├─ 需要所有结果,不管成败
│  └─ Promise.allSettled
│     例:批量操作(如批量删除文件),每个独立成败,最后汇总报告

├─ 多个候选源,任一最快返回的就用
│  └─ Promise.race
│     例:超时控制(race 一个 setTimeout)/ 多个 CDN 取最快

└─ 多个候选源,任一成功就用
   └─ Promise.any
      例:备用源策略——主源失败自动 fallback 到备份

3.3 各方法的实战用法

Promise.all:并发加载多个数据

// 串行(慢,三个请求依次发)
const user = await fetchUser(); // 等 1 秒
const posts = await fetchPosts(); // 等 1 秒
const comments = await fetchComments(); // 等 1 秒
// 总耗时:3 秒
 
// 并行(快,三个请求同时发)
const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);
// 总耗时:1 秒(取最慢的那个)

关键陷阱Promise.allall-or-nothing——任一失败,整个 Promise 立即 reject,其他成功的结果全部丢失

try {
  const [a, b, c] = await Promise.all([
    fetchA(), // 成功
    fetchB(), // 失败
    fetchC(), // 成功
  ]);
} catch (err) {
  // 只能拿到 fetchB 的错误
  // fetchA 和 fetchC 的结果完全丢失
}

Promise.allSettled:要所有结果(不管成败)

适合"批量操作,逐个汇报"的场景:

const results = await Promise.allSettled([
  deleteFile("a.txt"),
  deleteFile("b.txt"),
  deleteFile("c.txt"),
]);
 
results.forEach((result, index) => {
  if (result.status === "fulfilled") {
    console.log(`${index} 删除成功`);
  } else {
    console.warn(`${index} 删除失败:`, result.reason);
  }
});

何时选 allSettled vs all:你关心每个独立结果不希望一个失败影响其他——选 allSettled。否则选 all

Promise.race:超时控制最常见

function fetchWithTimeout(url, ms = 5000) {
  return Promise.race([
    fetch(url),
    new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)),
  ]);
}
 
// 使用
try {
  const response = await fetchWithTimeout("/api/slow", 3000);
} catch (err) {
  // 3 秒内没返回就抛 timeout
  // 或者 fetch 自己出错也抛
}

现代写法:用 AbortController + AbortSignal.timeout()(2023+)替代 Promise.race 做超时——能真正取消请求,不只是"忽略响应"。详见 §6.1 取消请求

Promise.any:备用源策略

// 三个 CDN,任一成功就用
const html = await Promise.any([
  fetch("https://cdn-a.example.com/index.html"),
  fetch("https://cdn-b.example.com/index.html"),
  fetch("https://cdn-c.example.com/index.html"),
]);
// 全部失败时抛 AggregateError,包含所有错误

Promise.any 是 ES2021 加入的,比 Promise.race 更"宽容"——race 第一个失败就 reject,any 要全失败才 reject。

3.4 实战经验:默认用什么

场景推荐
加载页面所需的全部数据Promise.all(任一失败页面崩)
批量操作,关心每个结果Promise.allSettled
超时控制AbortController.signal.timeout(不用 Promise.race)
多源 fallbackPromise.any
普通"等一个东西完成"await 直接等,不用 race / any

权威参考:MDN: Promise.all / Promise.allSettled / Promise.race / Promise.any


4. async / await

async / await 是 ES2017 引入的 Promise 语法糖——让异步代码写起来像同步代码

4.1 语法糖本质

// Promise 写法
function fetchUser() {
  return fetch("/api/user")
    .then((res) => res.json())
    .then((user) => {
      console.log(user);
      return user;
    });
}
 
// async-await 等价写法
async function fetchUser() {
  const res = await fetch("/api/user");
  const user = await res.json();
  console.log(user);
  return user; // 自动包装成 Promise.resolve(user)
}

核心规则

  • async 函数总是返回 Promise——return x 等价于 return Promise.resolve(x)
  • await 只能在 async 函数里用(ESM 顶层除外,见 §4.3)
  • await 会"解包" Promise——await promise 拿到的是 resolved 值,不是 Promise 对象

4.2 错误处理:try / catch

async function fetchData() {
  try {
    const response = await fetch("/api/data");
    const data = await response.json();
    return data;
  } catch (error) {
    // 捕获 fetch / json 任一步的错误
    console.error("Failed to fetch:", error);
    throw error; // 重新抛出或处理
  }
}

关键陷阱async 函数里没写 try/catch,错误会被包进返回的 Promise 的 rejection

async function bad() {
  throw new Error("oops");
}
 
// ❌ 调用者没 catch,错误被吞没
bad(); // Uncaught (in promise)
 
// ✅ 调用者负责 catch
bad().catch((err) => console.error(err));
 
// 或者用 await + try/catch
try {
  await bad();
} catch (err) {
  console.error(err);
}

经验法则

  • async 函数内部用 try/catch 处理预期错误(如网络失败、JSON 解析失败)
  • 调用者用 .catch()try/catch 处理函数返回的 Promise rejection

4.3 顶层 await(top-level await)

ES2022 允许在 ESM 模块顶层直接 await

// ✅ ESM 顶层(不需要包在 async 函数里)
const config = await fetch("/config.json").then((r) => r.json());
 
export default config;

限制

  • 只在 ESM 模块里可用(<script type="module">.mjs / "type": "module"
  • 不能在 CommonJS(require() / module.exports)里用

何时用:模块初始化时需要异步加载配置 / 数据——比 IIFE 包装 async 函数更简洁。

权威参考:TC39: top-level await proposal

4.4 async 函数的几个陷阱

陷阱 1:return await 看似多余但有时必要

async function fetchUser() {
  return await fetch("/api/user"); // await 多余?
}
 
// 等价于
async function fetchUser() {
  return fetch("/api/user"); // 直接返回 Promise
}

什么时候必须写 return await:在 try/catch 里:

async function fetchWithLog() {
  try {
    return await fetch("/api/user"); // ✅ 必须 await
    // 如果写 return fetch(...) 则 catch 捕获不到 fetch 的错误
  } catch (err) {
    console.error("Failed:", err);
    throw err;
  }
}

原因return fetch(...) 直接返回 Promise,不会"进入" try 块的执行上下文——Promise rejection 不会被 catch 捕获。return await 让 rejection 变成同步抛出,能被 catch 到。

ESLint 规则 no-return-await 会提醒你这个陷阱。

陷阱 2:串行 await 应该并行

// ❌ 串行(慢)
async function fetchAll() {
  const user = await fetchUser(); // 等 1 秒
  const posts = await fetchPosts(); // 等 1 秒
  return { user, posts }; // 总耗时 2 秒
}
 
// ✅ 并行(快)
async function fetchAll() {
  const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
  return { user, posts }; // 总耗时 1 秒
}

经验法则:如果两个 await 互不依赖(后者不需要前者的结果),一定要并行

陷阱 3:forEach 不等待 async 回调

// ❌ 反例:forEach 不等待
async function processAll(items) {
  items.forEach(async (item) => {
    await processItem(item); // forEach 不等这个 await
  });
  console.log("done"); // 会立即执行,不等 processItem 完成
}
 
// ✅ 正例:用 for...of
async function processAll(items) {
  for (const item of items) {
    await processItem(item);
  }
  console.log("done"); // 等所有 item 都处理完
}
 
// ✅ 正例:并行处理
async function processAll(items) {
  await Promise.all(items.map((item) => processItem(item)));
  console.log("done");
}

原因forEach 只是普通函数,不会等待 async 回调返回的 Promise。永远不在 forEach 里用 await

权威参考:MDN: async function / MDN: await


5. 微任务 vs 宏任务

理解微任务(microtask)宏任务(macrotask / task) 的执行顺序,是 debug race condition 的关键参照系。

5.1 两种任务的来源

类型来源 API
微任务Promise.then / .catch / .finallyqueueMicrotaskMutationObserver
宏任务setTimeout / setInterval、I/O 回调、UI 事件、MessageChannel

5.2 执行顺序:微任务优先级高

事件循环的核心规则(简化版):

1. 执行同步代码到栈空
2. 清空整个微任务队列(这一步会一直循环到队列空)
3. 取出一个宏任务执行
4. 重复步骤 2-3

关键:每次执行完一个宏任务或同步代码块后,会把当前所有微任务清空才继续。

5.3 经典执行顺序题

console.log("1");
 
setTimeout(() => console.log("2"), 0);
 
Promise.resolve().then(() => console.log("3"));
 
console.log("4");

输出顺序1, 4, 3, 2

推导过程

  1. 同步代码:打印 1 → 打印 4setTimeout 注册到宏任务队列、.then 注册到微任务队列)
  2. 同步执行完,清空微任务队列:打印 3
  3. 取出一个宏任务:打印 2

5.4 进阶:嵌套的微任务

console.log("1");
 
setTimeout(() => console.log("2"), 0);
 
Promise.resolve().then(() => {
  console.log("3");
  Promise.resolve().then(() => console.log("4"));
});
 
console.log("5");

输出顺序1, 5, 3, 4, 2

关键点:第一个 .then 里又注册了新的 .then——新微任务在当前微任务队列清空前会被加入,所以在 42 之前。

5.5 实战影响:React 18 自动批处理

React 18 的并发渲染依赖微任务调度——多个 setState 调用会被合并到同一个微任务里批处理:

function handleClick() {
  setCount((c) => c + 1); // 不立即触发渲染
  setFlag((f) => !f); // 不立即触发渲染
  setName("Alice"); // 不立即触发渲染
  // 当前同步代码执行完后,React 在微任务里统一渲染一次
}

这就是为什么 React 18 后

  • 多个 setState 不会触发多次渲染(合并成一次)
  • useEffect 在 React 完成 DOM 更新后但浏览器绘制前的微任务里执行
  • useLayoutEffect 在 DOM 更新后同步执行(DOM 已变但浏览器没绘制)

5.6 何时该理解微任务调度

必须理解

  • Debug race condition(异步操作顺序异常)
  • 写自定义 hooks,理解 useEffect / useLayoutEffect 的执行时机
  • 性能优化(避免微任务无限循环卡死 UI)

不需要深究

  • 业务代码极少需要手动调度微任务
  • 普通 Promise 链 / async-await 不需要懂这层细节

权威参考:MDN: Microtask 指南MDN: queueMicrotaskHTML 规范:事件循环处理模型

回链 §2.4 事件循环:第 2 章讲了浏览器侧的整体事件循环(含 requestAnimationFrame 和渲染管线)。本节聚焦 JS 语言层的微任务 / 宏任务区分,两者结合起来才是完整心智。


6. 实战模式

6.1 取消请求(AbortController)

AbortController 是 2017+ 的标准 API——让你能主动取消进行中的异步操作

const controller = new AbortController();
const signal = controller.signal;
 
// 把 signal 传给可取消的 API
fetch("/api/data", { signal })
  .then((res) => res.json())
  .then((data) => console.log(data))
  .catch((err) => {
    if (err.name === "AbortError") {
      console.log("请求被取消");
    } else {
      console.error("其他错误:", err);
    }
  });
 
// 在另一个地方取消
setTimeout(() => controller.abort(), 3000);

支持 AbortSignal 的 API

  • fetch
  • addEventListener(2020+)
  • 大多数现代异步 API

现代超时控制(替代 Promise.race)

// 2023+ 的标准写法
fetch("/api/data", {
  signal: AbortSignal.timeout(5000), // 5 秒超时
});

Promise.race 优秀的原因:真正取消请求——不只是"忽略响应",浏览器会发送 abort 信号停止网络传输,节省带宽。

6.2 React useEffect 清理过期请求

经典翻车场景:组件 unmount 后,await 的 Promise 才 resolve,导致在卸载组件上 setState:

// ❌ 反例:组件卸载后 setState
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/user/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data)); // 组件可能已卸载
  }, [userId]);
 
  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

修复:用 AbortController + 清理函数:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetch(`/api/user/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((data) => setUser(data))
      .catch((err) => {
        if (err.name !== "AbortError") {
          console.error(err);
        }
      });
 
    return () => controller.abort(); // 组件卸载或 userId 变化时取消
  }, [userId]);
 
  return user ? <div>{user.name}</div> : <div>Loading...</div>;
}

6.3 并发限制(手写 Pool)

需要请求 100 个 URL,但服务器限制 10 并发:

async function pool(items, concurrency, fn) {
  const results = [];
  const executing = [];
 
  for (const item of items) {
    const p = Promise.resolve().then(() => fn(item));
    results.push(p);
 
    if (items.length >= concurrency) {
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      if (executing.length >= concurrency) {
        await Promise.race(executing);
      }
    }
  }
 
  return Promise.all(results);
}
 
// 使用
const urls = Array.from({ length: 100 }, (_, i) => `/api/item/${i}`);
const results = await pool(urls, 10, fetch);

生产环境推荐用现成库p-limit(极轻量、广泛使用)。

6.4 重试(指数退避)

网络请求失败时自动重试,每次间隔翻倍:

async function retry(fn, maxAttempts = 3, baseDelay = 1000) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (attempt === maxAttempts) throw err;
 
      const delay = baseDelay * 2 ** (attempt - 1); // 1s, 2s, 4s
      console.warn(`Attempt ${attempt} failed, retry in ${delay}ms`);
      await new Promise((r) => setTimeout(r, delay));
    }
  }
}
 
// 使用
const data = await retry(() => fetch("/api/data").then((r) => r.json()), 3);

6.5 异步迭代(for await...of)

ES2018 引入的语法——遍历异步生成器异步可迭代对象

async function* fetchPages(urls) {
  for (const url of urls) {
    const data = await fetch(url).then((r) => r.json());
    yield data;
  }
}
 
// 用 for await 串行处理
for await (const page of fetchPages(urls)) {
  console.log(page);
  // 拿到一个就立即处理,不用等所有完成
}

何时用:流式处理大量数据(如分页加载、SSE 事件流)——比 Promise.all 更省内存。

权威参考:MDN: AbortControllerMDN: for await...of


7. 三档归位

知识点归位理由
Promise 三个状态 + 链式调用必学现代 JS 异步的基础
Promise 错误传播(最近 catch 捕获)必学错误处理的关键机制
Promise.all / allSettled / race / any 区分必学AI 易混用,选错代价大
async-await 语法 + try/catch必学现代异步代码标配
串行 vs 并行 await必学性能差异 N 倍
微任务 vs 宏任务执行顺序必学Debug race condition 必备
AbortController 取消请求必学React useEffect 清理 / 超时控制
顶层 await(top-level await)必学ESM 模块初始化常见
forEach 不等待 async 回调必学高频翻车点
return await 在 try/catch 里的必要性仍需理解ESLint 会提醒;知道为什么即可
for await...of 异步迭代仍需理解流式处理时用;普通业务不需要
并发限制 / 重试 / 指数退避仍需理解业务复杂时用;优先用 p-limit 等现成库
Promise A+ 规范细节仍需理解知道存在;实战不需要看规范
自己实现 Promise(手写 polyfill)可委托面试题;生产代码不写
Bluebird / Q 等老 Promise 库不学ES6 Promise 已经够用,老库已边缘化
Generator + 旧式协程模式不学async-await 已替代;只在维护老 koa 1.x 代码时见

8. AI 时代写异步的最小集

你想做AI 帮得上吗
把回调改成 Promise / async-await✅ 委托 AI(标准重构任务)
写 fetch + 错误处理✅ AI 写得熟,但 review 错误处理是否完整
选 Promise.all 还是 allSettled⚠️ AI 给默认值(通常 all),你要根据"任一失败是否能容忍"判断
把串行 await 改成并行✅ AI 会改,但你要 review "前后是否真的无依赖"
写 AbortController 取消逻辑✅ AI 熟悉标准用法
排查"Promise 没 resolve"卡死❌ Tier 4:调用栈 + 异步追踪需要你看
Debug race condition❌ Tier 4:AI 不知道你的并发场景
决定 useEffect 是否需要 AbortController⚠️ AI 默认会加,但你要判断业务是否真的需要
写并发限制 / 重试逻辑✅ 委托 AI 写基础版(或用 p-limit)

重点强调

AI 写异步代码时最容易错的三件事

  1. 错误处理漏掉:AI 写 async 函数经常没 try/catch,或 .catch()console.log 不重新抛——review 时必须检查"错误是否被吞没"
  2. forEach 里用 await:AI 偶尔会写 items.forEach(async ...) 然后期待"等所有完成"——改成 for...ofPromise.all(items.map(...))
  3. Promise.all 误用:AI 默认推荐 Promise.all,但批量操作场景应该用 allSettled——问自己"任一失败是否要中断全部"

你必须把握的 review 要点

Promise 链 / async-await 场景

  • 链尾是否有 .catch / 整个 await 是否在 try/catch 里
  • 错误处理是否合理(不只是 console.log
  • 串行 await 是否应该并行

Promise 静态方法选型

  • Promise.all → 任一失败即可全失败的场景
  • Promise.allSettled → 关心每个独立结果的批量操作
  • AbortSignal.timeout() → 替代 Promise.race + setTimeout

React useEffect 异步

  • 是否有 AbortController 防止过期 setState
  • 依赖数组是否正确(含 async 函数读到的所有外部变量)

9. 翻车库

9.1 async 函数没 try/catch(错误吞没)

症状:async 函数里 await 报错,控制台 "Uncaught (in promise)",但代码不崩。

原因:async 函数里的抛错被包进返回的 Promise rejection——调用者没 catch 就被吞没。

修复:函数内加 try/catch,或调用者加 .catch()

// ❌ 反例
async function fetchData() {
  const res = await fetch("/api/data"); // 可能失败
  return res.json();
}
 
// ✅ 正例 1:函数内处理
async function fetchData() {
  try {
    const res = await fetch("/api/data");
    return res.json();
  } catch (err) {
    console.error("Failed to fetch:", err);
    throw err; // 或返回默认值
  }
}
 
// ✅ 正例 2:调用者处理
fetchData().catch((err) => console.error(err));

9.2 forEach 里用 async(不等待)

症状forEach 循环里有 await,但函数执行完毕时异步操作还没完成。

原因forEach 不等待 async 回调返回的 Promise。

修复:用 for...ofPromise.all(map(...))

// ❌ 反例
async function processAll(items) {
  items.forEach(async (item) => {
    await processItem(item);
  });
  console.log("done"); // 立即执行,不等 processItem
}
 
// ✅ 正例 1:串行
async function processAll(items) {
  for (const item of items) {
    await processItem(item);
  }
  console.log("done");
}
 
// ✅ 正例 2:并行
async function processAll(items) {
  await Promise.all(items.map((item) => processItem(item)));
  console.log("done");
}

9.3 串行 await 应该并行

症状:多个 await 依次执行,总耗时是各自耗时之和(慢)。

原因:await 会暂停执行,等 Promise resolve——不需要依赖的 await 应该并行。

修复:用 Promise.all

// ❌ 反例(总耗时 3 秒)
const user = await fetchUser(); // 1 秒
const posts = await fetchPosts(); // 1 秒
const comments = await fetchComments(); // 1 秒
 
// ✅ 正例(总耗时 1 秒)
const [user, posts, comments] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);

9.4 Promise.all 一个失败全失败(应该用 allSettled)

症状:批量操作时,一个失败导致所有成功结果丢失。

原因Promise.all 是 all-or-nothing——任一 reject 立即短路。

修复:批量操作用 Promise.allSettled

// ❌ 反例:删除 10 个文件,一个失败其他结果全丢
const results = await Promise.all(files.map(deleteFile));
 
// ✅ 正例:每个文件独立成败
const results = await Promise.allSettled(files.map(deleteFile));
results.forEach((result, i) => {
  if (result.status === "fulfilled") {
    console.log(`${i} 删除成功`);
  } else {
    console.warn(`${i} 删除失败:`, result.reason);
  }
});

9.5 取消 Promise(不能直接 cancel)

症状:想取消一个 fetch 请求,但 Promise 没有 .cancel() 方法。

原因:Promise 本身不可取消——需要用 AbortController

修复:传 signal 给支持的 API:

// ❌ 反例
const promise = fetch("/api/data");
// 没有 promise.cancel() 方法
 
// ✅ 正例
const controller = new AbortController();
fetch("/api/data", { signal: controller.signal });
 
// 取消
controller.abort();

详见 §6.1 取消请求

9.6 React useEffect 没清理过期请求

症状:组件卸载后,setState 报 "Warning: Can't perform a React state update on an unmounted component"。

原因:useEffect 的异步操作在组件卸载后才完成,调用了已卸载组件的 setState。

修复:返回清理函数 + AbortController:

// ❌ 反例
useEffect(() => {
  fetch("/api/data").then((data) => setState(data));
}, []);
 
// ✅ 正例
useEffect(() => {
  const controller = new AbortController();
  fetch("/api/data", { signal: controller.signal })
    .then((data) => setState(data))
    .catch((err) => {
      if (err.name !== "AbortError") console.error(err);
    });
 
  return () => controller.abort();
}, []);

详见 §6.2 React useEffect 清理

9.7 微任务无限循环(卡死页面)

症状:页面卡死、UI 无响应、控制台没报错。

原因:微任务队列无限增长——浏览器不会渲染直到微任务清空。

// ❌ 反例:微任务无限循环
function bad() {
  Promise.resolve().then(() => bad()); // 立即排入微任务队列
}
bad(); // 页面卡死

修复:用宏任务(如 setTimeout)给浏览器喘息机会:

// ✅ 正例
function good() {
  setTimeout(() => good(), 0); // 每次让出控制权
}
good(); // 不会卡死(虽然也是无限循环,但浏览器能渲染)

经验:递归异步逻辑不要用 Promise.then / queueMicrotask——用 setTimeoutrequestAnimationFrame

9.8 返回 Promise 链但没 return

症状:async 函数里 .then 链,但函数提前返回导致链断了。

// ❌ 反例
async function fetchAndProcess() {
  fetch("/api/data")
    .then((res) => res.json())
    .then((data) => process(data)); // 没 return
 
  console.log("done"); // 立即执行,不等 fetch 完成
}
 
// ✅ 正例 1:return Promise 链
async function fetchAndProcess() {
  return fetch("/api/data")
    .then((res) => res.json())
    .then((data) => process(data));
}
 
// ✅ 正例 2:用 await
async function fetchAndProcess() {
  const res = await fetch("/api/data");
  const data = await res.json();
  return process(data);
}

10. 读完本节你应该能

Promise 基础

  • 解释 Promise 的三个状态及不可逆性
  • 写出 Promise 链的错误传播机制(最近的 catch 捕获)
  • 区分 .then(onFulfilled, onRejected).then().catch()
  • 解释为什么 Promise 不能"重新触发"

Promise 静态方法

  • 区分 Promise.all / allSettled / race / any 的语义
  • 给一个批量操作场景,能选对方法
  • 写出超时控制的两种实现(race vs AbortSignal.timeout)

async / await

  • 解释 async 函数总是返回 Promise
  • 写出 async 函数的标准错误处理(try/catch)
  • 解释何时必须 return await(在 try/catch 里)
  • 区分串行 await 和 Promise.all 并行
  • 解释为什么 forEach 里不能用 await

微任务 vs 宏任务

  • 列出微任务的来源(Promise / queueMicrotask / MutationObserver)
  • 列出宏任务的来源(setTimeout / I/O / UI 事件)
  • 推导一段代码的执行顺序
  • 解释 React 18 自动批处理为什么基于微任务

实战模式

  • 用 AbortController 实现取消请求
  • 在 React useEffect 里写过期请求清理
  • 写超时控制(用 AbortSignal.timeout)
  • 知道何时该用 for await...of

设计判断

  • 用"错误传播"心智解释 Promise 链 vs 回调地狱的根本差异
  • 解释为什么 Promise 一旦确定状态不可逆(设计选择)

11. 延伸阅读

访问性说明:MDN 中文版直接可访问;HTML 规范和 TC39 仓库是英文但访问稳定。


下一节:§4.3 模块系统——ESM / 动态 import / Node.js 互操作。

动手验证:把 §5 的执行顺序题复制到浏览器 DevTools 控制台跑——亲手看到输出比"记住规则"有效得多。