主题
第 05 章 · 异步编程(Promise / async / 事件循环)
一句话开篇:本章带你从"回调地狱"一路爬到"async/await 优雅天堂",并彻底搞懂 JS 单线程下事件循环、宏任务、微任务的关系——面试必考、工作天天用。
0. 生活类比(先建立直觉)
把 JS 想象成一家"小餐厅",只有一个厨师(主线程)。
- 客人点餐(同步代码):厨师立刻做,做完递出
- 客人说"做好了叫我"(异步任务):厨师把单子贴在窗口,自己去做下一单
- **服务员(事件循环)**不停地巡视窗口,谁好了就喊"X 号餐好了"
- VIP 通道(微任务):插队,每完成一道菜就先把 VIP 全处理完
- 普通通道(宏任务):排队,VIP 处理完才轮到下一个
| JS 概念 | 餐厅类比 |
|---|---|
| 主线程 | 一个厨师 |
| 同步代码 | 立等的菜 |
| 宏任务(setTimeout、setInterval、I/O) | 普通排队 |
| 微任务(Promise.then、queueMicrotask) | VIP 插队 |
| 事件循环 | 跑堂的服务员 |
| Promise | 一张写着"我去帮你做"的承诺单 |
| async/await | "等这单做完我再继续" |
1. 为什么需要异步
JS 是单线程的,如果 IO(网络请求、文件读取、定时器)是同步的,整个页面会卡死。
js
// 假想的同步请求(不存在!)
const data = httpGet('/api'); // 卡 3 秒
render(data);
console.log('永远到不了,页面早卡死了');异步的本质:先不等结果,把"完成后要做的事"挂在那儿,等结果回来再执行。
2. 异步演进史
2.1 回调函数(Callback)
最古老的方式:把"完成后要做的事"作为参数传给异步 API。
js
fs.readFile('a.txt', (err, data) => {
if (err) return console.error(err);
fs.readFile('b.txt', (err, data2) => {
if (err) return console.error(err);
fs.readFile('c.txt', (err, data3) => {
// 回调地狱(Callback Hell)
});
});
});痛点:
- 嵌套层级深,难读
- 错误处理麻烦(每层都要判断 err)
- 控制流复杂(并行/串行/竞速难写)
2.2 Promise(ES6)
Promise 是一个"承诺"对象,代表一个异步操作的最终完成或失败。
三种状态:
text
resolve(value)
pending ──────────────────────► fulfilled
│ │
│ reject(reason) │ 状态一旦改变
▼ │ 就不能再变
rejected ◄──────────────────────────┘核心 API:
js
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve('OK'), 1000);
});
p.then(value => console.log(value)) // OK
.catch(err => console.error(err))
.finally(() => console.log('done'));2.3 async / await(ES2017)
用同步的写法写异步,本质是 Promise 的语法糖。
js
async function getData() {
try {
const a = await readFile('a.txt');
const b = await readFile('b.txt');
return a + b;
} catch (err) {
console.error(err);
}
}| 写法 | 优点 | 缺点 |
|---|---|---|
| 回调 | 简单直接 | 回调地狱、错误难处理 |
| Promise | 链式调用、统一错误处理 | 仍然有 .then 嵌套 |
| async/await | 同步语法、try/catch 错误处理 | 易写出串行(性能陷阱) |
3. Promise 详解
3.1 Promise 链式调用
.then 永远返回新的 Promise,所以可以链式:
js
fetch('/api')
.then(res => res.json()) // 返回新 Promise
.then(data => transform(data))
.then(result => render(result))
.catch(err => showError(err));值穿透:.then 不传参数时,值会"穿透"到下一个 then。
js
Promise.resolve(1).then().then().then(v => console.log(v)); // 13.2 静态方法对比
| 方法 | 行为 | 全部成功 | 任一失败 | 返回 |
|---|---|---|---|---|
Promise.all([p1,p2,p3]) | 并行 | 等所有完成 | 立即 reject | [v1,v2,v3] |
Promise.race([p1,p2,p3]) | 并行 | 取最快的 | 取最快的 | 第一个 settle 的值 |
Promise.allSettled([p1,p2,p3]) | 并行 | 等所有 settle | 不会 reject | [{status,value/reason}] |
Promise.any([p1,p2,p3]) | 并行 | 取第一个成功 | 全 reject 才 reject | 第一个成功的值 |
js
// all:一个错全错
Promise.all([fetch('/a'), fetch('/b')])
.then(([a, b]) => render(a, b));
// allSettled:知道每个的结果,不管成败
Promise.allSettled(promises).then(results => {
results.forEach(r => {
if (r.status === 'fulfilled') console.log('OK', r.value);
else console.log('FAIL', r.reason);
});
});
// race:超时控制
Promise.race([
fetch('/api'),
new Promise((_, reject) => setTimeout(() => reject('timeout'), 3000))
]);3.3 错误处理
js
// ❌ 错:try/catch 抓不到 Promise 错误
try {
Promise.reject('err');
} catch (e) { /* 进不来 */ }
// ✅ 对:用 .catch 或 await + try/catch
Promise.reject('err').catch(e => console.log(e));
// async/await
async function f() {
try {
await Promise.reject('err');
} catch (e) { console.log(e); }
}💡 未捕获的 Promise rejection 会触发
unhandledrejection事件。
4. async / await 深入
4.1 本质
async 函数返回一个 Promise:
js
async function f() { return 1; }
// 等价于
function f() { return Promise.resolve(1); }await 会"暂停"函数执行,等 Promise resolve 后拿到值再继续:
js
async function getUser() {
const res = await fetch('/api/user'); // 暂停在这儿
const data = await res.json(); // 暂停在这儿
return data;
}4.2 串行 vs 并行
串行(慢):
js
const a = await fetchA(); // 等 1s
const b = await fetchB(); // 又等 1s
// 总共 2s并行(快):
js
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// 总共 1s(取较慢的一个)4.3 错误处理
js
async function f() {
try {
const a = await mayFail();
} catch (e) {
// 捕获 await 中的 reject
}
}5. 事件循环(Event Loop)⭐
这是面试必考,也是写 JS 的内功心法。
5.1 浏览器事件循环模型
text
┌─────────────────────────┐
│ Call Stack │ ← 同步代码
│ (调用栈) │
└─────────────────────────┘
│
▼ 同步代码执行完
┌─────────────────────────┐
│ Microtask Queue │ ← 微任务全清空
│ Promise.then │
│ queueMicrotask │
│ MutationObserver │
└─────────────────────────┘
│
▼ 微任务清空后
┌─────────────────────────┐
│ Render(浏览器渲染) │ ← 可能有
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Macrotask Queue │ ← 取一个宏任务
│ setTimeout │
│ setInterval │
│ I/O / UI 事件 │
│ MessageChannel │
└─────────────────────────┘
│
└─── 循环 ──→核心规则:
- 执行所有同步代码
- 清空所有微任务
- (可能)渲染
- 取一个宏任务执行
- 重复 2-4
5.2 宏任务 vs 微任务
| 类型 | 例子 |
|---|---|
| 宏任务(macrotask / task) | setTimeout、setInterval、setImmediate(Node)、UI 事件、I/O、MessageChannel |
| 微任务(microtask) | Promise.then/catch/finally、queueMicrotask、MutationObserver、process.nextTick(Node,更优先) |
5.3 经典面试题
js
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1 4 3 2逐步分析:
text
[同步] console.log('1') → 输出 1
[同步] setTimeout 注册宏任务 → 宏任务队列:[task1]
[同步] Promise.then 注册微任务 → 微任务队列:[micro1]
[同步] console.log('4') → 输出 4
─────── 同步代码完成 ───────
[微任务] 执行 micro1: console.log('3') → 输出 3
─────── 微任务清空 ───────
[宏任务] 执行 task1: console.log('2') → 输出 25.4 进阶面试题
js
async function async1() {
console.log('A1 start');
await async2();
console.log('A1 end');
}
async function async2() {
console.log('A2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
console.log('promise1');
resolve();
}).then(() => console.log('promise2'));
console.log('script end');
/*
script start
A1 start
A2
promise1
script end
A1 end ← await 后是微任务
promise2
setTimeout
*/💡
await x等价于Promise.resolve(x).then(...剩下代码),剩下代码进入微任务。
5.5 Node.js 事件循环(补充)
Node 的事件循环更复杂,分多个阶段:
text
┌───────────────────────────┐
│ timers (setTimeout) │
├───────────────────────────┤
│ pending callbacks │
├───────────────────────────┤
│ idle, prepare │
├───────────────────────────┤
│ poll (I/O) │
├───────────────────────────┤
│ check (setImmediate) │
├───────────────────────────┤
│ close callbacks │
└───────────────────────────┘每个阶段之间都会清微任务(Node 11+),process.nextTick 比 Promise 更优先。
6. 实战:常见手写题
6.1 手写 Promise.all
js
function myAll(promises) {
return new Promise((resolve, reject) => {
const results = [];
let count = 0;
promises.forEach((p, i) => {
Promise.resolve(p).then(
value => {
results[i] = value;
count++;
if (count === promises.length) resolve(results);
},
reject
);
});
if (promises.length === 0) resolve([]);
});
}6.2 限制并发请求
js
async function asyncPool(limit, tasks) {
const results = [];
const executing = [];
for (const task of tasks) {
const p = Promise.resolve().then(() => task());
results.push(p);
if (limit <= tasks.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) await Promise.race(executing);
}
}
return Promise.all(results);
}7. ⚠️ 易踩的坑
- 坑 1:忘记 await,函数原地返回 Promise。
const x = readFile()→x是 Promise,不是数据 - 坑 2:循环里 await 串行了。
for...of+ await 是串行的,需要并行用Promise.all - 坑 3:try/catch 抓不到 Promise reject(除非 await)
- 坑 4:Promise 状态一旦改变就不能再变。 多次 resolve 只生效第一次
- 坑 5:setTimeout(fn, 0) 不是 0ms! 浏览器最小约 4ms(嵌套超过 5 层时)
- 坑 6:await 后是微任务,不是同步。
await async2()等价于async2().then(剩下代码) - 坑 7:Promise.all 任意失败立即整体失败,残留请求不会取消。 用
AbortController配合 - 坑 8:在循环里用
.forEach + async,await 不会等待。要用for...of或map + Promise.all
8. 一句话总结
JS 是单线程的,事件循环 + 宏任务/微任务让它能"假装"并发;Promise 是用来"装异步结果的盒子",async/await 是它的同步皮肤;写代码时,"该并行就别串行"。
9. 延伸阅读
- 《JavaScript 高级程序设计(第 4 版)》第 11 章
- MDN:使用 Promise
- HTML 规范 - Event Loop:Event loop processing model
- Jake Archibald: In The Loop