【前端体系】从一道面试题谈谈对 Event Loop 的理解
前言
事件循环涉及 JavaScript 执行上下文、Promise jobs、浏览器任务、Node.js 的 libuv 阶段和宿主渲染。上来背一大堆名词很枯燥,因此本文保留原文的面试题路线:先看题,再用现代模型拆解。
原文发布于 Node.js 10/11、Chrome 73 前后,文中关于 await 的 tick 数量和 Node 版本输出属于历史实现观察。本文会保留这些历史背景,但以当前规范和运行时为准。原文配图来自 Juejin/ByteDance CDN,已下载到同目录 images 文件夹;图示中的旧版流程仅作历史参考。
一、从一道题引出 Event Loop
先看题目。下面代码可在现代浏览器控制台或 Node.js 中运行(Node.js 中 setTimeout 的延迟写成了 2000ms):
console.log('script start')
setTimeout(() => {
console.log('北歌')
}, 2000)
Promise.resolve()
.then(() => {
console.log('promise1')
})
.then(() => {
console.log('promise2')
})
async function foo() {
await bar()
console.log('async1 end')
}
foo()
async function errorFunc() {
try {
await Promise.reject('error!!!')
} catch (error) {
console.log(error)
}
console.log('async1')
return Promise.resolve('async1 success')
}
errorFunc().then(result => console.log(result))
function bar() {
console.log('async2 end')
}
console.log('script end')
当前主流浏览器和 Node.js 通常输出:
script start
async2 end
script end
promise1
async1 end
error!!!
async1
promise2
async1 success
北歌
输出顺序的关键不是“async 比 Promise 高”或“宏任务一定比微任务优先”,而是回调进入相应队列的时间不同:
foo()调用后,bar()的函数体同步执行,所以async2 end立即输出;await bar()的恢复代码异步执行,因此async1 end不会出现在同步日志中;errorFunc()先创建一个拒绝的 Promise,catch后的代码也要等 await 的恢复流程;- 第一个
.then执行后,第二个.then才会被安排,所以async1 end、error!!!、async1已经可能排在promise2前面; - 2 秒 timer 最后才输出
北歌。

二、JS 的运行机制:不要混淆抽象层次
1. Heap、Stack、Queue
原文先介绍了计算机数据结构中的堆、栈和队列,这部分可以保留:
- heap 数据结构通常是完全二叉树,用于优先队列;
- stack 遵循 LIFO;
- queue 通常遵循 FIFO。
但“JavaScript 的 heap”不是“二叉堆”;它是引擎管理对象和其他值的实现区域。ECMAScript 不规定 primitive 一定在栈、对象一定在堆,也不规定变量保存一个可观察的裸指针。

JavaScript 的调用栈用于执行上下文:
function first() {
second()
}
function second() {
console.log('second')
}
first()
函数调用会创建执行上下文并压入栈顶,返回后弹出。闭包、生成器和异步函数可能让某些绑定或执行状态在当前调用栈退出后仍然可访问,因此不能把“函数返回 = 所有局部对象立即释放”当作规则。

2. 为什么需要 Event Loop
一个 JavaScript agent 通常一次只执行一个 job,并且一个 job 运行至完成;如果网络、文件或定时器等待期间一直占用 JavaScript 调用栈,页面和服务就无法处理其他工作。宿主将异步操作交给系统、浏览器内部线程或 Node/libuv,完成后再把回调安排到未来的 task/job 中。
“JavaScript 单线程、宿主多线程”是方便理解的说法,但浏览器内部线程划分属于实现细节。Web Worker 和 Node worker_threads 还可以运行其他 JavaScript agent;它们不是让同一个调用栈同时执行两段代码。
3. 进程和线程
进程通常拥有独立的地址空间和资源,线程是进程内的执行单元,但“进程是 CPU 资源分配最小单位、线程是 CPU 调度最小单位”是操作系统教材中的常用概括,不适合作为所有系统的严格定义。本文只需要记住:一个进程可包含多个线程,一个 JavaScript agent 的代码执行仍按自己的事件循环串行推进。

