异步编程与Promise

张玥 2026年9月20日 阅读时间 40分钟
JavaScript 异步编程 Promise async/await 事件循环
前端开发技巧之异步编程与Promise

如果说闭包是 JavaScript 最容易被问到的概念,那么异步就是最容易写错的机制。几乎每一个线上事故的复盘里,都能找到它的影子:接口返回了但页面没更新、并发请求把浏览器打崩、await 写在 forEach 里静默失效、一个未捕获的 rejection 让整个 Node 进程退出。这些问题的根源,往往不是“不会用 Promise”,而是没有真正理解 JavaScript 的单线程模型与事件循环。Promise 不是语法糖,它是一套关于“未来值”的状态机与调度协议;async/await 也不是异步的银弹,它只是把 Promise 的 then 链换了一种写法。本篇 40 分钟长文,从事件循环讲起,逐层拆解回调、Promise、async/await、并发控制、取消机制、手写实现与工程实践,并在最后给出一份可以直接贴在工位上的检查清单。

一、单线程的宿命:为什么 JavaScript 必须异步

要理解异步,先要理解一个前提:JavaScript 在浏览器里只有一个主线程负责执行 JS 代码。这个线程同时还要负责解析 HTML、计算样式、布局、绘制。它一次只能做一件事。

为什么不设计成多线程?历史原因是 1995 年 Brendan Eich 设计这门语言时,目标是在浏览器里做轻量的 DOM 操作。如果两个线程同时修改同一个 DOM 节点,就需要引入锁、原子操作、内存屏障这一整套复杂的并发控制,而这对于一个“给网页加点交互”的脚本语言来说完全不值得。单线程换来了简单,代价就是——任何一段耗时过长的同步代码,都会把整个页面冻住。

// 这段代码会让页面卡死大约 3 秒
function block() {
  const start = Date.now();
  while (Date.now() - start < 3000) {}
}

block();
// 在这 3 秒里:点击无响应、滚动卡住、动画全部停摆

更麻烦的是,很多操作天生就“慢”:网络请求、文件读写、定时器、用户输入。它们的耗时无法预测,也不该由 JS 线程去等待。于是浏览器给出的方案是:把这些耗时的活儿交给浏览器内核的其他线程去做,JS 主线程只管发起请求和接收结果。而“发起”与“接收”之间如何衔接,就构成了整套异步机制。

事件循环:异步的心脏

事件循环(Event Loop)负责协调这一切。它的运行逻辑可以简化成一句话:主线程不断从任务队列里取出任务执行,执行完再取下一个。而任务队列又分成两种优先级:

1
调用栈
Call Stack
同步代码逐层入栈执行,执行完出栈。栈不空,事件循环就不会去取新任务。这就是同步代码能阻塞一切的原因。
2
Web APIs
浏览器能力
定时器、网络请求、DOM 事件监听都由浏览器其他线程托管。JS 只负责把回调注册进去,然后立刻返回继续执行。
3
微任务队列
Microtask
Promise 的 then/catch/finally、queueMicrotask、MutationObserver 都排在这里。每执行完一个宏任务,会把微任务队列全部清空再进入下一步。
4
宏任务队列
Macrotask
setTimeout、setInterval、I/O、UI 渲染、postMessage 排在这里。每轮事件循环只取一个宏任务执行,然后回头清空微任务队列。

微任务与宏任务的优先级

这是异步面试里出现频率最高的题目,也是实际开发中最容易踩坑的地方。记住一条规则就够了:

每执行完一个宏任务,先把微任务队列彻底清空(包括在执行微任务过程中新产生的微任务),然后才进入下一个宏任务。

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 → 4 → 3 → 2
// 1、4 是同步代码,先执行完
// 3 在微任务队列,4 执行完后立刻清空
// 2 在宏任务队列,等微任务全部清空后才轮到它

再看一个稍微复杂的例子,检验一下是否真的理解了:

setTimeout(() => {
  console.log('A');
  Promise.resolve().then(() => console.log('B'));
}, 0);

setTimeout(() => console.log('C'), 0);

// 输出:A → B → C
// A 和 B 属于同一个宏任务周期:先执行 A,再把 B 加入微任务队列并清空
// 之后才轮到下一个宏任务 C

为什么微任务要有更高优先级

因为微任务通常承载的是“状态一致性”相关的工作。比如 Promise 链上的后续处理,必须保证在同一个宏任务周期内完成,否则可能出现“状态已更新但视图没同步”的中间态。而宏任务(比如渲染、定时器)天然允许被推迟。把微任务设计成高优先级,是为了让一次逻辑上的“原子操作”尽可能连续地完成。

但这也带来了一个副作用:如果微任务里不断产生新的微任务,队列永远清不空,页面就会卡死,而且这种卡死比同步阻塞更隐蔽,因为它发生在每一轮宏任务的缝隙里。后面的“性能与调试”一节会专门讲这个问题。

二、回调时代:能跑,但会痛

在 Promise 普及之前,异步的标准写法是回调函数:把“完成后要做什么”作为参数传进去。这个方案在简单场景下完全没有问题:

setTimeout(() => {
  console.log('1 秒后执行');
}, 1000);

button.addEventListener('click', () => {
  console.log('被点击了');
});

问题出现在多个异步操作需要按顺序串联的时候。假设我们要:根据用户 ID 拿用户信息 → 根据用户信息拿订单列表 → 根据第一个订单拿详情 → 渲染页面。用回调写出来是这样的:

getUser(userId, (err, user) => {
  if (err) return handleError(err);

  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);

    getOrderDetail(orders[0].id, (err, detail) => {
      if (err) return handleError(err);

      getCoupon(detail.couponId, (err, coupon) => {
        if (err) return handleError(err);
        render(user, detail, coupon);
      });
    });
  });
});

