Vue.nextTick 从v3.5.13追溯到v0.7.0

1,031 阅读4分钟

该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。

其他篇章:

  1. Promise.try 和 Promise.withResolvers,你了解多少呢?
  2. 从 babel 编译看 async/await
  3. 挑战ChatGPT提供的全网最复杂“事件循环”面试题
  4. Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?
  5. Vue 是怎么从<HelloWorld />、<component is='HelloWorld'>找到HelloWorld.vue

接着事件循环的学习热浪走,我们来看看实际项目中关于宏任务微任务以及 Promise 的运用。让我们走进老生常谈的一道面试题—— Vue.nextTick 的原理是什么?

微信图片_20241127211517.jpg

注:来自 antfu 粉丝群的聊天图片

基本信息

姓名nextTick

籍贯:Vue 全局 API

出生年月:2013.12

功能:延迟执行回调函数,在下一次 DOM 更新循环结束后调用。

由来:Vue 使用虚拟 DOM 来优化渲染性能,组件状态的变化并不会立即更新实际的 DOM,而是等到下一个更新周期(微任务)才应用更新。因此,当需要在 DOM 更新完成后执行操作时,Vue 提供了 nextTick

<template>
  <div id="app">{{ message }}</div>
</template>

<script>
export default {
  data() {
    return { message: "Hello" };
  },
  methods: {
    updateMessage() {
      this.message = "Hello Vue!";
      console.log(this.$refs.app.innerText); // 此时还是旧值 "Hello"
    },
  },
};
</script>

在以上代码中,message 更新后,DOM 并不会立即更新。直接读取 DOM 状态可能会出现不一致的问题。

专业技能

  • 获取最新 DOM 状态:在数据改变后,立即读取更新后的 DOM 信息。
  • 与第三方库交互:在数据更新后,初始化动画、插件等需要基于 DOM 的操作。
  • 延迟执行回调:确保任务在 DOM 完成更新后运行。

工作经历

以下 Vue.nextTick 说明只涉及重大改动,细微改动不做讨论。因为篇幅问题,请点击文件链接跳转查看。

v0.7.0

文件位置src/utils.js

commitdom method callbacks should be async.

逻辑相当简单,就是使用 requestAnimationFrame 或者 setTimeout 来实现异步操作 dom。

requestAnimationFrame(() => {
  console.log('rAF');
});

setTimeout(() => {
  console.log('setTimeout');
}, 0);

console.log('Sync');

宏任务的执行顺序取决于主线程是否空闲:只有当前的所有同步任务和微任务执行完成后,才会从宏任务队列中取出任务执行。它不关心浏览器的渲染节奏,只要时间到了且主线程空闲,回调就会执行。

requestAnimationFrame 的回调会在 渲染阶段 之前执行,它是专门为优化动画帧设计的。每一帧开始时,浏览器会清空 rAF 队列,然后尝试重绘页面。rAF 保证了回调函数总是在浏览器即将绘制页面前执行,能确保动画的平滑性。它不会进入宏任务或微任务队列,而是被浏览器单独管理。

如果当前主线程空闲,执行当前同步任务,然后将 setTimeout 注册为宏任务,而 requestAnimationFrame 在渲染阶段前执行,那么在同步任务结束后,setTimeout > rAF。 反之,如果处于事件循环中,setTimeout 会被注册为下轮事件循环宏任务,而 requestAnimationFrame 会在本轮事件循环的渲染阶段前回调,那么就是 rAF > setTimeout

特性webkitRequestAnimationFrame / requestAnimationFramesetTimeout
设计目的为动画和页面重绘优化而设计,与浏览器刷新同步通用的定时器,适用于所有任务
回调时间精确到下一次浏览器刷新帧(通常是 16ms)由开发者指定,但实际时间可能延迟,受系统和事件循环限制
暂停机制当页面不可见或后台运行时,浏览器会自动暂停调用不会暂停,继续执行,可能浪费资源
性能更高效,避免多余的帧绘制可能因频繁触发导致主线程负担
适配性专为浏览器环境设计,不能在 Node.js 等环境使用浏览器和 Node.js 都支持
浏览器兼容性现代浏览器支持 requestAnimationFrame,旧浏览器使用 webkit 前缀广泛支持,包括旧浏览器
调用频率与显示器刷新率一致(通常每秒 60 次,即每帧 16.67ms)受事件循环和定时器精度限制
  • requestAnimationFrame / webkitRequestAnimationFrame
    • 用于实现动画(如滚动、过渡效果)。
    • 在需要与刷新同步的任务中更高效。
  • setTimeout
    • 用于非动画任务,例如定时触发的逻辑计算、数据更新等。
    • 用在与帧频无关的任务中。

