回顾 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);
});
});
});三个问题:
- 嵌套深——代码向右增长,可读性差
- 错误处理分散——每一层都需要独立的错误判断,不能统一
try...catch - 控制反转——你把回调的控制权交给了
$.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 之后才打印 22. 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(...)) |