三、浏览器事件循环的规范化模型
WHATWG HTML Standard 描述的浏览器事件循环可以概括为:
- 从一个 task queue 选择一个 task;
- 设置当前运行 task 并执行它,直到完成;
- 执行 microtask checkpoint,清空微任务队列;微任务中新加入的微任务也会继续执行;
- 浏览器在合适时机更新渲染;
- 继续下一个循环。
注意:
- HTML 允许多个 task queue,用户代理可以按调度策略选择;
- microtask queue 不是 task queue;
script整体通常是一个 task,不能把每条同步语句都称为一个宏任务;- “每个宏任务后必然立刻渲染一帧”不准确,渲染是宿主在合适时机进行的;
- Promise reaction 和
queueMicrotask回调会在 microtask checkpoint 中执行。

四、宏任务和微任务的优先关系
面试中可以使用下面的简化模型:
当前 task
└─ 同步代码运行至完成
└─ 清空 microtask queue
└─ 浏览器可能更新渲染
└─ 选择下一个 task
示例:
console.log('script start')
setTimeout(() => console.log('setTimeout'), 0)
Promise.resolve()
.then(() => console.log('promise1'))
.then(() => console.log('promise2'))
console.log('script end')
输出:
script start
script end
promise1
promise2
setTimeout
“宏任务优先”是误导性的总结。更准确的说法是:当前 task 必须先运行至完成;当前 task 结束后执行微任务检查点;微任务清空后才选择下一个 task。


五、重新分析第一道题
第一轮:脚本 task 中同步执行:
script start
async2 end
script end
在同步执行期间安排:
- 2 秒后的 timer task;
Promise.resolve().then的第一个 reaction;await bar()的异步恢复工作;await Promise.reject()的拒绝处理和后续恢复工作;errorFunc().then(...)在errorFunc返回 Promise 后等待其状态。
脚本 task 结束,开始清空 microtask:
promise1
async1 end
error!!!
async1
promise2
async1 success
第二个 .then 要等第一个 .then 结束后才能加入队列,因此它不一定紧跟第一个回调。最后 2 秒 timer task 输出:
北歌

六、黄金题
console.log('1')
setTimeout(() => {
console.log('2')
Promise.resolve().then(() => console.log('3'))
new Promise(resolve => {
console.log('4')
resolve()
}).then(() => console.log('5'))
}, 0)
Promise.reject().then(
() => console.log('13'),
() => console.log('12'),
)
new Promise(resolve => {
console.log('7')
resolve()
}).then(() => console.log('8'))
setTimeout(() => {
console.log('9')
Promise.resolve().then(() => console.log('10'))
new Promise(resolve => {
console.log('11')
resolve()
}).then(() => console.log('12'))
}, 0)
当前 Node.js 和浏览器通常输出:
1
7
12
8
2
4
3
5
9
11
10
12
第一次脚本 task 中同步打印 1、7;脚本结束后的 microtask 依次处理拒绝分支 12 和成功分支 8;再按 timer 注册顺序处理两个 timer。每个 timer 内部又是“同步日志 → 当前 timer 产生的 microtask”。
七、钻石题:Promise 链中的状态传递
new Promise(resolve => {
console.log(1)
resolve()
})
.then(() => {
console.log(2)
new Promise(resolve => {
console.log(3)
setTimeout(() => {
// 3 秒后才执行;但 Promise 早已 resolve
resolve()
}, 3000)
resolve()
})
.then(() => {
console.log(4)
new Promise(resolve => {
console.log(5)
resolve()
})
.then(() => console.log(7))
.then(() => console.log(9))
})
.then(() => console.log(8))
})
.then(() => console.log(6))
输出:
1
2
3
4
5
6
7
8
9
这里最容易错的是链的返回值:外层第一个 .then 的回调没有 return 内部 Promise,因此外层下一个 .then 中的 6 不会等待 4/5/7/9/8 完成。3 秒后的 resolve() 也没有作用,因为 Promise 在构造器中已经先变成 fulfilled。若需要表达真正的依赖关系,应显式 return:

return new Promise(resolve => {
// ...
})
一个 Promise 只能从 pending 转为 fulfilled 或 rejected 一次,后续再次调用 resolve/reject 不会改变它的状态;但回调抛错或返回拒绝 Promise,仍会影响下一个链式 Promise 的状态。

