结合 vue3 源码探究 nextTick()

527 阅读3分钟

之前在 vue2 的项目中,如果遇到更改数据后界面没有按照预期的那样渲染更新,我就会尝试使用 this.$nextTick(),有时候能够解决问题,但有时候并没有用,这时候我就会想,nextTick() 背后的原理到底是什么?今天就让我们去看看 vue3(v3.2.37)中关于 nextTick() 的源码,来一探究竟。

在 vue3 中使用 nextTick()

先来看一个案例,开始时我们显示 num 为 0,之后点击按钮,让 num 的值为一个随机数,并且在控制台打印输出包裹 num 的 div 节点的文本内容:

<!-- 代码片段一 -->
<script setup>
import { ref } from 'vue'
let num = ref(0)
const divRef = ref()
const handle = () => {
  num.value = (10 * Math.random()).toFixed(2)
  console.log(divRef.value.textContent)
}
</script>

<template>
  <div ref="divRef">{{ num }}</div>
  <button @click="handle">按钮</button>
</template>

结果显示如下:

GIF 2022-11-2 14-48-35.gif

可以看到,控制台输出的结果都是上一次的 num 值。造成这一结果的原因,在官方文档中有如下解释(关于什么是“tick”,可以参见另一篇文章):

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。

也就是说,假使我们在代码片段一的 handle 方法里写了个 for 循环,让 num 的值改变 100 次,最终对于 dom 的更新也就只会执行一次,显然这样做有利于性能的提高。所以当我们改变 num.value 后直接 console.log(),它们是同步执行的, dom 还没有发生更新,所以打印的结果还是上一次执行 handle 得到的 num

如果我们想在每次点击按钮后,控制台能直接打印本次执行 handle 所修改得到的 num,就需要使用 nextTick()。从 vue 中获取到 nextTick方法,传入一个回调函数,将打印这一步写在回调函数中::

<!-- 代码片段二-->
<script setup>
import { ref, nextTick } from 'vue'
// ...省略
const handle = () => {
  num.value = (10 * Math.random()).toFixed(2)
  nextTick(() => {
    console.log(divRef.value.textContent)
  })
}
</script>

如此,便可确保打印输出的执行,是在 num 改变导致的 dom 更新完成之后。因为 nextTick() 的作用就是:

等待下一次 DOM 更新刷新的工具方法。

源码探究

现在我们来看看 vue3 源码中 nextTick() 到底是怎么定义的。 定义位置: packages\runtime-core\src\scheduler.ts

// 代码片段三
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
// ...
export function nextTick<T = void>(
  this: T,
  fn?: (this: T) => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

可以看到,在第 10 行,我们传给 nextTick() 的回调 fn,会被放到 p.then() 中执行,当 currentFlushPromisenull 时, p 就等于resolvedPromise,实际上就是 Promise.resolve()。所以我们可以认为,代码片段二中的 nextTick() 的返回值相当于下面的代码:

Promise.resolve().then(console.log(divRef.value.textContent))

Promise.resolve().then()里的回调,属于微任务,会被加入到微任务队列的后面,与当组件的状态更新后执行的一系列微任务一起依次执行。在定义 nextTick 的这个文件里,还定义了个 flushJobs 函数,就是执行这些微任务的:

// 代码片段四 packages\runtime-core\src\scheduler.ts
function flushJobs(seen?: CountMap) {
  // ...省略部分代码
  flushPreFlushCbs(seen)
  queue.sort((a, b) => getId(a) - getId(b))
  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job && job.active !== false) {
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {
    flushPostFlushCbs(seen)
  }
}

比如 watch 的回调函数,就是由 flushPreFlushCbs 执行。组件的更新是在 callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) 中执行,其实就是 queue 队列里的事件,queue.sort((a, b) => getId(a) - getId(b)) 就是为了给更新的组件做个排序,比如父组件要排在子组件前面更新。flushPostFlushCbs 则是执行比如生命周期函数 mounted 的回调,还有比如 watchEffect 默认的第二个参数 { flush: 'pre' },就代表着侦听器将在组件渲染之前执行。如果想在组件渲染之后执行,则需要将 flush 设置为 'post',相应的回调就会被放入到对应的队列由 flushPostFlushCbs 执行。

当这些微任务都执行完了,再来执行 nextTick 中的回调,自然就可以得到正确的输出了。

感谢.gif 点赞.png