这就是著名的“回调地狱”(Callback Hell)。它的问题不只是缩进越来越深、越来越难看,而是有三个更本质的缺陷:

缺陷一:控制反转带来的信任问题

当你把回调函数交给第三方库或某个 API 时,你实际上把控制权交了出去。你不知道它会:

  • 调用回调多少次——可能一次都不调,也可能调了三次,导致你的渲染逻辑执行多遍。
  • 在什么时机调用——可能同步立即调用,也可能异步调用,两种情况下你的代码行为完全不同。
  • 是否吞掉异常——回调里抛出的错误可能被库的 try/catch 吃掉,永远传不到你这里。
  • 传给你什么参数——调用约定只存在于文档里,没有任何机制强制保证。

这就是所谓的“信任问题”。Promise 的设计初衷之一,正是把这部分控制权收回来:状态一旦落定就不可更改,回调最多执行一次,且永远在微任务中异步执行。

缺陷二:错误处理无法统一

回调风格通常遵循 (err, data) 约定,也就是所谓的 Node 风格回调。这意味着每一个回调里都要写一遍 if (err)。任何一个环节漏写,错误就会被静默吞掉。而且这层层的 if 让主流程被切割得七零八落,很难一眼看出“正常路径”是什么。

缺陷三:无法组合

如果想同时发起三个请求,等全部回来再处理,用回调需要自己维护一个计数器;如果想给整条链路加超时,需要额外包一层;如果想重试,需要把整个嵌套结构再包一层。回调本身是“一次性”的,它不是一个可以传递、组合、复用的值。

点击下面的按钮,对比同一段逻辑在回调与 Promise 下的写法差异:

getUser(userId, (err, user) => {
  if (err) return handleError(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handleError(err);
    getOrderDetail(orders[0].id, (err, detail) => {
      if (err) return handleError(err);
      render(user, detail);
    });
  });
});

三、Promise:一份关于未来值的契约

Promise 这个名字本身就是最好的解释:它是一个承诺——现在没有值,但未来某个时刻一定会给你一个结果,要么是成功的值,要么是失败的原因。这个“承诺”有三个不可动摇的特征。

特征一:三种状态,且只能单向流转

pending 初始状态,既未成功也未失败。异步操作正在进行中
fulfilled 已兑现,携带一个值(value)。之后不能再变成其他状态
rejected 已拒绝,携带一个原因(reason)。之后不能再变成其他状态
不可逆 pending → fulfilled 或 pending → rejected,只能发生一次

“状态不可逆”这一点极其重要。它意味着:即使你在 resolve 之后又调用了 reject,第二次调用会被完全忽略。这不是疏忽,而是刻意的设计——它保证了回调最多只会被执行一次,从根本上消灭了回调时代“被调用多次”的信任问题。

const p = new Promise((resolve, reject) => {
  resolve('第一次');
  reject('第二次');
  resolve('第三次');
});

p.then(v => console.log(v));
// 输出:第一次
// 后面的 reject 和 resolve 全部被静默忽略

特征二:executor 是同步执行的

这是最反直觉的一点。很多初学者以为 new Promise(fn) 里的 fn 会异步执行,实际上它立即同步执行。Promise 只是提供了异步结果的容器,容器本身是当场就建好的。

console.log('A');

const p = new Promise((resolve) => {
  console.log('B'); // 同步执行!
  resolve();
});

p.then(() => console.log('C')); // 微任务

console.log('D');

// 输出:A → B → D → C

所以,如果你在 executor 里写了很重的同步计算,它会和普通同步代码一样阻塞主线程。这不是 Promise 的问题,而是你把不该同步做的事放错了地方。

特征三:then 永远返回一个全新的 Promise

这是 Promise 能够链式调用的根本原因。每一次 .then() 都会创建一个新的 Promise,它的状态由回调函数的返回值决定:

  • 回调返回一个普通值 → 新 Promise 以该值 fulfilled。
  • 回调返回一个 Promise → 新 Promise 会“跟随”这个 Promise 的状态(称为解决过程)。
  • 回调抛出异常 → 新 Promise 以该异常 rejected。
  • 回调没有返回值 → 相当于返回 undefined,新 Promise 以 undefined fulfilled。
Promise.resolve(1)
  .then(v => v + 1)            // 返回 2
  .then(v => {
    throw new Error('boom');   // 抛出 → 新 Promise 被拒绝
  })
  .catch(e => {
    console.log(e.message); // 'boom'
    return 0;             // 把错误“修复”成 0,链恢复 fulfilled
  })
  .then(v => console.log('最终值:', v)); // 最终值:0

值穿透:为什么 then 里可以不写函数

如果你给 then 传的不是函数(比如 null、undefined、数字),它会被忽略,值会原封不动地传给下一个 then。这个特性叫“值穿透”:

Promise.resolve('hello')
  .then(null)
  .then(undefined)
  .then(v => console.log(v)); // 'hello'

值穿透让“条件式地插入处理步骤”变得很自然,但在链式调用里也容易造成误解——看到一串 then 结果值没变,先检查中间那些回调是不是忘了写 return。

错误冒泡:一个 catch 兜住整条链

Promise 链上的错误会自动向后传递,直到遇到第一个 catch 或带两个参数的 then。这意味着你不需要在每一层都写错误处理:

step1()
  .then(step2)  // step2 抛错
  .then(step3)  // 被跳过
  .then(step4)  // 被跳过
  .catch(err => {
    // 在这里统一处理 step2 的错误
  });

// 但要注意:catch 之后如果又抛出,链会再次变成 rejected
step1()
  .catch(e => { throw e; })  // 这里抛出,后续依然需要 catch
  .then(next);                     // next 不会执行
反例
p.then(onSuccess, onError)——在 then 的第二个参数里处理错误,只能捕获它前面那一个 Promise 的错误,捕获不到 onSuccess 内部抛出的异常。
正解
统一用 .catch() 收尾。它位于链的末尾,能捕获整条链上任何一环抛出的错误,语义也更清晰。

四、Promise 静态方法全解:并发组合的六种武器

Promise 构造函数上挂着几个静态方法,它们才是处理并发的主力。很多人只熟悉 Promise.all,但在实际业务里,另外几个方法的适用场景同样高频。

Promise.resolve 与 Promise.reject

这两个方法用于把任意值“包装”成 Promise。它们的价值在于统一接口:当你写一个可能返回 Promise、也可能返回普通值的函数时,调用方就不需要判断类型了。

Promise.resolve(42);         // 立即 fulfilled,值为 42
Promise.resolve(p);          // 如果 p 是 Promise,原样返回它
Promise.reject(new Error('x')); // 立即 rejected

// 常见用法:把同步函数包装成异步函数
function toAsync(fn) {
  return (...args) => Promise.resolve().then(() => fn(...args));
}

Promise.all:全部成功才算成功

接收一个可迭代对象,返回一个新的 Promise:所有输入都 fulfilled 时,以结果数组 fulfilled;任意一个 rejected,立即以该原因 rejected。结果数组的顺序与输入顺序严格一致,与完成时间无关。

const [user, orders, coupons] = await Promise.all([
  fetchUser(id),
  fetchOrders(id),
  fetchCoupons(id),
]);

// 三个请求并发发出,总耗时 ≈ 最慢的那个
// 但如果 fetchCoupons 失败,整个 all 立刻失败,前两个的结果拿不到

关键细节:Promise.all 在第一个失败时就返回了,但其他 Promise 并不会被取消——它们仍然在后台执行。如果你不处理它们的 rejection,还可能触发 unhandledrejection。这一点在“取消与超时”一节会展开。

Promise.allSettled:无论成败都要结果

这是 ES2020 引入的方法。它会等所有输入都落定(无论成功失败),然后返回一个对象数组,每项形如 { status, value } 或 { status, reason }。

const results = await Promise.allSettled([
  fetchA(),
  fetchB(),
  fetchC(),
]);

results.forEach(r => {
  if (r.status === 'fulfilled') {
    console.log('成功', r.value);
  } else {
    console.log('失败', r.reason);
  }
});

// 典型场景:批量上报埋点、批量上传文件、仪表盘多卡片独立加载 // 任何一项失败都不影响其他项的结果展示

Promise.race:谁先落定就用谁

“竞速”方法。返回第一个落定的 Promise 的结果,无论它是成功还是失败。

// 经典用法:给任意 Promise 加超时
function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) => {
    setTimeout(() => reject(new Error('请求超时')), ms);
  });
  return Promise.race([promise, timeout]);
}

// 另一个用法:多个镜像源取最快的一个
const data = await Promise.race([
  fetch('https://cdn-a.example.com/data.json'),
  fetch('https://cdn-b.example.com/data.json'),
]);

Promise.any:第一个成功即可

ES2021 引入。any 与 race 的区别在于:race 谁先落定就返回谁,失败也算;any 会忽略失败,等到第一个成功。如果全部失败,则抛出一个 AggregateError,它的 errors 属性包含所有失败原因。

try {
  const fastest = await Promise.any([
    fetch('https://api-a.com'),
    fetch('https://api-b.com'),
    fetch('https://api-c.com'),
  ]);
} catch (err) {
  // err 是 AggregateError
  console.log(err.errors); // 三个失败原因组成的数组
}

Promise.try:同步异常也能被捕获

这是较新的提案方法,解决的是一个很实际的痛点:Promise.resolve().then(fn) 的写法虽然能捕获 fn 内部的异常,但代码读起来有点绕。Promise.try(fn) 更直观——同步执行 fn,无论是同步抛错还是返回 rejected Promise,都会被统一捕获。

Promise.try(() => JSON.parse(input))
  .then(data => render(data))
  .catch(err => showError(err));

六个方法横向对比

方法 成功条件 失败条件 典型场景
Promise.all 全部成功 任意一个失败即失败 首屏多个必需接口并发加载
Promise.allSettled 永远成功 永不失败 批量上报、独立卡片加载
Promise.race 第一个落定且成功 第一个落定且失败 超时控制、多源竞速
Promise.any 第一个成功 全部失败 多镜像容错、备用接口
Promise.resolve 立即成功 — 统一返回类型、包装同步值
Promise.reject — 立即失败 统一错误出口、测试桩
Promise.try 回调正常返回 回调抛错或返回 rejected 包装可能同步抛错的函数

选择哪一个,本质上是回答一个问题:“我需要的是全部结果,还是第一个结果?我能接受部分失败吗?”——想清楚这两点,方法自然就定了。

五、async / await:让异步代码看起来像同步

ES2017 引入的 async/await 是 JavaScript 异步编程的分水岭。它没有引入任何新的运行时机制,本质上只是基于 Promise 的语法糖——但这一层糖,极大降低了异步代码的心智负担。

三个基本规则

  • async 函数永远返回一个 Promise。函数里 return 的值会被自动包装成 fulfilled;抛出的异常会被自动包装成 rejected。
  • await 只能用在 async 函数内部(顶层 await 是模块才有的特性)。它会暂停当前函数的执行,把后续代码注册为微任务。
  • await 后面跟的不是 Promise 也没关系,会被自动包装成 Promise,同样会产生一次微任务延迟。
async function f1() {
  return 1;
}
// 等价于
function f2() {
  return Promise.resolve(1);
}

