前阵子面试被问到:async/await 被编译成什么样了?
我答不上来。面试官说:你用了这么久 async/await,连它怎么实现的都不知道?
回来研究了 V8 源码和 ECMAScript 规范,才发现异步编程的水比想象中深得多。
一、async/await 不是语法糖
很多人说 async/await 是 Promise 的语法糖,严格来说不对。
它更接近 Generator + Promise 的自动执行器。V8 引擎会把 async 函数编译成状态机。
看这段代码:
javascript
async function foo() {
console.log(1);
await bar();
console.log(2);
}
V8 编译后大致等价于:
javascript
function foo() {
return new Promise(resolve => {
const stateMachine = {
state: 0,
next(value) {
switch (this.state) {
case 0:
console.log(1);
this.state = 1;
return Promise.resolve(bar()).then(v => this.next(v));
case 1:
console.log(2);
resolve();
return;
}
}
};
stateMachine.next();
});
}
每个 await 把函数分成不同的状态,执行完一个 await 就切换到下一个状态。
这就是为什么 await 后面的代码会被放进微任务队列------因为它实际上是 .then() 的回调。
面试追问:为什么 async/await 比 Promise.then 性能好?
因为 V8 对 async/await 做了优化,减少了 Promise 对象的创建。手写 .then().then().then() 会创建多个 Promise 实例,而 async/await 内部可能只创建一个。
二、微任务队列的真实实现
网上都说"微任务队列",但实际上不止一个队列。
根据 HTML 规范,浏览器有:
-
微任务队列(Microtask Queue)
- Promise.then/catch/finally
- MutationObserver
- queueMicrotask
-
Job Queue(ECMAScript 层面)
- Promise Jobs
- 这是 ES 规范定义的,比 HTML 规范更底层
Node.js 更复杂:
javascript
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timeout'), 0);
Node.js 输出:nextTick → promise → timeout → immediate
Node.js 有多个队列:
- nextTick Queue(优先级最高)
- Promise Queue
- Timer Queue(setTimeout/setInterval)
- Check Queue(setImmediate)
- Poll Queue(I/O)
- Close Queue
这是一个很多人不知道的点:Node.js 和浏览器的事件循环实现完全不同。
浏览器:HTML 规范定义,一个微任务队列 + 一个宏任务队列
Node.js:libuv 实现,多个阶段,每个阶段有自己的队列
三、MutationObserver 为什么是微任务?
MutationObserver 用来监听 DOM 变化:
javascript
const observer = new MutationObserver(() => {
console.log('DOM changed');
});
observer.observe(document.body, { childList: true });
document.body.appendChild(document.createElement('div'));
console.log('sync');
输出:sync → DOM changed
DOM 变化后,回调不是立即执行,而是放进微任务队列。
为什么这样设计?
假设一个循环里改了 100 次 DOM:
javascript
for (let i = 0; i < 100; i++) {
document.body.appendChild(document.createElement('div'));
}
如果每次 DOM 变化都触发回调,会执行 100 次。但如果放进微任务队列,100 次修改完成后只执行一次回调(批量处理)。
这是性能优化的经典设计。
四、Promise 的 then 为什么返回新 Promise?
看这道题:
javascript
const p = Promise.resolve(1);
const p2 = p.then(val => val + 1);
console.log(p === p2); // false
then 返回的是新 Promise,不是原来的。
为什么?
为了链式调用。如果返回同一个 Promise,链就会断掉:
javascript
Promise.resolve(1)
.then(val => val + 1) // 返回新 Promise,resolve(2)
.then(val => val + 2) // 拿到上一个 then 返回的 Promise
.then(console.log); // 4
每个 then 都返回新 Promise,形成一条链。
深层问题:then 返回的 Promise 什么时候 settle?
javascript
const p = new Promise(resolve => {
setTimeout(() => resolve('done'), 1000);
});
const p2 = p.then(val => val + '!');
p2 不是立即 settle 的,而是等 p resolve 后,then 的回调执行完,p2 才 resolve。
这涉及 Promise Resolution Procedure(Promise 解决过程),是 ES 规范里最复杂的部分之一。
五、手写 Promise 的核心难点
网上手写 Promise 的文章很多,但大部分都漏了关键点。
1. then 的回调可以返回 Promise
javascript
Promise.resolve(1)
.then(val => Promise.resolve(val + 1))
.then(console.log); // 2
then 的回调如果返回 Promise,要等这个 Promise settle 后,外层 then 返回的 Promise 才 settle。
javascript
then(onFulfilled) {
return new Promise((resolve, reject) => {
const result = onFulfilled(this.value);
// 关键:如果 result 是 Promise,要等它
if (result instanceof Promise) {
result.then(resolve, reject);
} else {
resolve(result);
}
});
}
2. then 可以被调用多次
javascript
const p = Promise.resolve(1);
p.then(console.log); // 1
p.then(console.log); // 1
p.then(console.log); // 1
每个 then 都要执行,所以要维护一个回调数组:
javascript
class MyPromise {
constructor(executor) {
this.callbacks = [];
const resolve = value => {
this.value = value;
this.callbacks.forEach(cb => cb(value));
};
executor(resolve);
}
then(onFulfilled) {
this.callbacks.push(onFulfilled);
}
}
3. 错误穿透
javascript
Promise.reject('error')
.then(val => val + 1)
.then(val => val + 2)
.catch(err => console.log(err)); // error
错误会沿着链传递,直到遇到 catch。
javascript
then(onFulfilled, onRejected) {
return new Promise((resolve, reject) => {
const handle = () => {
if (this.state === 'fulfilled') {
try {
const result = onFulfilled(this.value);
resolve(result);
} catch (err) {
reject(err);
}
} else if (this.state === 'rejected') {
if (onRejected) {
try {
const result = onRejected(this.reason);
resolve(result);
} catch (err) {
reject(err);
}
} else {
// 错误穿透:没有 onRejected 就继续传递
reject(this.reason);
}
}
};
if (this.state) {
// 已 settle,异步执行
queueMicrotask(handle);
} else {
// pending,加入队列
this.callbacks.push(handle);
}
});
}
六、性能优化:避免 Promise 地狱
问题:Promise 创建是有开销的
javascript
// 不好:创建大量不必要的 Promise
async function processItems(items) {
const results = [];
for (const item of items) {
const result = await Promise.resolve(item).then(x => x * 2);
results.push(result);
}
return results;
}
// 好:直接处理
async function processItems(items) {
return items.map(item => item * 2);
}
问题:微任务队列堆积
javascript
// 这段代码会导致微任务队列堆积,阻塞渲染
async function bad() {
while (true) {
await Promise.resolve();
// 这个循环会永远执行,UI 会卡死
}
}
微任务不会让出执行权给渲染,所以长时间运行的微任务会让页面卡顿。
解决方案:偶尔让出控制权
javascript
async function good() {
while (true) {
await new Promise(resolve => setTimeout(resolve, 0));
// 让出控制权,让浏览器有机会渲染
}
}
setTimeout(0) 会创建宏任务,每次宏任务之间浏览器有机会渲染。
七、冷门但重要的知识点
1. Promise 的构造函数是同步执行的
javascript
const p = new Promise(resolve => {
console.log('executor');
resolve(1);
});
console.log('after new');
// 输出:executor → after new
Promise 构造函数里的代码是同步执行的,只有 then 回调是异步的。
2. unhandledrejection 事件
javascript
Promise.reject('error');
window.addEventListener('unhandledrejection', event => {
console.log('未处理的 rejection:', event.reason);
});
Promise 被 reject 但没有 catch,会触发这个事件。
Node.js 类似:
javascript
process.on('unhandledRejection', (reason, promise) => {
console.log('未处理的 rejection:', reason);
});
3. Promise.finally 的特殊行为
javascript
Promise.resolve(1)
.finally(() => {
console.log('finally');
return 2; // 返回值被忽略
})
.then(console.log); // 1,不是 2
finally 不改变传递的值,只执行副作用。
但如果 finally 返回 rejected Promise:
javascript
Promise.resolve(1)
.finally(() => {
return Promise.reject('error');
})
.then(
val => console.log(val),
err => console.log(err) // error
);
4. async 函数的隐式 try-catch
javascript
async function foo() {
throw new Error('fail');
}
foo();
// 错误被包装成 rejected Promise,不会抛到全局
等价于:
javascript
function foo() {
return new Promise((resolve, reject) => {
try {
throw new Error('fail');
} catch (err) {
reject(err);
}
});
}
八、调试异步代码的技巧
1. Chrome DevTools 的 Async Stack Trace
勾选 Console 的 "Async" 选项,可以看到异步调用栈:
javascript
async function a() {
await b();
}
async function b() {
await c();
}
async function c() {
console.log('here');
throw new Error('fail');
}
a();
不开启 Async Stack Trace,调用栈只有 c。
开启后,可以看到 a → b → c 的完整调用链。
2. Node.js 的 --async-stack-traces
bash
node --async-stack-traces app.js
Node.js 12+ 支持,让异步错误堆栈更清晰。
总结
异步编程的难点不在 API,而在于:
- 理解底层机制 --- V8 如何编译 async/await,事件循环如何调度
- 知道边界情况 --- Node.js 和浏览器的差异,微任务堆积问题
- 能写出正确实现 --- Promise 的 resolve procedure,then 的链式调用
面试时,面试官问你"async/await 怎么实现的",不是让你背答案,而是看你是否真的理解原理。
参考资料: