Vue中nextTick的时序问题

2023-03-12,,

前言

Vue.$nextTick这个API相信很多人都用过,按照文档的解释,“在下次DOM更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的DOM”。我们通常会在使用第三方库或者处理复杂条件下的渲染时机的时候用到它,它是如此的好用以至于碰到棘手的问题的时候,我们都会想到是不是这简单的一行命令就可以解决问题呢?有时它确实奏效了,有时又没有,但在完全理解它之前,我建议还是对此保持谨慎。

看下面一段代码,猜猜点击div元素之后控制台会打印什么?

new Vue({
el: '#app',
template: '<div @click="handleClick">{{ msg }}</div>',
data () {
return {
msg: 1.0
}
},
methods: {
handleClick () {
this.msg = Math.random() // A
console.log('c1')
Promise.resolve().then(() => { // B
console.log('c2')
})
this.$nextTick(() => { // C
console.log('c3')
})
}
}
})

事实上如果是vue2.6.10版本,打印结果是c1 - c3 - c2,而如果是vue2.5.10版本,则结果是c1 - c2 - c3

那么,是什么导致了版本之间的差异呢?

差异原因分析

Vue内部存在着一个nextTick的“任务池”,里面包含着每次调用nextTick(callback)收集到的回调函数callback,在一个“合适的时机”,Vue清空任务池,依次触发收集到的回调函数。

上文中我们修改了Model层的数据,触发了reander watcher,Vue将执行一次重新渲染,但是这次重新渲染就是放在一个nextTick中去执行的,也就是说Vue没有即时地去更新View层,而是采用回调的方式进行;后面我们又用this.$nextTick函数往“任务池”中新加入一个回调,现在“任务池”中有两件任务了。

下面结合源代码看看:

// vue 2.6.10 部分源码
// 这个数组就是“任务池”
var callbacks = []
function nextTick (cb, ctx) {
var _resolve;
// nextTick就是往“任务池”中塞入了一个回调
callbacks.push(function () {
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
});
if (!pending) {
pending = true;
// 在“合适的时机”清空任务池的方法
timerFunc();
}
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve) {
_resolve = resolve;
})
}
}
...
// $nextTick 用法
// 1. this.$nextTick(function() { // do something })
// 2. this.$nextTick().then(function(ctx) { // do something })
Vue.prototype.$nextTick = function (fn) {
return nextTick(fn, this)
};

通过源码我们得知在“合适的时机”清空任务池的方法叫做TimerFunc,这个方法在时机选择上根据Vue版本有一些不同:

在Vue2.6.10版本,timerFunc的策略是优先在各处均使用微任务,即以Promise和MutationObserver为主,兼容宏任务setImmediate(仅IE和Edge兼容)和setTimeout。

在Vue2.5.10版本,Vue在dom事件中timerFunc优先使用了宏任务setImmediate和MessageChannel

下面来分析为什么会出现文初的差异:

在Vue2.6.10版本,handleClick触发后执行到A行,数据发生了变化,根据前文的描述,nextTick函数执行,此时TimerFunc执行,pending变为true,TimerFunc采用的是Promise,即Promise.resolve().then(flushCallbacks /* 清空任务池的方法 */),注册了一个微任务;接着执行到B行,它也注册了打印c2的Promise微任务,当然根据微任务执行规则,它是要晚于前一个微任务执行的;最后执行到C行,nextTick函数执行,它往任务池中塞入一个回调,然后到此为止了,因为此时pending是true,所以TimerFunc不重复执行。此时,微任务队列应该是这个样子的:

微任务1(flushCallbacks): 模板重新渲染 + 打印c3
微任务2(打印c2)

然后宏任务结束后微任务依次执行,顺序打印c3 -- c2

在vue2.5.10版本,handleClick触发后执行到A行,数据发生了变化,nextTick函数执行,但TimerFunc采用的是宏任务(为了方便我们假定所有浏览器均支持setImmediate),即setImmediate(flushCallbacks /* 清空任务池的方法 */),注册了一个宏任务;接着执行到B行,它注册了打印c2的Promise微任务;最后执行到C行,nextTick函数执行,往任务池中塞入一个回调,然后到此为止了。此时任务队列应该是这个样子的:

--- 当前宏任务
***
微任务1(打印c2) --- setImmediate宏任务,跟上面的宏任务没关系
flushCallbacks: 模板重新渲染 + 打印c3

根据以上分析,打印结果是c2 -- c3

那么怎么消除这个版本差异呢?其实改动一点代码即可

消除版本差异的方法

我们再看看nextTick的源码,发现如果不提供回调作为参数,则其会返回一个Promise,这个Promise将在callback执行时才resolve。

我们利用这种用法改写上面的代码:

handleClick () {
this.msg = Math.random()
console.log('c1')
Promise.resolve().then(() => {
console.log('c2')
})
// 换成这种写法
this.$nextTick().then(() => {
console.log('c3')
})
}

则两个版本均打印c1 -- c3 -- c2, 来分析下原因:

在vue2.6.10版本,执行到C行时nextTick任务池中加入的回调并没有直接打印c3,而是在它返回的Promise发生resolve后再把打印c3(then函数)加入微任务队列,此时微任务队列为:

微任务1(flushCallbacks): 模板重新渲染 + _resolve
微任务2(打印c2)
微任务3(打印c3) // 在微任务1中的_resolve调用后才被加入微任务队列中

在vue2.5.10版本,分析与上面类似,任务队列为:

--- 当前宏任务
***
微任务1(打印c2) --- setImmediate宏任务,跟上面的宏任务没关系
flushCallbacks: 模板重新渲染 + _resolve
***
微任务1(打印c3) // 在_resolve调用后才被加入微任务队列中

代码像这样改动,就保证了vue两个版本回调执行顺序的同一性,但是从任务队列可见回调的执行时机大不相同,前者是在同一个微任务队列中依次执行,而后者采用的宏任务模式显得更加复杂。js是单线程的,两个宏任务的执行间隔取决于任务耗时、浏览器策略等;至于各种宏任务之间调用的优先级和区别,就是另外一个问题了。

最后简单谈谈为什么vue源码对任务队列的执行方案从宏任务迁移到了微任务,其实这两种方案均存在一些问题,具体问题可以查看源码的注释,找到对应的issue进行分析。不同于React的Fiber架构,Vue并未在任务调度方面做得太多,最近发布的vue3.0beta版本仍然采用的是微任务方案。

Vue中nextTick的时序问题的相关教程结束。

《Vue中nextTick的时序问题.doc》

下载本文的Word格式文档,以方便收藏与打印。