console.log(f1() instanceof Promise); // true

await 到底做了什么

把 await 拆开来看,它等价于:

// 这两段代码在行为上基本等价

async function A() {
  const v = await getData();
  console.log(v);
  return v;
}

function B() {
  return getData().then(v => {
    console.log(v);
    return v;
  });
}

理解这一点非常关键,因为它解释了很多“看起来奇怪”的行为。比如:await 之后的代码永远是异步的,哪怕 await 的是一个已经 resolved 的 Promise。它会插入到微任务队列中,而不是立即执行。

串行与并行:await 的位置决定性能

这是 async/await 使用中最容易造成性能损失的坑。看下面两段代码:

// 串行:总耗时 = A + B + C
async function slow() {
  const a = await fetchA();
  const b = await fetchB();
  const c = await fetchC();
  return [a, b, c];
}

// 假设每个请求 1 秒,总耗时 3 秒
// 后一个请求要等前一个回来才发出
// 并行:总耗时 = max(A, B, C)
async function fast() {
  const pa = fetchA();  // 立即发出
  const pb = fetchB();  // 立即发出
  const pc = fetchC();  // 立即发出
  return await Promise.all([pa, pb, pc]);
}

// 总耗时约 1 秒
// 关键是先创建 Promise(发起请求),再统一 await

判断标准:这几个异步操作之间有没有数据依赖?没有依赖就应该并行。有依赖(比如 B 需要 A 返回的 ID)就只能串行,但也可以考虑先并行取回 A 的所有候选数据,再做本地筛选。

错误处理:try/catch 的边界

async/await 最大的便利之一是可以用同步的 try/catch 捕获异步错误。但要注意作用域:

async function load() {
  try {
    const user = await fetchUser();
    const orders = await fetchOrders(user.id);
    return { user, orders };
  } catch (err) {
    // 两个 await 的错误都会到这里
    // 但无法区分是哪一个失败的
    console.error(err);
    throw err;  // 继续往外抛,让调用方知道失败了
  }
}

// 如果需要区分来源,缩小 try 的范围:
async function load2() {
  let user;
  try {
    user = await fetchUser();
  } catch (err) {
    throw new Error('加载用户失败', { cause: err });
  }
  // ……
}

注意一个反直觉的点:try/catch 只能捕获 await 表达式的错误,捕获不到你没有 await 的 Promise。下面这种写法是错误的:

反例
try { fetchData(); } catch (e) { }
没有 await,fetchData 返回的 Promise 立刻返回,异常发生在未来,catch 根本等不到它。
正解
try { await fetchData(); } catch (e) { }
await 会把 Promise 的 rejected 状态“解包”成同步抛出,catch 才能接住。

返回值与 finally

不管 Promise 是成功还是失败,finally 里的代码都会执行,而且它不接收参数、不改变链上的值。它是清理工作的最佳位置:关闭 loading、释放锁、断开连接。

async function save() {
  showLoading();
  try {
    return await api.save();
  } finally {
    hideLoading();  // 无论成功失败都会执行
  }
}

// 注意:finally 里如果 return 或 throw,会覆盖之前的返回值
// 这是一个非常危险的写法,应该避免

async 函数的执行时机

一个常见的误解是认为 async 函数会“异步启动”。实际上,async 函数在调用时会同步执行到第一个 await 为止,之后的代码才会进入微任务队列。

async function f() {
  console.log('1');  // 同步执行
  await null;
  console.log('2');  // 微任务
}

console.log('3');
f();
console.log('4');

// 输出:3 → 1 → 4 → 2

六、并发控制:不要让浏览器替你“抗压”

Promise.all 很方便,但它有一个致命的前提:它不会限制并发数量。如果你把 500 个请求一次性塞进 Promise.all,浏览器就会真的同时发出 500 个请求。

无限并发的三个后果

  • 浏览器连接数限制:Chrome 对同一域名通常只允许 6 个并发连接,超出的请求会排队。你以为在并行,实际上后面的请求迟迟发不出去。
  • 服务端压力:500 个并发请求打到后端,可能直接触发限流甚至把服务打挂。
  • 内存与页面响应:大量请求的响应同时返回,会集中占用内存并产生连续的微任务,导致页面卡顿。

手写一个并发池

核心思路非常简单:维持固定数量的“工人”,每个工人循环从任务队列里取任务执行,直到队列为空。

async function pool(tasks, limit = 3) {
  const results = new Array(tasks.length);
  let cursor = 0;

  async function worker() {
    while (cursor < tasks.length) {
      const i = cursor++;
      // 单个任务失败不影响其他任务,按需决定是否捕获
      results[i] = await tasks[i]();
    }
  }

  const workerCount = Math.min(limit, tasks.length);
  await Promise.all(
    Array.from({ length: workerCount }, () => worker())
  );

  return results;
}

// 使用:一次最多 3 个请求,500 个任务总耗时约为串行的 1/3
const data = await pool(
  urls.map(url => () => fetch(url)),
  3
);

为什么这里用 while 而不是 for

因为每个 worker 需要动态地从共享游标取任务。如果写成 for (let i = 0; i < limit; i++),每个 worker 就只会处理固定下标,无法在完成一个任务后继续领取下一个。用 cursor++ 这种“取号”的方式,天然实现了任务的分发。

需要说明的是,cursor++ 在这里是安全的,因为 JavaScript 是单线程的,两个 worker 不可能真正同时执行这一行。这也是为什么单线程在某些场景下反而简化了并发编程——你不需要考虑锁。

带失败隔离的并发池

上面的版本中,任意一个任务抛出异常都会让对应的 worker 提前退出,最终 Promise.all 失败,剩下的任务也不会被处理。生产环境里更常见的是“尽力而为”的版本:

