回顾 JavaScript 异步编程的三阶段演进:回调 → Promise → async/await。每一步都在解决上一步的痛点,也带来了新的需要注意的细节。

为什么需要异步

JavaScript 是单线程语言,与 DOM 渲染共用同一个线程。同步操作会阻塞整个页面——这段代码只要不执行完,用户就无法点击、无法滚动。

// 同步 —— 阻塞
console.log(100);
alert(200); // 弹窗阻断:不点确认,后面的代码永远不会执行
console.log(300);
 
// 异步 —— 不阻塞
console.log(100);
setTimeout(() => console.log(200), 1000);
console.log(300);
// 输出:100 300 200 —— 300 不等 setTimeout 完成就立即打印了

异步的典型场景:网络请求、定时任务、资源加载(图片/音频/视频)、用户交互(点击/滚动/拖拽的事件回调)。

阶段一:回调函数

最早的异步方案是把”后续操作”写成一个回调函数传给异步 API:

// 回调地狱
$.get(url1, (data1) => {
  console.log(data1);
  $.get(url2, (data2) => {
    console.log(data2);
    $.get(url3, (data3) => {
      console.log(data3);
    });
  });
});

三个问题:

  1. 嵌套深——代码向右增长,可读性差
  2. 错误处理分散——每一层都需要独立的错误判断,不能统一 try...catch
  3. 控制反转——你把回调的控制权交给了 $.get,你不知道它会不会多次调用、什么时候调用

还有一个更隐蔽的问题:异步回调中的错误(如 throw new Error())无法被外层的 try...catch 捕获,因为回调执行时原始调用栈已经结束。

阶段二:Promise

Promise 不改变异步的本质,但改变了组织代码的方式——把”发起任务”和”处理结果”分离:

function getData(url) {
  return new Promise((resolve, reject) => {
    $.ajax({
      url,
      success(data) {
        resolve(data);
      },
      error(err) {
        reject(err);
      },
    });
  });
}
 
getData(url1)
  .then((data1) => {
    console.log(data1);
    return getData(url2);
  })
  .then((data2) => {
    console.log(data2);
    return getData(url3);
  })
  .then((data3) => {
    console.log(data3);
  })
  .catch((err) => console.error(err));

对比:

回调Promise
嵌套结构链式平铺
错误分散在各层一个 catch 统一处理
可能被多次调用状态不可逆,只 resolve/reject 一次
无法编排多个异步Promise.all / race / allSettled / any

Promise 组合器

// Promise.all —— 全部成功才成功,一个失败全失败
await Promise.all([fetch1(), fetch2(), fetch3()]);
 
// Promise.allSettled —— 等所有完成,不管成功失败,返回结果数组
const results = await Promise.allSettled([fetch1(), fetch2()]);
 
// Promise.race —— 第一个完成的决定结果(无论成功失败)
const first = await Promise.race([fetch1(), timeout(5000)]);
 
// Promise.any —— 第一个成功的决定结果,全失败才 reject
const firstSuccess = await Promise.any([fetch1(), fetch2(), fetch3()]);

then 和 catch 的状态传递

// then 正常返回 → resolved,后续 then 继续执行
Promise.resolve()
  .then(() => console.log(1))
  .catch(() => console.log(2)) // 不触发
  .then(() => console.log(3));
// 输出:1 3
 
// then 内抛错 → rejected → catch 捕获 → 恢复为 resolved
Promise.resolve()
  .then(() => {
    console.log(1);
    throw new Error('oops');
  })
  .catch(() => console.log(2)) // 触发,返回 resolved
  .then(() => console.log(3));
// 输出:1 2 3
 
// then 内抛错 → catch 捕获但 catch 里又抛错 → 继续 rejected
Promise.resolve()
  .then(() => {
    throw new Error('err1');
  })
  .catch(() => {
    throw new Error('err2');
  })
  .catch(() => console.log('caught err2'));

核心规则:catch 没有抛错则返回 resolved,抛错则返回 rejected。

一个未捕获的 Promise rejection