v0.9.3

文件位置src/utils.js

commitsetTimeout(0) is faster than rAF

这次改动是直接使用 setTimeout,因为尤大在 bench 测试中发现 setTimeout(0)requestAnimationFrame 更快。但是该 commit 有评论持反对意见,认为应该 revert

使用 setTimeout 可能让代码看起来“运行得更快”(不被渲染循环影响),但用户会感受到更新不够流畅,因为它无法重绘。

v0.10.4

文件位置src/utils.js

commitbring back the rAF

恢复 requestAnimationFrame 的使用。

v0.11.5

文件位置src/util/env.js

commitre-implement nextTick with MutationObserver

引入 MutationObserver 微任务:通过 document.createTextNode 创建一个 node 节点,使用 MutationObserver 对其进行监听 characterData。当 characterData 变化时,记录当前队列长度 l,取出回调执行,更新队列(避免执行回调过程添加新任务,所以切割前面记录的队列长度)。当调用 nextTick 时,将 cb 塞入队列,同时改变 node 节点的内容,触发监听回调执行。

v0.11.6

文件位置src/util/env.js

commitimprove nextTick

语法改为 IIFE,本质上还是 MutationObserver 微任务执行,同时 setTimeout 进行兼容处理。当多个回调函数通过 nextTick 注册时,pending 确保只会安排一次 handle 的异步调用。这样避免了重复触发和资源浪费。

v1.0.27

文件位置src/util/env.js