async function poolSafe(tasks, limit = 3) {
  const results = new Array(tasks.length);
  let cursor = 0;

  async function worker() {
    while (cursor < tasks.length) {
      const i = cursor++;
      try {
        results[i] = {
          status: 'fulfilled',
          value: await tasks[i](),
        };
      } catch (reason) {
        results[i] = { status: 'rejected', reason };
      }
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(limit, tasks.length) }, () => worker())
  );

  return results;
}

这个版本的返回结构其实就相当于自己实现了一个带并发限制的 Promise.allSettled,在批量上传、批量导入这类场景里非常实用。

要不要引入第三方库

社区里有 p-limit、async、bluebird 等成熟方案。p-limit 的 API 极其简洁:

import pLimit from 'p-limit';

const limit = pLimit(3);
const results = await Promise.all(
  urls.map(url => limit(() => fetch(url)))
);

它的体积很小,逻辑也很清晰。但理解手写版本的原理依然重要——只有知道池子是怎么跑起来的,你才能在出现“某个任务卡住导致整个池子停滞”这类问题时快速定位。

七、取消、超时与中断:Promise 最大的短板

Promise 有一个被广泛讨论的“缺陷”:它一旦创建就无法取消。这在早期是一个设计取舍——取消会引入复杂的语义问题(取消后状态是什么?取消的回调谁来调?)。直到 AbortController 出现,这个问题才有了一个统一的、跨 API 的解决方案。

AbortController:标准的取消原语

AbortController 由一个信号对象(signal)和一个触发方法(abort)组成。signal 可以被传递给 fetch、事件监听器,也可以被你自己封装的异步函数消费。

const controller = new AbortController();
const { signal } = controller;

fetch('/api/data', { signal })
  .then(res => res.json())
  .then(data => render(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求已被取消');
    } else {
      showError(err);
    }
  });

// 3 秒后取消
setTimeout(() => controller.abort(), 3000);

用 AbortSignal 实现超时

现代浏览器提供了 AbortSignal.timeout() 静态方法,一行代码搞定超时:

// 5 秒后自动中止
fetch('/api/slow', {
  signal: AbortSignal.timeout(5000),
});

// 组合多个信号:任意一个触发即中止
const combined = AbortSignal.any([
  userController.signal,
  AbortSignal.timeout(5000),
]);

如果需要兼容更老的环境,可以用 Promise.race 手动实现。但要记住前面提到的问题:race 只是让你不再等待慢的那个,它本身并不会中止请求。

function withTimeout(promise, ms, controller) {
  const timer = new Promise((_, reject) => {
    setTimeout(() => {
      controller?.abort();  // 关键:真正中止请求
      reject(new Error('TIMEOUT'));
    }, ms);
  });
  return Promise.race([promise, timer]);
}

处理“竞态”问题

这是搜索框、标签页切换这类场景的经典问题:用户快速输入 “abc”,依次发出三个请求。如果第一个请求的响应最后才回来,界面会显示 “a” 的结果,而输入框里已经是 “abc” 了。

解决方案有三种,从简单到规范:

// 方案一:请求序号,只接受最新的
let seq = 0;
async function search(keyword) {
  const id = ++seq;
  const data = await fetch(`/api/search?q=${keyword}`);
  if (id !== seq) return;  // 已经有更新的请求了,丢弃本次结果
  render(data);
}

// 方案二:AbortController,取消上一个请求
let current;
function search2(keyword) {
  current?.abort();
  current = new AbortController();
  return fetch(`/api/search?q=${keyword}`, { signal: current.signal })
    .then(res => res.json())
    .then(render)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
}

方案二更好,因为它不仅避免了错误的结果渲染,还真正节省了网络带宽与服务器资源。方案一只是丢弃结果,请求依然完整跑完了。

未处理的 rejection:一个隐藏的定时炸弹

当你用 Promise.race 实现超时,或者用 Promise.all 处理并发时,那些“落败”的 Promise 如果最终 rejected,就变成了无人处理的 rejection。在浏览器里会打印一条警告,在 Node.js 中默认会导致进程退出。

// 浏览器:全局兜底
window.addEventListener('unhandledrejection', event => {
  console.error('未处理的 rejection:', event.reason);
  reportToServer(event.reason);
  event.preventDefault();  // 阻止控制台默认报错
});

// Node.js:全局兜底
process.on('unhandledRejection', (reason) => {
  console.error(reason);
});

// 更推荐:给可能落单的 Promise 主动加上 catch
const p = fetchData();
p.catch(() => {});  // 标记为已处理
return Promise.race([p, timeout]);

八、手写 Promise:理解状态机的最好方式

面试里高频出现的“手写 Promise”,目的从来不是让你真的去实现一个生产级库,而是检验你是否理解状态流转、回调队列、异步调度、链式传递这四个核心机制。下面按最小可用的标准来实现一遍。

第一步:状态与构造

const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

class MyPromise {
  constructor(executor) {
    this.state = PENDING;
    this.value = undefined;
    this.callbacks = [];  // 待执行的回调队列

    const resolve = (value) => this.settle(FULFILLED, value);
    const reject = (reason) => this.settle(REJECTED, reason);

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  settle(state, value) {
    if (this.state !== PENDING) return;  // 状态不可逆
    this.state = state;
    this.value = value;
    this.flush();
  }

  flush() {
    this.callbacks.forEach(cb => queueMicrotask(cb));
    this.callbacks = [];
  }
}

这里已经体现了两个关键点:settle 里判断状态,保证只落定一次;回调通过 queueMicrotask 调度,保证异步执行。后者是最容易被忽略的细节——如果直接同步调用回调,整个 Promise 的时序语义就崩了。

第二步:then 与链式传递

class MyPromise {
  // …… 接上面的构造部分

