15. 说说你对事件循环Event Loop的理解?
区分宿主异步、任务和微任务检查点,通过流程图与嵌套 Promise 真题推导浏览器执行顺序。
问题解析
同一 JavaScript agent 中的代码通常逐段运行到结束,事件循环(Event Loop)由宿主协调后续任务、微任务与渲染。分析顺序时,要分清“启动异步操作”“注册回调”和“回调开始执行”。
核心概念
为什么需要事件循环
浏览器/Node 可以由其他线程或操作系统处理 I/O,同一 agent 的 JS 回调仍按调用栈逐段执行;Worker 可以提供另一个执行环境。异步等待不会阻塞主线程,但把昂贵计算放进 Promise executor 或 async 函数的同步部分,仍会阻塞当前线程。
把异步相关概念放回各自层次
| 层次 | 示例 | 负责什么 |
|---|---|---|
| 运行资源 | 进程、线程、Worker | 承载执行与隔离;不等同于任务队列 |
| 宿主调度 | 浏览器事件循环、Node/libuv | 决定已就绪回调与检查点何时运行 |
| 启动操作的 API | setTimeout、XHR/fetch、fs.readFile |
开始计时、网络或文件 I/O,完成后通知 |
| 结果与组合 | Promise | 表达最终结果与依赖;不自动开启新线程 |
| 可暂停控制流 | Generator、yield、next/throw |
保存和恢复函数执行;不自动等待 I/O |
| 异步语法 | async/await |
用 Promise 结果恢复后续代码 |
setTimeout(fn, 2000) 不是“精确两秒后执行”,而是计时达到条件后才有资格被调度;长任务、后台节流等都能进一步延迟。queueMicrotask 也可用于 Node,process.nextTick 则是 Node 的独立队列,不能与浏览器 API 混成统一优先级。
任务分类
在JavaScript中,所有的任务都可以分为:
- 同步任务:立即执行的任务,一般会直接进入到主线程中执行
- 异步任务:异步执行的任务,比如ajax网络请求、setTimeout定时函数等
详细解答
事件循环的基本流程
图中渲染步骤是有条件的。浏览器可以有多个任务队列,不存在把所有异步 API 都塞进一个 FIFO 的通用模型;同一任务源的顺序与不同任务源之间的选择也要分开讨论。
执行机制
- 选择一个可运行任务,执行其中同步代码;异步 API 可能启动宿主操作或登记回调。
- 到达微任务检查点,按队列顺序执行微任务;执行期间新增的微任务追加到队尾,继续清空。
- 如果存在渲染机会,进入渲染更新;
requestAnimationFrame回调属于这里,不是独立的“微任务之后必跑”队列。 - 继续选择任务;无可运行工作时可等待,不是在 JS 中用
while (true)忙轮询。
这是面试时常用的浏览器主线,检查点还可能出现在脚本/回调清理等规范位置,不能概括为只在每轮末尾发生一次。requestAnimationFrame 与计时器的相对顺序取决于渲染机会,隐藏页面通常还会暂停 rAF。Node 的 phase 与 nextTick 顺序另见 Node 事件循环。
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
Promise.resolve().then(() => {
console.log(3);
});
console.log(4);
// 输出: 1 -> 4 -> 3 -> 2
深入理解
宏任务(MacroTask)
“宏任务”是常见教学用语,浏览器规范通常称 task;它的区别在于调度阶段,而非函数耗时一定更长。
常见的宏任务:
- script(外层同步代码)
- setTimeout/setInterval
- UI 事件派发(渲染更新不是普通 task 队列的一项)
- postMessage、MessageChannel
- setImmediate、I/O(Node.js)
微任务(MicroTask)
微任务在宿主执行微任务检查点时运行,不会抢占当前 JS 调用栈。Promise reaction 的入队还依赖被观察的 Promise 是否已经完成,详见 reaction 入队时机。
常见的微任务:
- Promise.then/catch/finally
- MutationObserver
- queueMicrotask
process.nextTick 是 Node 专有调度队列,需按 Node 运行上下文分析,不列为浏览器微任务。
执行顺序示例
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
执行过程分析:
- console.log(1) - 同步,输出1
- setTimeout - 宏任务,放入宏任务队列
- Promise.then - 微任务,放入微任务队列
- console.log(6) - 同步,输出6
- 同步代码执行完毕,执行微任务,输出4,同时新的setTimeout放入宏任务队列
- 执行宏任务(按放入顺序),先执行第一个setTimeout,输出2,遇到Promise.then放入微任务
- 执行微任务,输出3
- 执行下一个宏任务,输出5
复杂场景分析
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
输出顺序:script start -> async1 start -> async2 -> promise1 -> script end -> async1 end -> promise2 -> setTimeout
返回 Promise 时,别漏掉接管过程
下面保留资料中的完整题目。按浏览器普通脚本运行,先预测日志,再用控制台验证;两个 return 会让外层链等待返回的 Promise,不能只按代码行序把所有 then 入队。
console.log('script start');
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);
Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(() => console.log('promise3'));
}, 0);
return new Promise(resolve => {
console.log('promise4');
resolve();
}).then(() => {
console.log('promise5');
queueMicrotask(() => console.log('microtask1'));
});
}).then(() => console.log('promise6'));
new Promise(resolve => {
console.log('promise8');
resolve();
}).then(() => {
console.log('promise9');
return Promise.resolve().then(() => {
console.log('promise10');
setTimeout(() => console.log('timeout3'), 0);
});
}).then(() => console.log('promise11'));
日志顺序:
script start → promise8
→ promise2 → promise4 → promise9 → promise5 → promise10
→ microtask1 → promise6 → promise11
→ timeout1 → promise1 → timeout2 → promise3 → timeout3
| 阶段 | 推导关键点 |
|---|---|
| 同步段 | executor 同步运行,先输出 script start、promise8;最先就绪的 reaction 是 promise2 与 promise9 |
| 第一条链 | promise2 内同步输出 promise4,创建 promise5 的 reaction,外层开始接管它返回的 Promise |
| 第二条链 | promise9 创建 promise10 的 reaction,第二条外层链同样需要等待返回值 |
| 清空微任务 | promise5 安排 microtask1,promise10 安排 timeout3;接管 job 不打印日志,但决定 promise6/11 的就绪时机 |
| 定时器段 | 在本题同窗口、同类零延迟计时器的条件下按登记先后执行;timeout1 与 timeout2 各自产生的微任务在下一个计时器回调前完成 |
手推时同时记“打印日志”和“新任务入队/Promise 决议”两张清单。无日志的内部 job 也会影响先后;遇到子类、覆写 then 或混入 Node nextTick,应重新按具体运行环境推导。
零毫秒与一毫秒:先写能保证的顺序
下面是另一类常见真题。日志中的 stack、macro、micro 只是字符串:例如 macro [8] 实际在同步脚本里输出,stack [7] 实际在 Promise reaction 中输出。判断调度阶段要看注册位置,不能靠名字。
console.log('stack [1]');
const p = Promise.resolve();
setTimeout(() => console.log('macro [2]'), 0);
setTimeout(() => console.log('macro [3]'), 1);
for (let i = 0; i < 3; i++) p.then(() => {
setTimeout(() => {
console.log('stack [4]');
setTimeout(() => console.log('macro [5]'), 0);
p.then(() => console.log('micro [6]'));
}, 0);
console.log('stack [7]');
});
console.log('macro [8]');
限定为同一浏览器窗口的普通脚本、原生且未被改写的 Promise/计时器,并让全部回调执行完毕,可以先写出以下偏序:
- 前缀固定为
stack [1] → macro [8] → stack [7] × 3,然后才进入计时器回调。 - 首次登记的零延迟
macro [2]在本题其他计时器日志之前。 - 三个
stack [4]按登记顺序执行;每次输出后,自己的micro [6]都在下一计时器回调之前完成。 - 三个
macro [5]在三个stack [4]回调及其微任务之后,按登记顺序执行。 macro [3]在macro [2]之后,但仅凭1与0不能把它固定放在所有stack [4]之前或之后。
只去掉 macro [3] 时,剩余日志的顺序如下:
stack [1] → macro [8] → stack [7] × 3 → macro [2]
→ stack [4] → micro [6]
→ stack [4] → micro [6]
→ stack [4] → micro [6]
→ macro [5] × 3
同窗口计时器对更早登记且等待时间不更长的计时器有顺序约束,因此这里可以推导零延迟链的先后。较早登记的一毫秒计时器与较晚登记的零延迟计时器不满足这一比较条件;登记时刻、计时粒度与宿主调度都会影响它们的相对顺序。不要把某次控制台输出升级为跨浏览器保证,也不要把所有计时器都概括成“按 delay 排序”。更深层嵌套还可能触发最小延迟限制,Node 的计时规则也应单独分析。
加强版:计时器内再登记微任务与计时器
把第一个计时器替换为下方逻辑,同时把最后的同步日志改为 stack [8],得到完整加强版:
console.log('stack [1]');
const p = Promise.resolve();
setTimeout(() => {
console.log('stack [2]');
setTimeout(() => {
for (let i = 0; i < 3; i++) p.then(() => console.log('micro [2-1]'));
}, 0);
p.then(() => console.log('micro [2-2]'));
}, 0);
setTimeout(() => console.log('macro [3]'), 1);
for (let i = 0; i < 3; i++) p.then(() => {
setTimeout(() => {
console.log('stack [4]');
setTimeout(() => console.log('macro [5]'), 0);
p.then(() => console.log('micro [6]'));
}, 0);
console.log('stack [7]');
});
console.log('stack [8]');
沿用上面的浏览器前提,去掉位置未固定的 macro [3] 后,剩余顺序为:
stack [1] → stack [8] → stack [7] × 3
→ stack [2] → micro [2-2]
→ stack [4] → micro [6]
→ stack [4] → micro [6]
→ stack [4] → micro [6]
→ micro [2-1] × 3 → macro [5] × 3
micro [2-2] 在首个计时器的检查点执行;包含三个 micro [2-1] 的计时器,登记晚于三个 stack [4] 计时器、早于它们内部登记的 macro [5]。macro [3] 不会插入同一次微任务检查点中间,它也一定晚于 stack [2] 及随后的 micro [2-2];除此之外,不应抄成固定的最后一行。测试应核对这些先后约束、次数及相邻微任务组,而非强行匹配一个完整输出数组。
最佳实践
- 避免长时间运行的同步任务:会阻塞事件循环
- 合理使用微任务:微任务过多会阻塞渲染
- 分解大任务:使用setTimeout将大任务分解
// 优化前:长时间运行的任务阻塞UI
function processLargeArray(data) {
for (let i = 0; i < data.length; i++) {
processItem(data[i]); // 处理大量数据,阻塞UI
}
}
// 优化后:使用setTimeout分解任务
function processLargeArrayAsync(data, chunkSize = 100) {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, data.length);
for (let i = index; i < end; i++) {
processItem(data[i]);
}
index = end;
if (index < data.length) {
setTimeout(processChunk, 0); // 让出主线程
}
}
processChunk();
}
面试要点
- 单线程与异步:理解JavaScript为什么是单线程以及如何实现异步(深入阅读:事件循环的基本流程)
- 宏任务与微任务:能够区分常见的宏任务和微任务(深入阅读:微任务(MicroTask))
- 执行顺序:掌握同步代码、微任务、宏任务的执行优先级(深入阅读:执行顺序示例)
- async/await与事件循环:理解await会阻塞async函数内部代码,将其加入微任务队列(深入阅读:事件循环的基本流程)
- 实际应用:能够分析复杂的代码执行顺序(深入阅读:执行顺序示例)
常见考点
- Promise.then是微任务,setTimeout是宏任务(深入阅读:详细解答)
- await后面的代码会进入微任务队列
- 微任务在当前宏任务结束后立即执行
- 宏任务之间会检查并执行微任务队列
来源与取舍
JS异步编程.pdf 第 1–3 页提供概念分组与事件循环题,第 5、7 页提供基础顺序例子,第 11–12 页提供嵌套 Promise 真题。基础例子复用已有章节,补完整题目的推导;第 3 页原图是未生成的占位框,改为本项目流程图,修正 rAF 固定优先级与浏览器/Node 混用表述。