commitadjust nextTick implementation (fix #3730)

涉案 issueIOS10 微信页面滑动过程中产生tap事件,会导致Vue渲染不出来

使用 postMessage 代替 MutationObserver,监听 message 事件,判断是否当前 window 触发以及 data 是否为 __vue__nextTick__,然后执行回调队列。postMessage 也是属于宏任务,但是无需等待定时器计时完成,直接插入当前任务队列,在当时 setTimeout(0) 因为系统时间以及计时器模块问题,所以 postMessage 延迟更小。而在事件循环中,postMessage 作为当前轮次的宏任务被执行,而 setTimeout 的回调会等待到下一轮事件循环才触发。

但是因为 postMessage 是宏任务,而 MutationObserver 是微任务,微任务的优先级是高于宏任务的,所以在该 commit 的评论区有人指出他的测试用例没有通过。

v1.0.28

文件位置src/util/env.js

commitrevert nextTick to microtask semantics by using Promise.then

恢复了 MutationObserver 的使用,同时加入 Promise.then 来解决 IOS10 微信页面滑动过程中产生tap事件,会导致Vue渲染不出来。但是从 comment 中,我们可以知道在某些 iOS 版本中,Promise.then() 所触发的微任务队列可能会在执行几次后停止工作。也就是说,尽管回调函数已被加入微任务队列,但它们不会被执行,直到浏览器做其他工作(比如处理定时器)。这种问题会导致回调函数无法按预期执行。所以 if (isIOS) setTimeout(noop) 通过调用 setTimeout,来触发浏览器处理一个定时器任务,这就迫使浏览器退出微任务队列并处理它,确保微任务队列中的回调能够被执行。

经过搜索,导致微任务无法按预期执行有可能是 PromiseRejectionEvent 的问题。

One of the conditions for babel-polyfill to decides whether to use native Promise or not is isNode || typeof PromiseRejectionEvent == 'function'. In mobile phone (some of my testers that I've tested), PromiseRejectionEvent is undefined, so Promise was rewritten. ( vuex requires a Promise polyfill in some browser) —— Weird Promise in UIWebView in iOS >= 9.3.3

iOS do not implement PromiseRejectionEvent, so it use polyfill. But it seems promise polyfill has bug in iOS UIWebview when scrolling. —— iOS UIWebview scroll, promise polyfill bug

v2.5.0

文件位置src/util/env.js

commitfix: use MessageChannel for nextTick

涉案 issuecheckbox can not be selected if it's in a element with @click listener?click would trigger event other vnode @click event.Select value is not updated correctly when input handler triggers class change

因为 Promise/MutationObserver 微任务优先级过高,在本应该是顺序执行的事件之间触发,甚至在事件的冒泡过程中频繁触发(举个例子,click 事件在当前元素捕获,执行完回调方法后会接着微任务的执行,继续冒泡到上一层父级元素,再次执行父级元素的回调方法,又到微任务的轮次,导致 nextTick 的更新频繁执行),所以改成宏任务执行,又因为 setImmediate 并不是所有环境都支持,所以使用 MessageChannel 兼容实现,而在没有 DOM 事件的环境继续使用 Promise.then

  • setImmediate 被设计为在当前事件循环中的 I/O 事件处理完成之后立即执行任务(但优先于任何新的渲染和用户交互任务)。它比微任务(如 PromiseMutationObserver)的优先级低,但高于宏任务(如 setTimeoutrequestAnimationFrame)。
console.log('start')

const NEXT_TICK_TOKEN = '__vue__nextTick__'
window.addEventListener('message', e => {
  if (e.source === window && e.data === NEXT_TICK_TOKEN) {
    console.log('postMessage')
  }
})

const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = () => console.log('MessageChannel')

// postMessage > MessageChannel > setTimeout
window.postMessage(NEXT_TICK_TOKEN, '*')
port.postMessage(1)
setTimeout(() => console.log('setTimeout'), 0)

// postMessage > setTimeout > MessageChannel
window.postMessage(NEXT_TICK_TOKEN, '*')
setTimeout(() => console.log('setTimeout'), 0)
port.postMessage(1)

// MessageChannel > postMessage > setTimeout
port.postMessage(1)
window.postMessage(NEXT_TICK_TOKEN, '*')
setTimeout(() => console.log('setTimeout'), 0)

// MessageChannel > setTimeout > postMessage
port.postMessage(1)
setTimeout(() => console.log('setTimeout'), 0)
window.postMessage(NEXT_TICK_TOKEN, '*')

// setTimeout > postMessage > MessageChannel
setTimeout(() => console.log('setTimeout'), 0)
window.postMessage(NEXT_TICK_TOKEN, '*')
port.postMessage(1)

// setTimeout > MessageChannel > postMessage
setTimeout(() => console.log('setTimeout'), 0)
port.postMessage(1)
window.postMessage(NEXT_TICK_TOKEN, '*')

Promise.resolve().then(() => console.log('Promise'))
console.log('end')

从我的测试看,postMessageMessageChannelsetTimeout(0) 的输出顺序是按照代码顺序执行的,并不存在优先级问题。

大胆猜测这里选择使用 MessageChannel 的原因是:

  • window.addEventListener('message', e => { ... }) 会接收所有 message 事件,同时 window.postMessage(NEXT_TICK_TOKEN, '*') 向所有窗口进行广播,并不优雅。
  • setTimeout(0) 在2017年还存在最小 4ms 的延时误差。

如果有人更清楚情况,请在评论区告知。

ae72a2490eb64cb8b7159f4277605417.gif

v2.5.2

文件位置src/core/util/next-tick.js

commitfix: further adjust nextTick strategy

涉案 issuev-show is firing late on 2.5.1

nextTick 终于有了它自己的独栋大别墅。

因为当前事件循环产生的宏任务会在下次事件循环执行,排在了当前渲染任务后面,所以会出现视图更新不及时,出现闪屏的情况,动画效果不理想。所以划分宏任务和微任务机制处理,默认使用微任务更新,但在涉及 DOM 事件监听时,使用宏任务。

Q:如果使用 requestAnimationFrame,能否解决问题?

v2.6.0

文件位置src/core/util/next-tick.js

commitfix: async edge case fix should apply to more browsers

移除宏任务,使用 Promise.then > MutationObserver > setImmediate > setTimeout 的优先级进行处理。

但是微任务会重新导致@click would trigger event other vnode @click event.,为了避免这种重复触发,代码中通过 isUsingMicroTask 判断是否正在执行微任务更新机制。如果是,它会记录当前的时间戳 currentFlushTimestamp,并将该时间戳与事件的 timeStamp 属性进行比较。只有当事件的时间戳大于或等于这个记录的时间戳时,才会调用原始的事件处理函数。这样可以确保事件处理函数只在事件实际发生后才被触发,避免了微任务触发时的多次调用。

function add (
  name: string,
  handler: Function,
  capture: boolean,
  passive: boolean
) {
  if (isUsingMicroTask) {
    const attachedTimestamp = currentFlushTimestamp
    const original = handler
    handler = original._wrapper = function (e) {
      if (e.timeStamp >= attachedTimestamp) {
        return original.apply(this, arguments)
      }
    }
  }
  target.addEventListener(
    name,
    handler,
    supportsPassive
      ? { capture, passive }
      : capture
  )
}

前半生历程

从以上的源码变化,可以看出 Vue2 nextTick 的主要问题在于宏任务微任务的优先级问题。

  • 优先级问题:由于宏任务和微任务的优先级不同,某些情况下 nextTick 的执行可能不如预期,可能会影响复杂的 DOM 更新操作或用户交互响应。
  • 多重异步调度:如果多个 nextTick 被连续调用,可能导致多个回调堆积在微任务队列中,可能影响性能,尤其是在大规模更新时。

v3.0.0 - v3.5.13

文件位置packages\runtime-core\src\scheduler.ts

commit:因为从 v3.0.0 开始,nextTick 都是调度器 + Promise.then 微任务的思想,所以直接解读最新版本 v3.5.13 的源码。相关 commits 请自行查阅。

从文件名就可以看出,以上逻辑是任务调度器。

核心数据

  • queue:存储主任务队列,按优先级顺序执行。
  • pendingPostFlushCbs:存储“后置回调”,这些任务在主任务完成后执行。
  • activePostFlushCbs:当前正在执行的“后置回调”队列。
  • currentFlushPromise:当前调度器的 Promise,用于支持异步操作。

关键方法

  • queueJob(job: SchedulerJob): void

    • 功能:将任务加入主队列。主要为:watch$forceUpdate,组件的更新渲染,hmr 中强制父实例重新渲染。

    • 逻辑

      1. 检查任务是否已被加入队列 (QUEUED 标志)。
      2. 根据任务的 id 确定插入位置,确保队列按优先级排序。
      3. 添加 QUEUED 标志,调用 queueFlush 启动任务调度。
  • queueFlush(): void

    • 功能:启动任务调度器。

    • 逻辑

      1. 如果当前没有正在执行的调度器任务,创建一个基于 Promise 的微任务来执行 flushJobs
  • flushJobs(seen?: CountMap): void

    • 功能:执行主任务队列。

    • 逻辑

      1. 遍历队列,执行每个任务,同时处理递归任务和错误。
      2. 清理任务的 QUEUED 标志,并执行所有 pendingPostFlushCbs
      3. 如果在执行过程中有新任务加入队列,递归调用自身继续处理。
  • queuePostFlushCb(cb: SchedulerJobs): void

    • 功能:将任务加入“后置回调”队列。

    • 逻辑

      1. 支持单个或多个任务。
      2. 去重后将任务加入 pendingPostFlushCbs,调用 queueFlush 启动调度。
  • flushPostFlushCbs(seen?: CountMap): void

    • 功能:执行所有“后置回调”任务。多为 effect 等需要等 DOM 更新后再执行的任务和更新后脏数据的清理。

    • 逻辑

      1. pendingPostFlushCbs 去重并排序。
      2. 执行所有回调,确保任务不会递归触发自身。
      3. 清理执行状态。
  • flushPreFlushCbs(instance?: ComponentInternalInstance, seen?: CountMap, i: number = flushIndex + 1): void

    • 功能:执行“前置任务”。主要为:由于 props 变更触发的 watchwatchEffect 回调函数,以及 watch 中的 flush 不为 syncpost

    • 逻辑

      1. 从主队列中找到 PRE 标志任务,优先执行这些任务。
      2. 如果任务允许递归执行(ALLOW_RECURSE),保留其 QUEUED 状态。
  • findInsertionIndex(id: number): number

    • 功能:使用二分搜索找到任务在队列中的插入位置。保证队列中任务按 id 排序,以支持父组件任务优先于子组件任务执行。
  • checkRecursiveUpdates(seen: CountMap, fn: SchedulerJob): boolean

    • 功能:检测是否有任务递归触发自身。

    • 逻辑

      1. 使用 seen 记录任务的触发次数。
      2. 超过限制 (RECURSION_LIMIT) 时,抛出错误并阻止任务继续执行。

这也是整个 Vue3 的异步更新策略,当组件更新被触发(如响应式数据变更)时,更新任务被加入调度器队列(queue)。通过 queueFlush() 异步启动调度逻辑,调用 flushJobs 来清空 queue 队列,调用 flushPostFlushCbs 清空 pendingPostFlushCbs 队列。

  • 优先级管理:通过任务 id 和标志位管理任务的执行顺序。
  • 递归保护:防止任务无限递归触发。
  • 异步调度:通过 Promise 微任务确保任务在正确时机执行。
  • 分阶段执行:支持“前置任务”、“主任务”和“后置回调”分阶段执行。
  • 错误处理:为任务执行提供错误捕获和报告。

关于“我”

nextTick 返回一个基于 currentFlushPromise 或默认 resolvedPromise 的微任务 (Promise.then),即在当前事件循环的微任务队列中执行。而微任务的优先级高于事件循环中的宏任务,因此它可以保证在浏览器渲染前执行。而 currentFlushPromiseflushJobs 执行所有调度任务后的 Promise,即 nextTick 被放置在所有调度任务(包括 DOM 更新)执行完毕后,确保它获取的 DOM 信息是最新的。

c1bb1a02abc8401686878176be6007d5~tplv-dy-aweme-images-v2_3000_3000_q75.jpg

喜欢这篇文章的朋友不要忘了点赞收藏评论三连哦~