  then(onFulfilled, onRejected) {
    // then 必须返回一个新的 Promise,才能链式
    return new MyPromise((resolve, reject) => {
      const handle = () => {
        const cb = this.state === FULFILLED ? onFulfilled : onRejected;

        // 值穿透:不是函数就跳过
        if (typeof cb !== 'function') {
          this.state === FULFILLED
            ? resolve(this.value)
            : reject(this.value);
          return;
        }

        try {
          const result = cb(this.value);
          // 如果返回的是 Promise,需要“跟随”它的状态
          if (result instanceof MyPromise) {
            result.then(resolve, reject);
          } else {
            resolve(result);
          }
        } catch (err) {
          reject(err);  // 回调抛错 → 新 Promise 被拒绝
        }
      };

      if (this.state === PENDING) {
        this.callbacks.push(handle);  // 还没落定,先存起来
      } else {
        queueMicrotask(handle);  // 已落定,直接进微任务
      }
    });
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

这段代码里有四个容易出错的细节:

  • 回调必须异步执行,用 queueMicrotask 而不是同步调用。
  • 返回值是 Promise 时要跟随,否则链式调用会拿到一个嵌套的 Promise。
  • 回调抛错要转成 reject,而不是让异常逃逸到全局。
  • pending 状态要存回调,不能直接执行。

第三步:静态方法

static resolve(value) {
  if (value instanceof MyPromise) return value;
  return new MyPromise(resolve => resolve(value));
}

static reject(reason) {
  return new MyPromise((_, reject) => reject(reason));
}

static all(iterable) {
  const list = [...iterable];
  return new MyPromise((resolve, reject) => {
    const results = [];
    let count = 0;

    if (list.length === 0) return resolve([]);

    list.forEach((item, i) => {
      MyPromise.resolve(item).then(
        value => {
          results[i] = value;
          if (++count === list.length) resolve(results);
        },
        reject  // 任意一个失败立即整体失败
      );
    });
  });
}

static race(iterable) {
  return new MyPromise((resolve, reject) => {
    for (const item of iterable) {
      MyPromise.resolve(item).then(resolve, reject);
    }
  });
}

注意 Promise.all 里 results[i] = value 的写法:用索引赋值而不是 push,因为 Promise 完成顺序是不确定的,用 push 会导致结果数组的顺序和输入顺序不一致。

真实 Promise 还有哪些额外能力

上面这个实现覆盖了核心机制,但真实规范还包含不少细节:

  • thenable 处理:任何带有 then 方法的对象都能被“同化”,并且要处理递归解析(resolve 一个自身会死循环)。
  • 多次调用 resolve/reject:只认第一次,后续忽略(我们已实现)。
  • 回调必须异步且只执行一次。
  • 错误传播:then 内部的异常必须传给下一个 Promise,而不是抛到全局。
  • 性能优化:V8 对 Promise 有大量内联缓存与快速路径优化,手写版在性能上完全无法相比。

所以结论是:手写 Promise 用来理解原理非常有价值,但生产代码永远用原生实现。

九、九个高频陷阱:每一个都真实咬过人

陷阱 1 forEach 里的 async 回调——await 完全失效
陷阱 2 在循环里逐个 await,把并行写成了串行
陷阱 3 then 回调里忘了 return,后续拿到 undefined
陷阱 4 在 Promise 构造函数里做异步初始化,外部拿不到状态
陷阱 5 用 try/catch 包没有 await 的调用
陷阱 6 把 async 函数当成构造函数用
陷阱 7 忘记处理 rejection,导致进程崩溃或静默失败
陷阱 8 在微任务里递归产生微任务,导致页面完全卡死
陷阱 9 把 Promise 当缓存用,反复创建导致重复请求

陷阱 1:forEach + async

这是新手最容易踩的坑,没有之一。

// 错误:forEach 不会等待 async 回调
async function bad() {
  [1, 2, 3].forEach(async (id) => {
    const data = await fetchById(id);
    console.log(data);
  });
  console.log('全部完成');  // 会最先打印!
}

// 正确:for...of 会按顺序 await
async function good1() {
  for (const id of [1, 2, 3]) {
    const data = await fetchById(id);
    console.log(data);
  }
  console.log('全部完成');
}

// 正确:并行执行,全部完成后继续
async function good2() {
  await Promise.all([1, 2, 3].map(id => fetchById(id)));
  console.log('全部完成');
}

原因很简单:forEach 的签名是 forEach(callback),它不关心回调的返回值,自然也不会去 await 一个 Promise。同理,map 虽然能返回 Promise 数组,但如果你忘了 await Promise.all,依然不会等待。

陷阱 2:循环里的串行 await

即使你用了 for...of 让 await 生效了,也可能付出了性能代价。如果几次请求之间没有依赖关系,串行执行会让总耗时线性叠加:

// 慢:n 个请求,总耗时 = n × 单次耗时
for (const id of ids) {
  results.push(await fetchById(id));
}

// 快:先全部发出,再统一等待
const results = await Promise.all(ids.map(id => fetchById(id)));

// 折中:需要限流时用并发池
const results = await pool(ids.map(id => () => fetchById(id)), 5);

陷阱 3:忘了 return

这是最隐蔽的一类 bug,因为代码不会报错,只是结果不对。

// 错误:没有 return,下一个 then 拿到 undefined
getUser()
  .then(user => {
    getOrders(user.id);  // 忘了 return!
  })
  .then(orders => console.log(orders));  // undefined

// 正确
getUser()
  .then(user => getOrders(user.id))
  .then(orders => console.log(orders));

陷阱 4:在构造函数里做异步初始化

构造函数不能是 async,因为构造函数必须返回 this。如果你在里面发请求,外部无法知道初始化何时完成。

// 错误:外部无法等待初始化
class Api {
  constructor() {
    fetch('/config').then(r => r.json()).then(c => {
      this.config = c;  // 何时完成?不知道
    });
  }
}

// 正确:用静态异步工厂方法
class Api {
  static async create() {
    const instance = new Api();
    instance.config = await fetch('/config').then(r => r.json());
    return instance;
  }
}

const api = await Api.create();

陷阱 5、6、7、8、9 简析

  • try/catch 包不住没有 await 的调用:前面已经展开,核心是“异常发生在未来,catch 在当下”。
  • async 函数不能当构造函数:new (async function(){})() 会抛 TypeError,因为 async 函数的返回值永远是 Promise,无法作为实例返回。
  • 忘记处理 rejection:浏览器控制台的红色警告不是装饰,它意味着你的用户可能看到的是一个永远 loading 的界面。
  • 微任务饥饿:下面这个写法会让页面彻底无响应,因为微任务队列永远清不空:
    function starve() {
      Promise.resolve().then(starve);
    }
    starve();  // 页面卡死,连渲染都轮不到
  • 把 Promise 当缓存:Promise 只能解决一次,如果多个地方调用同一个函数,会重复发起请求。需要缓存的是“请求结果”或“正在进行的 Promise 实例”:
    const cache = new Map();

    function fetchOnce(key, fn) {
      if (cache.has(key)) return cache.get(key);
      const p = fn().finally(() => cache.delete(key));
      cache.set(key, p);
      return p;
    }

十、调试与性能:让异步问题无所遁形

异步调用栈

异步代码最难调试的地方在于,出错时的调用栈是“断”的——你只看到 Promise 链末尾那一段,看不到最初的调用者。现代 DevTools 已经支持异步调用栈追踪:

  • Chrome DevTools 的 Sources 面板默认开启 Async 调用栈,会在栈帧中展开 async 边界,让你从错误点一路回溯到最初的触发位置。
  • 可以在代码里加 console.trace(),在关键节点打印调用路径。
  • 给 Promise 链的每一环加上有意义的命名,可以显著提升断点调试的效率。
// 给 Promise 起名字,报错信息更清晰
const loadUser = fetch('/user');
loadUser.catch(e => { throw new Error('loadUser failed', { cause: e }); });

// Error 的 cause 选项能保留原始错误链
try {
  await loadUser;
} catch (err) {
  console.log(err.message);  // loadUser failed
  console.log(err.cause);    // 原始的 fetch 错误
}

长任务与页面响应

浏览器需要每 16.7ms 渲染一帧(60fps)。如果主线程被一个耗时 200ms 的同步任务占住,这一帧就丢了,用户就会感觉到卡顿。这个超过 50ms 的任务在性能面板里被标记为“长任务”。

一帧的预算分配(16.7ms) 60fps
JS
样式
布局
绘制
余量
异步代码本身不会阻塞主线程,但一次返回的大量结果如果在同一个微任务周期里被集中处理(比如 5000 条数据的循环渲染),就会形成长任务。解决办法是把大任务切片,用 requestIdleCallback 或 setTimeout 分帧处理。
// 分片处理大数组,避免阻塞渲染
async function processInChunks(items, chunkSize = 200) {
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach(renderItem);

    // 让出主线程,给浏览器一次渲染机会
    await new Promise(r => setTimeout(r, 0));
  }
}

微任务饥饿的识别与规避

如果页面完全卡死,但 Performance 面板里看不到任何长任务,而是在不断刷新的微任务记录,那就是微任务饥饿。常见诱因是:

