JavaScript 是单线程的语言,却能够高效地处理网络请求、用户交互、定时器等"耗时"操作,这背后全靠一套精妙的异步编程模型。很多人会用 Promise 和 async/await,但未必清楚事件循环里微任务和宏任务如何调度,更不知道 async 函数不过是 Generator 的语法糖。今天我们就来深潜异步编程,从底层机制到高层抽象,彻底搞懂它。
一、单线程的困境与回调
JavaScript 设计之初是为了操作 DOM、响应用户事件,如果采用多线程同时修改 DOM 会带来复杂的同步问题。因此它选择单线程 + 异步非阻塞模型。
javascript
// 同步阻塞(假设 sleep 是阻塞函数)
console.log('开始');
sleep(3000); // 假想:线程卡住3秒
console.log('结束'); // 3秒后才输出
如果所有操作都同步执行,网络请求或定时器就会让页面"卡死"。解决方案是回调:把后续逻辑封装成函数,等异步操作完成后调用。
javascript
console.log('开始');
setTimeout(() => {
console.log('定时器完成');
}, 3000);
console.log('结束');
// 输出:开始 → 结束 → (3秒后) 定时器完成
回调解决了阻塞问题,但带来了"回调地狱":多层嵌套、错误处理混乱、难以追踪。于是 Promise 应运而生。
二、事件循环:微任务与宏任务
要理解 Promise 和 async/await,必须先掌握 JavaScript 的运行时模型------事件循环(Event Loop)。
核心组件
- 调用栈:执行同步代码的栈结构,函数调用时入栈,返回时出栈。
- 宏任务队列 :存放宏任务(macro-task),如
setTimeout、setInterval、I/O、UI 渲染。 - 微任务队列 :存放微任务(micro-task),如
Promise.then、MutationObserver、queueMicrotask。
循环规则
- 执行一个宏任务(从队列头部取出)。
- 执行过程中产生的微任务,全部依次执行(清空微任务队列)。
- 必要时进行 UI 渲染(浏览器约 16.6ms 一次)。
- 回到第 1 步,取出下一个宏任务。
关键点 :微任务会在当前宏任务结束、下一个宏任务开始之前全部执行。
javascript
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => console.log('3'));
}, 0);
Promise.resolve().then(() => {
console.log('4');
setTimeout(() => console.log('5'), 0);
});
console.log('6');
// 输出顺序:1, 6, 4, 2, 3, 5
分析:
- 同步代码:1、6 直接输出。
- 微任务队列:Promise.then 输出 4,内部又添加了宏任务 5。
- 当前宏任务结束,清空微任务 → 输出 4 结束。
- 下一个宏任务:setTimeout 回调输出 2,然后它的微任务输出 3。
- 再下一个宏任务:输出 5。
理解这个顺序是调试异步 bug 的基础。
三、Promise:从零实现一个简版
Promise 是一个状态机,有三种状态:pending、fulfilled、rejected。状态一旦改变就不可逆。我们手写一个简化版,就能看清 then 的回调是如何注册和调用的。
javascript
class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.callbacks = []; // 存储 then 注册的回调
const resolve = (value) => {
if (this.state !== 'pending') return;
this.state = 'fulfilled';
this.value = value;
this.callbacks.forEach(cb => this._handle(cb));
};
const reject = (reason) => { /* 类似实现 */ };
try {
executor(resolve, reject);
} catch (e) {
reject(e);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
this.callbacks.push({
onFulfilled: () => {
try {
const result = onFulfilled(this.value);
resolve(result);
} catch (e) {
reject(e);
}
},
onRejected: () => { /* 类似处理 */ }
});
});
}
_handle(callback) {
if (this.state === 'fulfilled') {
callback.onFulfilled();
} else if (this.state === 'pending') {
this.callbacks.push(callback);
}
}
}
真正的 Promise 规范还涉及值的穿透、多次 then 链式调用、以及微任务调度(上面简化版是同步调用回调,实际需要用 queueMicrotask 或 setTimeout 包装)。但核心思想不变:then 只是注册回调,resolve/reject 触发执行。
四、async/await:Generator 的语法糖
很多人觉得 async 函数很神奇:函数内部可以写同步风格的代码,却能等待异步结果。实际上,它的底层是 Generator + 自动执行器。
Generator 基础
Generator 函数可以暂停和恢复执行:
javascript
function* gen() {
const a = yield 1;
console.log(a);
const b = yield 2;
return b;
}
const it = gen();
console.log(it.next()); // { value: 1, done: false }
console.log(it.next('A')); // 输出 'A', { value: 2, done: false }
console.log(it.next('B')); // { value: 'B', done: true }
yield 可以返回一个值,并等待 next 传参进来恢复执行。这个特性恰好可以用来模拟"等待异步结果"。
模拟 async/await
假如我们想让 generator 能自动执行,并支持 Promise:
javascript
function run(generatorFunc) {
const it = generatorFunc();
function step(prevResult) {
const { value, done } = it.next(prevResult);
if (done) return Promise.resolve(value);
// 如果 value 是 Promise,等它完成后再继续
return Promise.resolve(value).then(step);
}
return step();
}
// 使用
run(function* () {
const data1 = yield fetch('/api/1').then(r => r.json());
const data2 = yield fetch('/api/2').then(r => r.json());
console.log(data1, data2);
});
这个 run 函数就是一个自动执行器 。而 async/await 正是这种模式的语法糖:async 函数返回 Promise,await 后面跟 Promise,引擎自动在 then 中恢复执行。
javascript
// 用 async/await 等价写法
async function fetchData() {
const data1 = await fetch('/api/1').then(r => r.json());
const data2 = await fetch('/api/2').then(r => r.json());
console.log(data1, data2);
}
所以 await 的本质就是把后续代码包装成一个微任务回调,挂载到 Promise 上。
五、常见陷阱与最佳实践
1. 忘记 return 导致并行变串行
javascript
// 错误:顺序执行,总耗时 = 请求1 + 请求2
async function bad() {
const a = await fetch('/api/a');
const b = await fetch('/api/b'); // 等 a 完成才发 b
}
// 正确:并发执行
async function good() {
const p1 = fetch('/api/a');
const p2 = fetch('/api/b');
const [a, b] = await Promise.all([p1, p2]);
}
2. 不要在循环内使用 await(除非故意串行)
需要并发时用 Promise.all 或 Promise.allSettled。
3. 未捕获的 Promise 拒绝
javascript
// 危险:错误被吞掉
async function danger() {
throw new Error('Oops');
}
danger(); // 无报错,但产生一个未处理的拒绝
// 正确:要么 await,要么 .catch
await danger();
// 或 danger().catch(console.error);
现代 Node.js 和浏览器会在未捕获的 Promise 拒绝时打印警告,但最好显式处理。
4. 微任务过多导致 UI 阻塞
javascript
// 递归 Promise.then 会一直占用微任务队列,导致无法执行宏任务(包括 UI 渲染)
function loop() {
Promise.resolve().then(loop);
}
loop();
解决方案:使用 setTimeout 或 queueMicrotask 控制。
六、总结
异步编程深潜,我们看到:
- 事件循环是调度核心,宏任务和微任务的执行顺序决定了代码输出。
- Promise 本质是状态机,then 注册回调,resolve 触发微任务执行。
- async/await 是 Generator 的语法糖,让异步代码像同步一样书写。
- 性能与正确性需要理解并发、错误处理和微任务队列。
掌握这些底层机制,你不仅能写出更可靠的异步代码,还能轻松调试那些"为什么先输出 2 后输出 3"的谜题。下次面试官问"事件循环",你可以自信地从宏任务、微任务讲到 async/await 的实现原理。
异步编程,不再黑盒。
