在使用Vue开发的过程中,如果我们想在更新DOM结束之后执行回调,我们可以使用Vue的nextTick方法。然而在一次日常开发中,我却遇到了nextTick执行时无法获取到更新后的DOM的问题。下面是排查过程中的一些记录:
一个例子
下面是能够复现这个bug的简单demo:
<template>
<div v-if="isLoading" id="loading">加载中</div>
<div v-else id="app">
<ul>
<li v-for="item in list" :key="item.id" :id="'floor-' + item.id">floor: {{item.id}}</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
isLoading: true,
list: [],
id: 0
};
},
methods: {
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
},
genList(count) {
const result = [];
for (let i = 0; i < count; i++) {
result.push({ id: this.id++ });
}
return result;
},
getApp() {
return document.getElementById("app");
},
},
async mounted() {
// 模拟异步过程
await this.delay(200);
// 修改data
this.list = this.genList(100);
// 在nextTick中尝试获取更新后的列表元素
this.$nextTick(() => {
const floor = document.getElementById("floor-50");
console.log("nextTick1 before loading floor: ", floor);
});
this.isLoading = false;
}
};
</script>
this.list
是要渲染的列表,按理说修改了this.list
的值后马上调用nextTick,应该能在回调函数里获取到id为floor-50的dom元素。然而打印的结果却是null
,表明在回调函数执行时floor-50这个dom元素还没渲染出来,为什么会这样呢?
nextTick的文档
回头再看nextTick的文档说明: 在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
在上面的例子中,我们确实是在修改数据后立即调用了nextTick,但是所谓的下次DOM更新循环又是指什么时候呢?翻阅文档后没有找到对应的说明,我们只好去看看Vue源码里nextTick的实现方式了。
nextTick的作用
nextTick的源码实现在这里:vue/next-tick.js at dev · vuejs/vue · GitHub,总共只有100行左右,大部分都是兼容性代码。 简单来说,在主线程的执行过程中(一个tick内)注册的nextTick回调,会在tick结束后执行。那为什么在修改数据后立即使用nextTick就能在回调中获取到更新后的dom呢?原因就是Vue的更新dom的时候,也是在nextTick中执行的。
响应式更新的时机
对Vue响应式更新原理有所了解的同学都知道,在template中使用到的data会作为组件的依赖被收集起来,所以当我们对组件中响应的数据做了修改,会触发setter的逻辑,从而通知这个依赖的所有Watcher触发一次update。这个update走到最后会调用一个nextTick(flushSchedulerQueue)
,这里就是为什么我们能在nextTick访问到更新后的dom的关键:
当我们修改了data之后,nextTick callback:
this.list = this.genList(100);
// 理论上此时的nextTick callback队列: [flushSchedulerQueue]
然后我们通过nextTick注册回调,打印dom:
this.list = this.genList(100);
// 理论上此时的nextTick callback队列: [flushSchedulerQueue]
this.$nextTick(() => {
const floor = document.getElementById("floor-50");
console.log("nextTick1 before loading floor: ", floor);
});
// 理论上此时的nextTick callback队列: [flushSchedulerQueue, console]
可以看到,我们的console是在dom更新操作后执行的,因此应该能访问到更新后的dom。
出现bug的原因
按照上面的分析,似乎问题就出在this.list = this.genList(100)
这行代码执行后,Vue没有把flushSchedulerQueue
放进nextTick的队列里。再仔细看看例子里template的代码,我们发现了问题所在:
<template>
<div v-if="isLoading" id="loading">加载中</div>
<div v-else id="app">
<ul>
<li v-for="item in list" :key="item.id" :id="'floor-' + item.id">floor: {{item.id}}</li>
</ul>
</div>
</template>
由于使用了v-if,所以首次渲染时,该组件的依赖其实只有isLoading
。list
由于在v-else中,修改它并不会触发视图更新,所以例子中的代码执行后的nextTick callback队列如下:
this.list = this.genList(100);
// 此时的nextTick callback队列: []
this.$nextTick(() => {
const floor = document.getElementById("floor-50");
console.log("nextTick1 before loading floor: ", floor);
});
// 此时的nextTick callback队列: [console]
this.isLoading = false
// 此时的nextTick callback队列: [console, flushSchedulerQueue]
可以看到,console在flushSchedulerQueue之前执行,所以自然就获取不到更新后的dom元素了。
根据这个原理,如果我们想修复这个bug,其实有三种方式:
- 确保触发一个会更新dom的操作,如上述例子中提前执行this.isLoading=false,再调用nextTick
- 改为setTimeout, 走macroTask。macroTask一定在microTask之后执行,缺点就是setTimeout会带来一定的延时。
- v-if改v-show,从而使修改的data能触发视图更新
完整的demo在这里,感兴趣的小伙伴可以自己动手尝试一下:
https://codesandbox.io/s/nexttick-work-unexpected-td7t5codesandbox.io参考资料
[1] https://cn.vuejs.org/v2/api/#Vue-nextTick
[2] vue/next-tick.js at dev · vuejs/vue · GitHub
[3] 派发更新