JavaScript 事件循环:微任务、宏任务与渲染时机
JavaScript 事件循环:微任务、宏任务与渲染时机
JavaScript 是一门单线程语言,它同一时间只能做一件事。但借助事件循环(Event Loop),它能高效处理网络请求、用户交互和定时器等异步操作,同时保持界面的流畅响应。理解事件循环、微任务与宏任务的分工,是写出高性能、可预测代码的关键。
为什么需要事件循环
在浏览器中,渲染引擎和 JavaScript 引擎共享主线程。如果 JS 长时间占用线程,页面就会“卡死”。事件循环通过任务队列与优先级调度,在“执行代码”与“更新界面”之间找到平衡,让异步结果在合适的时机被处理。它的核心思想是:
- 同步代码直接进调用栈立即执行。
- 异步任务先挂起,结果就绪后,对应的回调被放入任务队列排队。
- 调用栈清空后,事件循环从任务队列中取出回调执行。
调用栈与任务队列
JS 引擎维护一个调用栈(Call Stack),记录函数的执行上下文。当调用栈为空时,事件循环就会检查任务队列,将等待中的任务推入栈中执行。这些任务分为两类:宏任务(MacroTask) 和 微任务(MicroTask)。
宏任务(MacroTask / Task)
宏任务代表一个独立的、完整的执行单元。常见的宏任务来源包括:
<script>整体代码(第一个宏任务)setTimeout、setInterval的回调- I/O 操作(如
fetch完成后的回调) - UI 交互事件(
click、scroll等) requestAnimationFrame(在渲染前执行,某些角度也可归为宏任务)setImmediate(Node.js 环境)
浏览器在一个宏任务执行完成后,会检查是否需要渲染,然后再从宏任务队列中取出下一个宏任务。
微任务(MicroTask)
微任务是一些需要尽快执行、但又不能打断当前宏任务的异步任务。它们总是在当前宏任务执行完、下一个宏任务开始前被全部清空。常见的微任务来源:
Promise.then()、Promise.catch()、Promise.finally()async/await(本质是 Promise 的语法糖)MutationObserverqueueMicrotask()
事件循环的完整执行流程
浏览器的每一次事件循环迭代可以简化为以下步骤:
- 执行一个宏任务(从宏任务队列中取出最老的一个)
- 清空微任务队列:依次执行所有微任务,直到队列为空。这期间新产生的微任务也会在当前循环中被执行。
- 判断是否需要渲染:浏览器会根据刷新率(通常 60fps,即每 16.6ms 一次)和当前环境决定是否进行页面渲染。
- 若需要渲染,则执行与渲染相关的操作(如执行
requestAnimationFrame回调、重排重绘等)。 - 进入下一轮事件循环,取出下一个宏任务。
全局
<script>代码本身就是一个宏任务,所以所有写在脚本中的同步代码属于“第一个宏任务”。执行完同步代码后,若存在微任务,会立即清空微任务队列,之后才可能渲染,接着再执行setTimeout等下一个宏任务。
微任务与宏任务的执行顺序演示
来看一段经典混合代码,彻底理解执行顺序:
console.log('1'); // 同步
setTimeout(() => {
console.log('2'); // 宏任务回调
}, 0);
Promise.resolve().then(() => {
console.log('3'); // 微任务
});
console.log('4'); // 同步
输出顺序为:1 → 4 → 3 → 2。
解释:
- 整体代码作为一个宏任务开始,顺序输出
1。 setTimeout将其回调放入宏任务队列,等待一次循环。Promise.then将回调放入微任务队列。- 输出
4,同步代码执行完毕。 - 当前宏任务结束,开始清空微任务队列,输出
3。 - 微任务清空后,浏览器可能判断渲染(此处代码不会触发渲染),然后取出下一个宏任务,输出
2。
再看一个包含多次微任务的例子:
setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
});
console.log('D');
输出顺序:D → B → C → A。注意 B 中的微任务 C 会在同一轮微任务清空中立即执行,不会留到下一轮。
为什么微任务一定要在渲染之前全部执行
浏览器的渲染运行在宏任务之间(偶尔在同一个事件循环中),而微任务队列必须在渲染前被清空。原因之一是避免微任务导致的界面更新延迟:如果你在一次宏任务中多次变更 DOM 并产生 Promise 回调,它们将在渲染前一次性处理完,保证渲染的结果是最新的。如果微任务在渲染后才执行,用户可能会先看到旧的界面,再跳变到新界面,严重影响体验。
但这也带来一个隐患:如果微任务中又不断产生新的微任务(无限递归),事件循环将被卡在清空微任务队列的步骤,导致页面无法渲染和响应。因此,不建议在微任务中无限添加微任务。
渲染时机的细节
当浏览器认为需要更新页面时(例如 DOM 变化、CSS 动画、滚动等),会在合适的时机执行渲染步骤。典型的渲染相关钩子:
requestAnimationFrame(rAF):回调在渲染前执行,适合更新动画或处理样式,以保证在下一帧绘制前完成计算。- 渲染(包括样式计算、布局、绘制):在这之后页面才会显示新的内容。
具体顺序可以这样理解:
宏任务 → 微任务队列清空 → requestAnimationFrame → 渲染 → 下一宏任务(如果时间允许)。
因此,若希望操作与下一帧渲染同步,可以使用 rAF,而不是 setTimeout。
在 Node.js 环境中的差异
Node.js 也有事件循环,但它是基于 libuv 的多阶段循环,包括 timers、pending callbacks、idle、poll、check、close callbacks 等阶段。重点区别:
process.nextTick()是 Node 独有的微任务,优先级高于Promise微任务。在每一个 Node 事件循环阶段结束前,会先清空nextTick队列,再清空 Promise 微任务队列。setImmediate在 check 阶段执行,setTimeout(fn,0)在 timers 阶段执行,执行顺序取决于事件循环启动的时间和环境,并不总是固定。- 浏览器的“一个宏任务 → 全部微任务 → 渲染”模型本质相似,但 Node 没有渲染步骤。
本文重点聚焦浏览器环境,Node 开发者可参考专门的事件循环剖析。
最佳实践与常见陷阱
-
避免长时间运行的宏任务
将大任务拆分成多个小宏任务(例如用setTimeout分段),让出线程给用户交互和渲染。 -
谨慎使用无限微任务
像Promise循环不断追加微任务会导致渲染阻塞和内存泄漏。务必提供退出条件。 -
利用
queueMicrotask控制执行时机
当需要保证某些代码在当前宏任务完成后、任何异步回调或渲染前执行时,可使用queueMicrotask,但不要滥用。 -
区分 setTimeout(fn, 0) 与 Promise
setTimeout延迟最小约 4ms(嵌套时),且一定会等到下一轮事件循环;Promise 微任务在同轮末立即执行。根据优先级需求选择。 -
使用
requestAnimationFrame进行视觉更新
需要与浏览器刷新同步的动画、布局修改,优先使用 rAF,避免用setTimeout或微任务造成布局抖动。
总结
- 事件循环是 JavaScript 异步调度的核心,单线程通过任务队列实现并发。
- 宏任务(如
setTimeout、事件)每次只执行一个,然后清空所有微任务(如Promise.then)。 - 渲染通常发生在两次宏任务之间,并且在微任务队列清空之后。
- 掌握执行顺序能帮你写出更可靠、高效的前端代码,避免界面卡顿和难以排查的时序问题。
只要理清“同步代码 → 微任务 → 可能渲染 → 下一个宏任务”这一主线,绝大多数异步谜题都能迎刃而解。