  • 在 then 里递归调用返回 Promise 的函数,没有让出事件循环。
  • 使用 queueMicrotask 递归。
  • 某些响应式框架的更新循环陷入死循环(A 更新触发 B 更新,B 又触发 A)。

规避方式就是在每一轮之间强制插入一个宏任务,把控制权还给浏览器:await new Promise(r => setTimeout(r, 0)),或者使用 scheduler.yield()(如果环境支持)。

网络面板看并发

打开 DevTools 的 Network 面板,切换到瀑布图视图,你可以直观地看到:

  • 哪些请求是并行发出的(同一时间点开始)。
  • 哪些是串行的(前一个完成才发下一个,呈阶梯状)。
  • 是否存在长时间的 Stalled(排队等待连接),这通常意味着并发数超了。
  • 请求的 Waterfall 是否有明显的 Waiting (TTFB) 过长。

很多性能问题不需要看代码,只需要看这张瀑布图就能定位:如果首屏的五个接口是阶梯状排列的,那一定是漏了并行优化。

十一、工程实战:四个可以直接抄走的工具函数

前面讲了原理,这一节给出四个在真实项目里反复验证过的实现。它们都很短,但覆盖了绝大多数异步基础设施需求。

1. 带指数退避的重试

网络抖动是常态,一次失败不代表真的失败。重试时必须加退避,否则会把已经过载的服务打得更惨。

async function retry(fn, { times = 3, baseDelay = 300, maxDelay = 5000 } = {}) {
  let lastError;

  for (let attempt = 0; attempt < times; attempt++) {
    try {
      return await fn(attempt);
    } catch (err) {
      lastError = err;

      // 最后一次失败不再等待
      if (attempt === times - 1) break;

      // 指数退避 + 随机抖动,避免同时重试造成雪崩
      const delay = Math.min(
        baseDelay * 2 ** attempt + Math.random() * 100,
        maxDelay
      );
      await new Promise(r => setTimeout(r, delay));
    }
  }

  throw lastError;
}

// 使用:只对可重试的错误重试
const data = await retry(async () => {
  const res = await fetch('/api/data');
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
});

需要注意:不是所有错误都值得重试。4xx 类的错误(参数错误、权限不足)重试多少次都是一样的结果,只有网络错误、超时、5xx 才适合重试。可以在 fn 内部判断并抛出一个标记为“不可重试”的错误类型。

2. 并发去重(请求合并)

同一个接口在短时间内被多处调用,是 SPA 里的常见现象。用一张 Map 缓存“正在进行的 Promise”,就能把多次请求合并成一次:

const inflight = new Map();

function dedupe(key, fn) {
  if (inflight.has(key)) {
    return inflight.get(key);  // 复用同一个 Promise
  }

  const promise = fn().finally(() => {
    inflight.delete(key);  // 完成后清理,允许下次重新请求
  });

  inflight.set(key, promise);
  return promise;
}

// 使用
const user = await dedupe('user:123', () => fetchUser(123));
// 同一时刻再次调用 dedupe('user:123', ...) 会拿到同一个 Promise

finally 在这里非常关键:无论成功还是失败都要清理缓存,否则一次失败的请求会让这个 key 永久失效。

3. 带超时与取消的请求封装

async function request(url, { timeout = 10000, signal, ...options } = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new Error('TIMEOUT')), timeout);

