本文从一道面试题切入,系统梳理浏览器与 Node 的事件循环模型;解释宏任务与微任务的区别、执行顺序与常见坑;结合浏览器渲染时机;深入 Vue 3 批处理源码如何巧用微任务实现"同一轮内的合并更新";最后从任务调度角度讨论性能优化策略。完整成文,便于一次性阅读与复盘。
1. 从一道面试题入手:宏任务微任务混杂的打印顺序(浏览器)
示例代码(Chrome 环境;setImmediate 在浏览器一般不可用,若实现则顺序不稳定,不建议依赖):
js
jsconsole.log(1);
setImmediate?.(() => {
console.log(2);
});
setTimeout(() => {
console.log(3);
new Promise((resolve) => {
console.log(10);
resolve();
}).then(() => {
console.log(11);
});
}, 0);
requestAnimationFrame?.(() => {
console.log(4);
});
Promise.resolve().then(() => {
console.log(5);
});
new Promise((resolve) => {
resolve(6);
}).then(() => {
console.log(7);
});
(async function () {
console.log(8);
await Promise.resolve();
console.log(9);
})();
requestIdleCallback?.(() => {
console.log(12);
});
常见输出(Chrome):
1, 8, 5, 7, 9, 4, 3, 10, 11, 12
顺序解析:
同步:1、8
微任务(在当前宏任务末尾清空):5、7、9
帧回调:requestAnimationFrame 在下一帧绘制前:4
计时器宏任务:setTimeout(0) 回调:3,回调内同步:10,回调末尾微任务:11
空闲回调:requestIdleCallback 优先级最低:12
2. 事件循环与宏/微任务:区别、原理,微任务会卡死吗?
同步任务:立即执行、阻塞线程。例:普通代码、同步 I/O、大量计算、浏览器强制布局。
宏任务(task):进入事件循环的任务队列,逐个取出执行。例:setTimeout/setInterval、用户事件、I/O 回调等。
微任务(microtask):优先级更高,在"当前宏任务结束后、下一个宏任务开始前"被"清空"。
浏览器:Promise.then/catch/finally、queueMicrotask、MutationObserver
Node:Promise 微任务 + process.nextTick(优先级高于 Promise 微任务)
事件循环关键节拍:
-
执行一个宏任务
-
清空当前产生的所有微任务(可能再产生微任务,继续清空直到无)
-
浏览器可能进入渲染阶段
-
进入下一轮宏任务
微任务会卡死吗?会。若在微任务里不断排入新的微任务(微任务"自旋"),规范会"清空"队列,导致渲染和后续宏任务都被饿死。Node 中滥用 process.nextTick 风险更高。
3. 浏览器渲染机制与宏任务、微任务
一帧(简化):
-
执行一个宏任务(如点击事件或计时器)
-
清空微任务
-
渲染步骤(样式计算 → 布局 → 绘制)
-
下一帧前执行 requestAnimationFrame
-
空闲阶段可能执行 requestIdleCallback(可带 deadline)
要点:
微任务在渲染前清空,因此框架能在"同一轮交互"内合并多次状态到一次 DOM 提交。
requestAnimationFrame 用于动画,回调在下一帧绘制前;回调内产生的微任务会在该帧绘制前被清空。
requestIdleCallback 优先级最低,适合非关键、可延迟任务,最好带超时兜底。
实务建议:
动画逻辑 → requestAnimationFrame
次要任务 → requestIdleCallback
微任务只做"收尾",避免自旋导致渲染饥饿
4. Node.js 的事件循环与浏览器区别
Node(libuv)阶段(简化):
-
timers:到期的 setTimeout/setInterval
-
pending callbacks:系统级 I/O 回调
-
idle/prepare:内部使用
-
poll:拉取 I/O 事件,大多数 I/O 回调在此执行;队列空且有 timer 到期会回到 timers;否则可能阻塞等待 I/O
-
check:执行 setImmediate
-
close callbacks: 事件等
-
close
微任务:
process.nextTick:每个阶段切换前先清空,优先于 Promise 微任务
Promise 微任务:与浏览器类似,宏任务后清空
典型对比:
setTimeout(0) vs setImmediate:顶层脚本中通常 setTimeout(0) 先;在 I/O 回调中注册时往往 setImmediate 先。
Node 无 requestAnimationFrame/requestIdleCallback(那是浏览器渲染相关 API)。
把第 1 节示例裁剪为 Node(去掉 rAF/rIC)常见顺序:
1, 8, 5, 7, 9, 3, 10, 11, 2
5.从 Vue 批处理源码看宏/微任务
Vue 3 的"批处理(batching)"是把宏/微任务优势工程化的典型:
副作用函数(ReactiveEffect)包裹渲染与 watch,当依赖变更时不立即执行,而是交给 scheduler(job) 排队。
使用微任务 Promise.resolve().then(flushJobs) 统一触发 flush,把同一 tick 内多次状态变更合并为一次渲染与一次 DOM 提交。
多级队列与顺序:
pre 队列:flush: 'pre' 的 watch
主更新队列:组件更新 job(按 id 排序,保证父先子、稳定)
post 队列:flush: 'post'、onUpdated、指令后置等
Set 去重、执行计数与递归保护,防重复与无限循环。
简化伪码(核心思路):
ts
const queue: Job[] = []
const queued = new Set<Job>()
let isFlushing = false, isFlushPending = false
function queueJob(job) {
if (!queued.has(job)) {
queued.add(job)
queue.push(job)
if (!isFlushing && !isFlushPending) {
isFlushPending = true
Promise.resolve().then(flushJobs) // 微任务触发批处理
}
}
}
function flushJobs() {
isFlushPending = false
isFlushing = true
flushPreFlushCbs()
queue.sort(jobComparatorById)
for (const job of queue) job()
queue.length = 0
queued.clear()
flushPostFlushCbs()
isFlushing = false
}
与渲染契合:
微任务清空发生在渲染之前,故框架可合并更新;nextTick() 等待这次 flush(含 post)完成,保证能读取到更新后的 DOM。
在 Node(SSR):
无浏览器渲染阶段,但同样利用微任务合并计算,避免重复渲染逻辑。
若业务滥用 process.nextTick 可能扰动 flush 节奏,需理解阶段优先级。
6. 从宏任务、微任务来看性能优化(浏览器与 Node)
切片长任务,避免长帧(>16.67ms)
使用 setTimeout/MessageChannel/requestIdleCallback 将大计算拆成小片
示例(MessageChannel 分片):
js
const chan = new MessageChannel();
const BATCH = 1000;
function work(arr, i = 0) {
const end = Math.min(i + BATCH, arr.length);
for (let j = i; j < end; j++) { /* 计算 */ }
if (end < arr.length) {
chan.port1.postMessage(null);
chan.port2.onmessage = () => work(arr, end);
}
}
work(bigArray);
动画与交互在正确时机
动画/视觉更新 → requestAnimationFrame
非关键、可延迟 → requestIdleCallback(带超时)
避免强制重排:读写分离,读在一起,写放 rAF
微任务只做"收尾",避免自旋
.then/queueMicrotask 用来合并与尾部处理,不要无限链式创建
框架已做批处理,业务少叠加微任务循环
Node:避免主线程被 CPU 任务绑死
用 worker_threads/子进程并行重计算;I/O 使用异步 API
合理选择 setImmediate(check 阶段尾部任务),谨慎 process.nextTick
限流与背压
浏览器:节流/防抖高频事件,虚拟列表/切片渲染
Node:流式处理(背压)、并发控制(p-limit/Bottleneck)
监控与诊断
浏览器:Performance 面板(Long Task、帧时间)、Lighthouse(TTI/TBT/LCP/CLS)
Node:perf_hooks.monitorEventLoopDelay()、APM、CPU/GC/堆快照
Vue 最佳实践
顺应批处理:同一 tick 内多次 state 修改会合并;读 DOM 用 nextTick
watch 的 flush 选择恰当;避免在 effect 中无界 setState 循环
任务安排"策略表":
关键视觉更新 → rAF
非关键低优先 → rIC(带 timeout)
批处理收束 → 微任务(适度)
重计算 → 切片/并行
I/O 密集 → 异步 + 背压
常见任务类型速查
同步(阻塞):普通 JS、复杂计算、大对象 JSON 处理、浏览器强制布局、Node 同步 I/O/加解密压缩
微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver、Node process.nextTick
宏任务:setTimeout/setInterval、消息通道、浏览器事件回调、XHR/fetch 回调入队、requestAnimationFrame(渲染前)、requestIdleCallback(空闲)、Node I/O 回调(poll)、setImmediate(check)
总结
事件循环主线:一个宏任务 → 清空微任务 →(浏览器)渲染 → 下一轮宏任务。
浏览器侧:微任务在渲染前清空;requestAnimationFrame 在下一帧绘制前;requestIdleCallback 在空闲时。
Node 侧:libuv 分阶段;setTimeout 在 timers,setImmediate 在 check;process.nextTick 优先级最高。
Vue 3 批处理:用微任务统一 flush,多级队列(pre/更新/post)+ 去重排序,合并多次状态为一次 DOM 提交;nextTick 保证读到已更新 DOM。
性能优化要点:根据任务类型和渲染时机安排工作,切片/并行重任务,避免微任务饿死事件循环,利用框架批处理节奏。