JavaScript 事件循环是什么?宏任务和微任务有什么区别?
简化版
事件循环负责协调同步代码、异步任务和渲染。同步代码先执行,之后清空微任务队列,再进入下一个宏任务。常见宏任务有 setTimeout、事件回调,常见微任务有 Promise.then、queueMicrotask、MutationObserver。
详细版
执行顺序通常是:
- 执行当前调用栈里的同步代码。
- 当前宏任务结束后,清空所有微任务。
- 浏览器有机会进行渲染。
- 取下一个宏任务继续执行。
示例:
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
console.log(4);
输出是 1 4 3 2。原因是同步代码先执行,Promise 回调进入微任务,定时器回调进入宏任务。当前同步执行完后先跑微任务,再跑下一个宏任务。
完整版教学
一、为什么需要事件循环
JavaScript 主线程一次只能执行一段代码。如果遇到定时器、网络请求、用户事件,不可能一直阻塞等待。浏览器会把异步完成后的回调放入任务队列,主线程空闲后再取出来执行。
事件循环就是由宿主协调任务、微任务与渲染机会的处理模型。网络与定时器等能力由浏览器环境提供,完成条件满足后只会排入后续工作,并不会在当前 JavaScript 执行栈中并发插入回调。
二、宏任务和微任务的差别
宏任务可以理解为一次较大的事件循环单元,比如脚本执行、定时器回调、用户事件回调。
微任务是在合适的微任务检查点执行的工作,常见检查点出现在脚本或回调清理之后。重要特点是:检查点会持续处理队列直至为空,新加入的微任务也会继续执行。如果微任务里不断产生新微任务,可能阻塞渲染和后续任务,这种高优先级并不意味着适合承载无限工作。
三、Promise 为什么比 setTimeout 先执行
setTimeout 即使延迟是 0,也只是把回调放到宏任务队列,不能打断当前脚本。Promise.then 放到微任务队列,当前宏任务结束后马上执行。
所以:
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
一定先输出 promise,再输出 timeout。
四、浏览器渲染与事件循环
浏览器通常在宏任务之间寻找机会渲染。如果某个宏任务执行太久,或者微任务链太长,页面就会卡顿,用户事件也迟迟无法响应。
这就是为什么复杂计算要切片、异步调度,或者交给 Web Worker。渲染不是每完成一个任务都必然发生,用户代理还要结合刷新率、页面可见性和是否确有视觉更新来选择渲染机会。
五、面试追问与工程落地
事件循环题常会给混合代码考输出。解题顺序是先画三块:同步调用栈、微任务队列、宏任务队列。遇到 async/await 时,await 前面的代码同步执行,await 后面的代码可以理解为放进 Promise 微任务继续执行。
还要区分浏览器和 Node。浏览器里要关心渲染时机、用户事件、定时器、Promise;Node 里还有 process.nextTick、I/O callbacks、check 阶段、setImmediate 等。普通前端面试先答浏览器模型,如果对方追问 Node,再展开差异。
工程意义是避免长任务。一个超过 50ms 的同步任务就可能让页面输入、滚动、点击变卡。复杂计算可以拆片、用 requestIdleCallback、Web Worker,或者把渲染压力交给虚拟列表和分页。
六、按任务、微任务检查点和渲染机会推演
运行下面代码时,脚本任务先输出 A、D;脚本清理阶段执行微任务检查点,先输出 B,该微任务又排入一个微任务,所以同一检查点继续输出 C;定时器任务最早之后才输出 E。最终顺序是 A D B C E。
console.log('A');
queueMicrotask(() => {
console.log('B');
queueMicrotask(() => console.log('C'));
});
setTimeout(() => console.log('E'), 0);
console.log('D');
脚本任务[A,D] → 微任务检查点[B,C] → 渲染机会 → 定时器任务[E]
| 来源 | 进入哪里 | 关键边界 |
|---|---|---|
| 顶层脚本、用户事件 | 某个 task source 的任务 | HTML 中并非只有一条统一宏任务队列 |
Promise reaction、queueMicrotask | 微任务队列 | 检查点会持续取到队列为空 |
setTimeout | 定时器任务源 | delay 是下限,不保证准点 |
requestAnimationFrame | 渲染更新步骤中的回调 | 与下次渲染机会相关,不是普通定时器 |
| Worker 消息 | 对应事件循环的任务 | Worker 有自己的事件循环模型 |
HTML 标准使用 task 与 task source,而“宏任务”是教学中的常用概括。用户代理可以在不同任务源间选择可运行任务,但同一任务源要保持规定的相对顺序;因此不能把所有异步回调想成一条绝对 FIFO 大队列。
微任务会一直执行到队列为空,新排入的微任务也在同一检查点继续执行;递归制造微任务可能长期推迟其他任务和渲染。
七、常见误区与追问
- 误区:每执行一个微任务后浏览器都会渲染一次。 微任务检查点通常会先把队列清空,渲染还取决于窗口是否获得渲染机会。
- 误区:
setTimeout(fn, 0)会在 0 毫秒后立即执行。 它只在满足最小等待并被事件循环选择后执行,嵌套定时器还可能被钳制,主线程阻塞会继续推迟。 - 误区:浏览器只有一条严格 FIFO 的宏任务队列。 标准定义多个 task source,用户代理可在保持各源顺序约束的前提下选择任务。
- 追问:微任务中继续加入微任务会怎样? 新任务仍进入当前微任务队列,检查点会继续执行,直到队列为空。
- 追问:
await后面的代码为什么常作为微任务分析?await会暂停 async 函数,等待值处理完成后通过 Promise 相关 Job 恢复后续执行。 - 追问:Promise 构造器的 executor 是异步执行吗? 不是,创建 Promise 时 executor 立即同步调用,异步的是已登记 reaction 的后续 Job。
- 追问:浏览器与 Node 的事件循环能否用同一套阶段顺序? 不能,ECMAScript 定义 Job,具体任务调度由宿主负责;Node 还包含自身的阶段与 nextTick 规则。
八、加强记忆
事件循环可以记成“同步先跑,微任务插队,宏任务排队”。每轮宏任务结束后会清空微任务,然后浏览器才有机会渲染和进入下一轮。