  // 外部取消信号也要能传导进来
  const onAbort = () => controller.abort(signal?.reason);
  signal?.addEventListener('abort', onAbort, { once: true });

  try {
    const res = await fetch(url, { ...options, signal: controller.signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } finally {
    clearTimeout(timer);
    signal?.removeEventListener('abort', onAbort);
  }
}

这个封装里有一个容易被忽略的细节:finally 中的 removeEventListener。如果只 addEventListener 而不移除,长时间运行的应用会不断堆积监听器,形成内存泄漏。

4. 串行队列

有些操作必须严格顺序执行:写文件、更新同一条数据库记录、操作同一个 DOM 节点。把 Promise 串成一条链,就能实现一个简单的队列:

function createQueue() {
  let tail = Promise.resolve();

  return function enqueue(task) {
    // 无论前一个成功还是失败,都继续执行下一个
    const result = tail.then(task, task);

    // tail 吞掉错误,避免一个失败导致整条链断掉
    tail = result.catch(() => {});

    return result;
  };
}

const enqueue = createQueue();

enqueue(() => writeFile('a.txt', '1'));
enqueue(() => writeFile('a.txt', '2'));
enqueue(() => writeFile('a.txt', '3'));
// 严格按 1 → 2 → 3 的顺序写入

tail.then(task, task) 这个写法很有意思:成功和失败都用同一个函数处理,保证队列不会因为一个任务失败而永久卡住。而返回的 result 保留了真实的成功/失败状态,调用方仍然可以 catch。

十二、异步编程检查清单

把全文的结论浓缩成一份可以贴在工位上的清单。每次写完异步代码,扫一眼这 20 条,能挡掉绝大多数问题。

  • 知道同步代码会阻塞一切,耗时计算不放主线程。
  • 分得清微任务与宏任务,能预测输出顺序。
  • Promise 状态不可逆,resolve 之后再调用任何方法都无效。
  • executor 是同步执行的,不要在里面写耗时逻辑。
  • then 永远返回新 Promise,链式调用的基础。
  • 每个 then 回调都要 return,否则下一个环节拿到 undefined。
  • 用 catch 收尾,而不是 then 的第二个参数。
  • 无依赖的异步操作并行执行,用 Promise.all 而不是逐个 await。
  • 需要全部结果就用 allSettled,不要用 all 硬扛。
  • 需要超时就用 AbortSignal.timeout,而不是只 race 一个定时器。
  • 不要用 forEach 跑 async,用 for...of 或 Promise.all(map)。
  • 循环内 await 前先问一句:“这几步有依赖吗?”
  • 批量请求必须限流,用并发池控制在 3~6 个。
  • 给可能落单的 Promise 加 catch,避免 unhandledrejection。
  • 全局注册 unhandledrejection 监听,把错误上报到监控系统。
  • 处理竞态:用请求序号或 AbortController 丢弃过期结果。
  • 别用 async 函数当构造函数,用静态工厂方法替代。
  • finally 里不要 return 或 throw,它会覆盖之前的返回值。
  • 避免微任务饥饿,递归时记得插入宏任务让出主线程。
  • 大数组处理要分片,每一片之间让出渲染机会。
// 一份可直接复用的异步工具集骨架

// 1. 统一请求:超时 + 取消 + 错误规范化
async function request(url, options) { /* ... */ }

// 2. 重试:指数退避 + 抖动
async function retry(fn, opts) { /* ... */ }

// 3. 并发池:控制同时在飞的任务数
async function pool(tasks, limit) { /* ... */ }

// 4. 去重:合并同一时刻的相同请求
function dedupe(key, fn) { /* ... */ }

// 5. 串行队列:保证顺序执行
function createQueue() { /* ... */ }

// 6. 分片处理:避免长任务阻塞渲染
async function processInChunks(items, size) { /* ... */ }

异步编程的本质,是在单线程的世界里编排多个未来的时间点。Promise 提供了状态与组合的抽象,async/await 提供了阅读的便利,而事件循环决定了这一切什么时候真正发生。理解了这三层,你写出的代码就不再是“照着示例改一改”,而是清楚地知道每一行会在什么时候、以什么顺序执行。

最后送给所有正在和异步搏斗的开发者一句话:当一段异步代码行为诡异时,先别急着加 console.log,先把它的执行顺序在纸上画一遍。微任务在哪里、宏任务在哪里、哪些 Promise 还没落定——画出来,答案往往就自己浮现了。