从一个 `console.log` 顺序翻车说起,聊聊微任务那些糟心事

0 阅读1分钟

从一个 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 先出来,没毛病,同步代码嘛。然后 promisemicrotaskmutation——这个等下再说,对吧?跑一下 Chrome。

没问题。

再跑一次。

sync
mutation
promise
microtask

坏了。MutationObserver 跑前面去了,你写代码的顺序根本不算数,入队时机才是爹。

同一个队列,不同的入队姿势

讲道理,我翻过不少事件循环的文章,十篇有八篇把 Promise.thenqueueMicrotaskMutationObserver 往"微任务"这个筐里一扔就完事了,说它们优先级一样。对吗?对。有用吗?没用。优先级一样但入队时机天差地别,最终谁先跑完全是另一码事。

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 不是免费的午餐。

那浏览器事件循环一轮的真实顺序到底是啥?把渲染管线也画进来的话:

  1. 取一个宏任务跑完(setTimeout 回调、MessageChannel、用户点击事件之类的)
  2. 清空微任务队列,Promise.thenqueueMicrotaskMutationObserver 全在这一步
  3. 浏览器判断:需要渲染吗?不一定。屏幕 60Hz 的话大约 16.6ms 一帧,你要是 1ms 内连着跑了 10 个 setTimeout(fn, 0),大概率这 10 个宏任务全跑完了才渲染一次
  4. 如果要渲染——进入渲染阶段:跑 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 回调也算一个可执行上下文,所以在 rAFqueueMicrotask 是安全的。

嗯,继续。

那在 rAF 回调里再调一次 requestAnimationFrame 注册的新回调会在当前帧跑吗?不会。规范写得很清楚:每帧开始时浏览器会对当前已注册的 rAF 回调列表做一次快照,只跑快照里的,执行期间新注册的推到下一帧。双 rAF 能保证跨帧不是 hack,是规范行为。

ResizeObserver 的调度时机更绕——卡在布局之后绘制之前,还可能触发二次 re-layout。够呛。这个回头单独写。