从一个 `console.log` 顺序翻车说起,聊聊微任务那些糟心事
从一个 console.log 顺序翻车说起,聊聊微任务那些糟心事
Promise.resolve().then(() => console.log('promise'))
queueMicrotask(() => console.log('microtask'))
const observer = new MutationObserver(() => console.log('mutation'))
const node = document.createTextNode('')
observer.observe(node, { characterData: true })
node.data = '1'
console.log('sync')
你猜输出啥?sync 先出来,没毛病,同步代码嘛。然后 promise、microtask、mutation——这个等下再说,对吧?跑一下 Chrome。
没问题。
再跑一次。
sync
mutation
promise
microtask
坏了。MutationObserver 跑前面去了,你写代码的顺序根本不算数,入队时机才是爹。
同一个队列,不同的入队姿势
讲道理,我翻过不少事件循环的文章,十篇有八篇把 Promise.then、queueMicrotask、MutationObserver 往"微任务"这个筐里一扔就完事了,说它们优先级一样。对吗?对。有用吗?没用。优先级一样但入队时机天差地别,最终谁先跑完全是另一码事。
queueMicrotask(fn) 最老实——你调用的那一瞬间 fn 就塞进微任务队列了,没有中间商赚差价,没有任何包装层,一步到位。Promise.resolve().then(fn) 差不多,因为这个 Promise 已经是 resolved 状态了,.then 执行的时候 fn 也是立刻入队,但它多走了一层 PromiseReactionJob 的内部机制,比 queueMicrotask 慢那么一丢丢。所以这俩的顺序基本就是你写代码的顺序,稳得一批。
MutationObserver 的话?不一样。
它的回调入队时机取决于浏览器把 DOM 变更"收集"完毕的时间点——浏览器会在同一个微任务检查点(microtask checkpoint)触发前,把这段时间内攒起来的所有 DOM 变更打包,然后才把 MutationObserver 的回调作为微任务丢进队列。就这个"攒"的动作,导致了时序上的不确定性,你没法拿看代码顺序来推断它什么时候入队(听起来很合理对吧,但是)。
// 这段的顺序是确定的
queueMicrotask(() => console.log('A')) // 调用瞬间入队
Promise.resolve().then(() => console.log('B')) // 也是立刻,但多了一层包装
// 永远 A → B
坦白说 V8 源码里 queueMicrotask 走的是 EnqueueMicrotask 这条更短的路径,而 Promise.then 要经过 NewPromiseReactionJobTask 再绕一圈才入队。别问我怎么知道的。
说到这里我自己都有点绕了。
这也解释了 Vue 的 nextTick 演变史:Vue 2 最早用 MutationObserver,时序不稳,后来换成 Promise.then,到 Vue 3 干脆用 queueMicrotask 打底。越直接越可控,就这么简单。
requestAnimationFrame 压根不是任务,它是渲染管线的一部分
这块是重灾区。
我见过无数事件循环示意图,把 rAF 画在宏任务和微任务旁边,搞一个所谓的"rAF 队列"。
来看一段会让你抓狂的代码:
document.querySelector('.box').style.transform = 'translateX(0px)'
requestAnimationFrame(() => {
document.querySelector('.box').style.transform = 'translateX(100px)'
})
你以为会看到元素从 0px 平滑过渡到 100px 的动画?实际效果:元素直接出现在 100px 的位置,没有任何过渡。怎么回事?因为第一行的样式修改和 rAF 回调里的修改都在同一帧被处理了,浏览器把它们合并成一次渲染,中间根本没有产生过一帧"元素在 0px"的画面。
修复方案有两个。一是"双 rAF"技巧:
box.style.transform = 'translateX(0px)'
requestAnimationFrame(() => {
// 第一个 rAF:确保 0px 这个值被渲染出去了
requestAnimationFrame(() => {
// 第二个 rAF:下一帧再改成 100px
box.style.transform = 'translateX(100px)'
})
})
二是用 getComputedStyle(box).transform 强制触发一次同步重排,逼浏览器把第一次修改"落地"。但这招有代价——同步布局计算在列表场景下会直接把帧率干到个位数,getComputedStyle 不是免费的午餐。
那浏览器事件循环一轮的真实顺序到底是啥?把渲染管线也画进来的话:
- 取一个宏任务跑完(
setTimeout回调、MessageChannel、用户点击事件之类的) - 清空微任务队列,
Promise.then、queueMicrotask、MutationObserver全在这一步 - 浏览器判断:需要渲染吗?不一定。屏幕 60Hz 的话大约
16.6ms一帧,你要是 1ms 内连着跑了 10 个setTimeout(fn, 0),大概率这 10 个宏任务全跑完了才渲染一次 - 如果要渲染——进入渲染阶段:跑
rAF回调,算样式,跑布局,绘制,合成
反正大概是这么个意思。
注意第 3 步那个"不一定"。这就是为什么你不能拿 rAF 当"尽快执行"用,它的实际延迟可能比 setTimeout(fn, 0) 还大(听起来很合理对吧,但是)。React 的 scheduler 选了 MessageChannel 而不是 rAF,原因就在这——React 要的是"尽快切到下一个任务切片",不是"等到下一次渲染前"。
混在一起用的时候,地狱开始了
虚拟滚动列表。滚动事件触发后你要干三件事:算可视区域、改 DOM、等渲染完拿新 DOM 的高度。三步,三种调度策略。搞混一个直接白屏。
onScroll = (event) => {
// 同步算可视范围,这步没争议
const visibleRange = calcRange(event.scrollTop)
// 微任务里批量更新 DOM——为什么?
// 因为要赶在当前帧渲染前把 DOM 改好
queueMicrotask(() => {
updateDOM(visibleRange)
// 等渲染完测量高度,用 rAF?
// 坑来了
requestAnimationFrame(() => {
// rAF 跑在渲染阶段开头,布局还没算呢
// 你拿到的高度是上一帧的
requestAnimationFrame(() => {
const heights = measureHeights() // 这里才安全
})
})
})
}
rAF 的回调跑在渲染流水线的最前面,在样式计算和布局之前,你在 rAF 里读 offsetHeight 之类的值,拿到的可能是旧的。又是双 rAF——第一个保证 DOM 更新进了渲染管线,第二个在下一帧拿上一帧的布局结果。
嗯,继续。
怎么说呢,这个调度模型其实一句话就能讲完:微任务在当前宏任务结束后渲染前清空,rAF 在渲染阶段开头跑,渲染整完后想做事没有原生 API,要么双 rAF 要么 ResizeObserver。
但还有更绕的。rAF 回调里能不能产生微任务?能。
requestAnimationFrame(() => {
console.log('rAF-1')
queueMicrotask(() => console.log('micro-in-rAF'))
})
requestAnimationFrame(() => {
console.log('rAF-2')
})
// 输出:rAF-1 → micro-in-rAF → rAF-2
每个 rAF 回调执行完后浏览器都会检查微任务队列并清空,跟宏任务结束后清空微任务是同一套逻辑——每个可执行上下文结束时都有一个 microtask checkpoint,rAF 回调也算一个可执行上下文,所以在 rAF 里 queueMicrotask 是安全的。
嗯,继续。
那在 rAF 回调里再调一次 requestAnimationFrame 注册的新回调会在当前帧跑吗?不会。规范写得很清楚:每帧开始时浏览器会对当前已注册的 rAF 回调列表做一次快照,只跑快照里的,执行期间新注册的推到下一帧。双 rAF 能保证跨帧不是 hack,是规范行为。
ResizeObserver 的调度时机更绕——卡在布局之后绘制之前,还可能触发二次 re-layout。够呛。这个回头单独写。