const p = Promise.reject('oops');
// 此时没有 .catch 处理,浏览器会报 Unhandled Promise Rejection
// 在 Node.js 中从 v15 开始,未处理的 rejection 会终止进程
 
// 补救:在下一个微任务之前加 catch 就来得及
setTimeout(() => p.catch(console.error), 0); // 可以捕获

阶段三:async/await

Promise 的语法糖,用同步写法写异步逻辑。关键规则:async 函数自动返回 Promise,await 后接 Promise 并等待其结果:

async function fn() {
  return 100; // 等价于 return Promise.resolve(100)
}
fn().then((data) => console.log(data)); // 100
 
// await 等待 Promise
(async function () {
  const img1 = await loadImg(url1);
  console.log(img1.width, img1.height);
  const img2 = await loadImg(url2);
  console.log(img2.width, img2.height);
})();

错误处理

(async function () {
  try {
    const res = await fetch('/api/data');
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = await res.json();
    console.log(data);
  } catch (err) {
    // 任何一个 await 或同步代码抛错,都会跳到这里
    console.error('请求失败:', err.message);
  }
})();

try...catch 能捕获 await 表达式的 rejection 和同步代码的异常,但不能捕获 Promise 构造函数里异步抛出的错误。

几个容易忽视的细节

1. async 函数返回的 Promise 何时 resolve?

async function demo() {
  console.log(1);
  await 0;
  console.log(2);
}
demo();
console.log(3);
// 输出:1 3 2
// await 后面的代码被放入微任务队列,所以在 3 之后才打印 2

2. forEach 配合 await 不会排队

// ❌ forEach 的回调是 async 函数,但 forEach 本身不等它完成
['a', 'b', 'c'].forEach(async (item) => {
  await doSomething(item); // 三个 doSomething 几乎同时触发
});
 
// ✅ for...of 会等每次迭代的 await
for (const item of ['a', 'b', 'c']) {
  await doSomething(item);
}
 
// ✅ 全部并行、全部完成后继续
await Promise.all(['a', 'b', 'c'].map(doSomething));

3. 顶层的 await

// ES2022+:模块顶层可以使用 await(无需 async 包裹)
// 但会阻塞整个模块的加载
const data = await fetch('/api/config').then((r) => r.json());
export { data };

手写 Promise

function loadImg(src) {
  return new Promise((resolve, reject) => {
    const img = document.createElement('img');
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`图片加载失败:${src}`));
    // src 放最后 —— 如果图片在缓存中,onload 可能同步触发
    // 放在事件绑定之后能确保事件不丢失
    img.src = src;
  });
}
 
loadImg('/avatar.png')
  .then((img) => console.log('宽度:', img.width))
  .catch((err) => console.error(err));

异步取消:AbortController

Promise 本身不支持取消,但可以通过 AbortController 与支持它的 API(fetch)配合实现:

const controller = new AbortController();
 
fetch('/api/data', { signal: controller.signal })
  .then((res) => res.json())
  .catch((err) => {
    if (err.name === 'AbortError') {
      console.log('请求被取消');
    }
  });
 
// 在需要取消时
controller.abort();

这在搜索联想(用户快速输入时取消上一个请求)、组件卸载时取消未完成的请求等场景中非常实用。

for…of 的排队效果

function multi(num) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(num * num), 1000);
  });
}
 
const nums = [1, 2, 3];
 
// forEach —— 三个异步同时启动,1 秒后同时完成
nums.forEach(async (i) => {
  const res = await multi(i);
  console.log(res); // 1 秒后同时打印:1 4 9
});
 
// for...of —— 排队:1 秒打 1,再 1 秒打 4,再 1 秒打 9
(async function () {
  for (const i of nums) {
    const res = await multi(i);
    console.log(res);
  }
})();

异步的本质

async/await 消灭了回调嵌套,但 JavaScript 仍然是单线程,异步仍然依赖 Event Loop 调度。await 后面的代码被放入微任务队列——它只是让代码看起来同步,执行的底层机制没变。

写法底层
回调视具体 API,放入宏任务/微任务队列
Promise.then微任务队列
await 后的代码微任务队列(等价于 Promise.resolve().then(...)

相关笔记