结合 vue3 源码探究 nextTick()
之前在 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>
结果显示如下:
可以看到,控制台输出的结果都是上一次的 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()
中执行,当 currentFlushPromise
为 null
时, 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
中的回调,自然就可以得到正确的输出了。