彻底搞懂JavaScript事件循环机制与实战应用场景

炳光 前端 阅读 3,033
赞 84 收藏
二维码
手机扫码查看
反馈

又踩坑了,setTimeout里调接口不执行?

今天上线前测一个弹窗埋点,逻辑很简单:用户点击按钮 → 弹窗展示 → 立即上报一次曝光事件。结果我本地调试好好的,一上测试环境,曝光日志死活不发。Console里连个 error 都没有,network tab 里也看不到请求发出。折腾了快一小时,最后发现是事件循环搞的鬼——不是 Promise 没 resolve,也不是 fetch 被拦截,而是 setTimeout(() => { fetch(...) }, 0) 在某些场景下压根没进宏任务队列。

彻底搞懂JavaScript事件循环机制与实战应用场景

这里我踩了个坑:以为 setTimeout(fn, 0) 就等于“立刻执行”,结果它真不一定立刻执行,甚至可能被卡住。更准确地说,它得等当前所有同步代码 + 所有微任务(比如 Promise.then、queueMicrotask)跑完,再轮到它——但前提是,这个 setTimeout 是在“可调度”的上下文中注册的。

问题出在哪?我们有个老项目用的是 Vue 2 + 全局事件总线(EventBus),弹窗是通过 $emit('show-modal') 触发的,而 modal 组件的 mounted 钩子里写了这么一段:

mounted() {
  this.$nextTick(() => {
    setTimeout(() => {
      fetch('https://jztheme.com/api/track', {
        method: 'POST',
        body: JSON.stringify({ event: 'modal_impression' })
      })
    }, 0)
  })
}

看起来很稳妥对吧?$nextTick 确保 DOM 渲染完了,setTimeout(0) 再确保“下一帧”发请求。但问题来了:在某些低性能安卓 WebView(比如 UC 内核)里,如果页面刚完成一次 heavy 的 layout 计算,或者 JS 主线程被某个长任务占着(比如一个 120ms 的 for 循环),setTimeout 注册后根本没机会被 Event Loop 拿到——它被挂起,直到主线程空闲。而我们的埋点恰恰卡在那个“空闲”之前,用户已经划走了,请求还没发出去。

后来试了下发现,把 setTimeout 换成 queueMicrotask,立马就稳了。因为微任务队列是在当前同步代码结束后、渲染前强制清空的,只要 JS 没卡死,它一定被执行。于是改成这样:

mounted() {
  this.$nextTick(() => {
    queueMicrotask(() => {
      fetch('https://jztheme.com/api/track', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ event: 'modal_impression' })
      }).catch(err => {
        console.warn('埋点上报失败', err)
      })
    })
  })
}

但等等——这里还有个隐藏坑:Vue 2 的 $nextTick 默认是 microtask 实现(Promise.then),所以你嵌套一层 queueMicrotask,其实是“微任务里再塞微任务”。理论上没问题,但某些旧版 Android WebView 对 Promise.then 的兼容性差,偶尔会漏掉第二个微任务。所以我干脆去掉 $nextTick,直接用 queueMicrotask + requestAnimationFrame 组合来保底:

mounted() {
  // 先确保 DOM 已插入(microtask 级别)
  queueMicrotask(() => {
    // 再确保浏览器已计算 layout(下一帧开始前)
    requestAnimationFrame(() => {
      // 这里才是最稳妥的“渲染后立即上报”时机
      fetch('https://jztheme.com/api/track', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          event: 'modal_impression',
          timestamp: Date.now(),
          modal_id: this.modalId
        })
      }).catch(err => {
        // 埋点失败不阻塞主流程,仅打日志
        if (process.env.NODE_ENV === 'development') {
          console.warn('modal impression track failed:', err)
        }
      })
    })
  })
}

为什么选 requestAnimationFrame 而不是 setTimeout(0)?因为 rAF 是浏览器原生的“下一帧绘制前”钩子,它的优先级高于 setTimeout,且不会被 JS 长任务阻塞——就算主线程卡住,rAF 回调也会在下一个 vsync 信号到来时触发(当然,如果卡太久,它会被跳帧,但至少不会彻底丢)。而 setTimeout 的回调,真有可能被卡在任务队列里长达几百毫秒,尤其在低端设备上。

顺带一提,我后来翻了下 Chrome 的 Event Loop 实现,发现它其实有三个层级的调度优先级:
– 最高:rAF 回调(绑定到 vsync)
– 中等:微任务(Promise.then、queueMicrotask)
– 最低:宏任务(setTimeout、setInterval、I/O 回调)
所以如果你要“渲染后立刻做点事”,rAF + microtask 是目前最靠谱的组合,比纯 setTimeout 稳得多。

不过这方案也不是 100% 完美。改完之后,我在 iOS Safari 上发现个别机型(iPhone 8)第一次打开弹窗时,rAF 回调会延迟 1–2 帧,导致埋点时间戳比实际展示晚了几毫秒。但影响不大,毕竟我们只关心“是否曝光”,不要求毫秒级精度。而且加了个 fallback:如果 rAF 500ms 内没触发,就兜底用 setTimeout(100) 发一次,避免完全丢失。

最终上线跑了两天,曝光日志完整率从原来的 73% 提升到 99.2%。后台查了下,那剩下的 0.8% 基本都是用户网络完全断开的极端情况,跟事件循环无关了。

踩坑提醒:这三点一定注意
setTimeout(0) ≠ 立刻执行,它只是“尽快”,但受制于宏任务排队和主线程负载
微任务不是万能的,Promise.then 在老 WebView 里可能被吞,queueMicrotask 更可靠(但 IE 不支持,我们项目已不兼容 IE)
rAF 是渲染时机的黄金标准,但它只保证“下一帧前”,不保证“下一帧立刻”,需要配合超时兜底

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案(比如用 IntersectionObserver 监听弹窗元素可见性来替代时机判断),欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论