八、王者题:定时器与 finally
下面保留原文题目的核心结构,并把输出按当前语义重新标注:
Promise.resolve()
.then(() => {
console.log('promise1')
return new Promise(resolve => {
setTimeout(() => {
console.log('timer2')
resolve()
}, 0)
})
.then(async () => {
await foo()
return new Error('error1')
})
.then(result => {
setTimeout(() => {
console.log(result)
Promise.resolve()
.then(() => new Error('error!!!'))
.then(value => console.log('then:', value))
.catch(error => console.log('catch:', error))
}, 3000)
})
.finally(() => {
console.log(undefined)
throw new Error('error2')
})
.then(
value => console.log(value),
error => console.log(error.message),
)
})
.then(() => console.log('promise2'))
function foo() {
setTimeout(() => console.log('async1'), 2000)
}
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(() => console.log('promise3'))
}, 0)
console.log('start')
日志的相对顺序通常是:
start
promise1
timer1
promise3
timer2
undefined
error2
promise2
async1
Error: error1
then: Error: error!!!
这里的 Error 实际在控制台中可能带有堆栈信息,且 2 秒、3 秒 timer 的具体时间会受调度影响。finally 的回调不会接收前一个 Promise 的 fulfillment value;它的返回值会影响链,但正常返回时会透传原状态,抛出 error2 则让后续 Promise rejected。


九、荣耀王者题
async function async1() {
console.log('async1 start')
new Promise(resolve => {
try {
throw new Error('error1')
} catch (error) {
console.log(error.message)
}
setTimeout(() => resolve('promise4'), 3000)
})
.then(value => console.log(value))
.finally(value => console.log(value))
console.log(await async2())
console.log('async1 end')
}
function async2() {
console.log('async2')
return new Promise(resolve => {
setTimeout(() => resolve(2), 3000)
})
}
console.log('script start')
setTimeout(() => console.log('setTimeout'), 0)
async1()
new Promise(resolve => {
console.log('promise1')
resolve()
})
.then(() => {
console.log('promise2')
return new Promise(resolve => resolve())
.then(() => console.log('then 1-1'))
})
.then(() => console.log('promise3'))
console.log('script end')
通常的日志顺序为:
script start
async1 start
error1
async2
promise1
script end
promise2
then 1-1
promise3
setTimeout
promise4
undefined
2
async1 end
需要注意两个 3 秒 timer 的注册顺序、await 恢复以及 Promise 链中每个回调的返回值。不同平台对同一阈值 timer 的调度可能存在细微差别,但不应据此推导出“await 把代码同步化”。await 会暂停当前 async 函数并异步恢复,调用者不会同步拿到最终结果。

十、Node.js 与浏览器的差异
浏览器
- 由 HTML Standard 定义 task queue、microtask checkpoint 和渲染机会;
Promisereaction、queueMicrotask和MutationObserver属于 microtask 相关机制;- 没有 Node 的
process.nextTick和 libuv 六阶段; - 长 task 和无界 microtask 都可能阻塞交互。
Node.js
Node.js 使用 libuv 阶段:timers、pending callbacks、idle/prepare、poll、check、close callbacks。现代 Node 还需考虑 Node 20/libuv 1.45.0 对 timers 与 poll 顺序的变化。
Node 的 Promise microtask 和 process.nextTick 也不能混为一谈:nextTick 队列具有更高的 Node 特有优先级,递归调用会阻塞 I/O。Node 11 以后每个回调后的 microtask 处理更接近浏览器;如果文章或面试题标注 Node 10、Node 11,应把它作为历史背景,不要直接套到 Node 20+。
十一、最终的分析方法
- 先写明浏览器还是 Node.js,以及大致版本;
- 把脚本整体视作当前 task,先执行所有同步代码;
- 记录每个 Promise reaction、
await恢复、timer、I/O 和 nextTick 的注册时间; - 当前 task 或 callback 结束后,先清空对应的 microtask/nextTick 队列;
- 处理链式 Promise 时,确认回调是否
return了下一个 Promise; - 记住
finally默认透传状态,但抛错或返回拒绝会改变链; - timer 的 delay 是最小阈值,不能当作精确时钟;
- 对复杂输出,在目标运行时用最小脚本验证,并把观察结果和规范保证分开。