前言
不知道大家在平时用Vue进行开发的时候,是不是经常听到一句话: 遇事不决,$nextTick。
不得不说nextTick在有些时候确实是有奇效的,特别是前几年刚刚入行的时候,总会遇到一些奇奇怪怪的问题:
- 这里我明明赋值了啊,为什么没有拿到呢?
- 这里我明明改了的,怎么这个框框的大小就是拿不到呢?
然后就会去问一问度娘,突然就看到了nextTick这个关键词,反手一个CV,诶,还真好了,真不错!$nextTick真香!
这一节,我们就重点来从源码层面深入的看一下这个神奇nextTick到底是如何运行的。
nextTick
先看一下官网对它的介绍:
等待下一次 DOM 更新刷新的工具方法。
当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个tick才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。
nextTick()
可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。
示例:
<script>
import { nextTick } from 'vue'
export default {
data() {
return {
count: 0
}
},
methods: {
async increment() {
this.count++
// DOM 还未更新
console.log(document.getElementById('counter').textContent) // 0
await nextTick()
// DOM 此时已经更新
console.log(document.getElementById('counter').textContent) // 1
}
}
}
</script>
<template>
<button id="counter" @click="increment">{{ count }}</button>
</template>
上面官网的例子可以很清晰的表明,在函数中对某个状态进行更新的时候,我们在修改数据之后立马获取Dom是拿不到修改后的数据的,但是在nextTick之后,则可以拿到正确的Dom数据。
接下来,我们就来看一下nextTick的源码:
// Vue3这里不再进行向下兼容,直接使用了Promise来操作异步
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null
function nextTick( this: T,fn?: (this: T) => void
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
nextTick直接相关的源码就很简单,就是给传入nextTick中的回调函数包了一层,通过Promise.resolve,将回调函数fn放在了微任务队列,实现了fn的延迟执行,从而能够顺利的拿到Dom更新后的数据。
这里,我们可以猜测,既然放入微任务队列的fn执行时可以拿到Dom更新后的数据,那么很显然Dom更新的任务一定是在nextTick创建出来的微任务之前的,那具体是怎么一回事呢?这里我们就要接着去源码中找答案了。
setupRenderEffect
这里需要提一下用于更新组件的函数setupRenderEffect
:
setupRenderEffect函数中主要做的事情有:
- 定义组件更新函数
componentUpdateFn
,这个函数中会根据实例instance
是否挂载来进行不同的操作:如果组件还未挂载,则初始化组件;已经挂载,则在这个函数中进行更新组件操作。 - 然后将渲染函数componentUpdateFn包装成
effect副作用函数
,并将该副作用函数effect的执行方法run方法赋值给组件实例instance的update属性上,将组件uid作为update函数的id值(这里的id值主要用于排列effect的执行顺序)。 - 最后执行update方法,执行更新。
这里我们可以重点关注一下创建effect的逻辑。根据上面的代码,我们可以看到,在创建ReactiveEffect实例的时候除了传入了更新组件的函数之外,还传入了 () => queueJob(update)
作为第二个参数,而这第二个参数我们在ReactiveEffect中可以看到是作为实例的scheduler属性的。
class ReactiveEffect {
...
constructor(fn, scheduler = null, scope) {
this.fn = fn;
this.scheduler = scheduler;
this.active = true;
this.deps = [];
this.parent = void 0;
recordEffectScope(this, scope);
}
...
}
scheduler,翻译为调度程序
,我们也就可以猜测出,作为scheduler参数传入的函数大概率是跟任务排序相关的内容,我们再在源码中搜索scheduler看看还在哪里出现了呢?
function triggerEffect(
effect: ReactiveEffect,
debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
if (effect !== activeEffect || effect.allowRecurse) {
...
// 如果有scheduler则执行scheduler,否则执行run方法
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
triggerEffect函数我们在第一节有提到,主要功能是触发执行effect的函数。这里我们可以看到在执行前,会先判断effect上是否有scheduler属性,如果有则执行的是scheduler函数,没有则才会执行run方法。
queueJob
那至此,我们就明白了effect的执行机制,这会可以把目光转向queueJob:
// 任务队列
const queue = []
function queueJob(job) {
if (!queue.length || !queue.includes(job,isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)) {
if (job.id == null) {
// 将job维护在任务队列末尾
queue.push(job)
} else {
// 替换掉任务队列中从flushIndex + 1开始对应id的job
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
function queueFlush() {
// 如果任务队列的更新状态是还未开始更新
if (!isFlushing && !isFlushPending) {
// 将更新状态修改为pending
isFlushPending = true
// 将flushJobs维护进微任务队列
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
其中有几个变量这里解释一下:
queue
表示 维护的任务队列。job
表示 我们维护进队列中的任务,就像上面我们提到的update函数。isFlushing
表示 队列正处于更新状态中。allowRecurse
表示 允许递归调用。
queueJob
函数的整体逻辑:
- 首先,我们根据条件进行判断,如果任务队列
queue
为空或者在当前队列中无法搜索到job(这里会根据是否允许递归调用自身来决定查询的起始点),满足条件的任务job
才会被维护进queue
数组中。 - 然后,根据传入的
job
是否有id
,没有id
则将job
维护在队列末尾,如果有id
,则替换掉queue
中相应的job
。 - 最后,判断任务队列没有被执行的话,则将flushJobs函数添加到微任务中,并将该
Promise
赋值给currentFlushPromise。
flushJobs
接着,我们再看被推入微任务队列的这个flushJobs
函数具体是做什么的:
function flushJobs(seen) {
// 将更新状态修改为正在更新
isFlushPending = false;
isFlushing = true;
// 主要用于记录传入的job次数
seen = seen || /* @__PURE__ */ new Map();
//根据Job的id不同进行排序,如果相同则根据是否有pre属性进行排序
//(这里pre属性主要是在watch时会赋予)
queue.sort(comparator);
// checkRecursiveUpdates函数用于维护传入的job出现次数,上限为100次,超出则会在控制台中给出警告
const check = (job) => checkRecursiveUpdates(seen, job)
try {
// 遍历并执行任务队列中的每个job
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex];
if (job && job.active !== false) {
if (check(job)) {
continue;
}
callWithErrorHandling(job, null, 14 /* SCHEDULER */);
}
}
} finally {
// 任务队列执行完,重置任务队列索引
flushIndex = 0;
// 清空任务队列
queue.length = 0;
// 执行后置任务队列
flushPostFlushCbs(seen);
// 复原更新工作状态为false
isFlushing = false;
// 重置当前微任务变量字段为null
currentFlushPromise = null;
// 如果主任务队列、后置任务队列还有没被清空,就继续递归执行
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen);
}
}
}
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}
flushJobs
函数在做的事情注释里写的很清楚了,整体流程就是:
- 首先,修改状态,将任务队列
queue
进行排序。 - 然后执行任务队列
queue
中的每个任务job
。 - 最后,执行后置任务队列,重置相关变量及状态。
到这里,我们其实就可以发现,Vue3任务队列整体的更新策略就是:
- 首先,更新
job
中带有pre
属性的,这个是在queue.sort
排序的时候将带有pre
属性的job
排在前面体现。 - 然后,执行那些普通的
job
。 - 最后,在finally中会执行
flushPostFlushCbs
函数去处理后置任务队列中的job
。
总结
至此,我们大概就了解了Vue3中任务队列的运行机制。我们再来看nextTick:
在nextTick函数中,我们注意到有这样一行代码:
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
我们可以发现,nextTick
中的函数会在currentFlushPromise的then回调中
才会执行,换句话也就是说,这样就确保了currentFlushPromise中的内容是要比nextTick
中的函数先执行的。
然后,在组件更新函数setupRenderEffect
中,我们关注到这个函数把更新函数componentUpdateFn
包装为副作用函数effect并将effect的执行函数通过queueJob函数
维护进任务队列queue
。
最后在queueFlush
函数中,我们就发现了currentFlushPromise被赋值
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true;
currentFlushPromise = resolvedPromise.then(flushJobs);
}
}
这里将任务队列的执行函数包装为了一个Promise,并赋值给了currentFlushPromise。而这个任务队列中,就包含有组件更新函数。所以也就确保了,nextTick中的函数,一定是等待Dom更新完毕之后才去执行的,也就能拿到正确的Dom数据了。