在我们使用Vue的过程中,基本大部分的 watcher
更新都需要经过 异步更新
的处理。而 nextTick
则是异步更新的核心。
官方对其的定义:
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
Vue 可以做到 数据驱动视图更新
,我们简单写一个案例实现下:
<template>
<h1 style="text-align:center" @click="handleCount">{{ value }}h1>
template>
<script>
export default {
data () {
return {
value: 0
}
},
methods: {
handleCount () {
for (let i = 0; i <= 10; i++) {
this.value = i
console.log(this.value)
}
}
}
}
script>
vue异步更新dom
当我们触发这个事件,视图中的 value
肯定会发生一些变化。
这里可以思考下,Vue是如何管理这个变化的过程呢?比如上面这个案例,value
被循环了10次,那 Vue 会去渲染dom视图10次吗?显然是不会的,毕竟这个性能代价太大了。其实我们只需要 value
最后一次的赋值。
实际上 Vue 是 异步更新 视图的,也就是说等 handleCount()
事件执行完,检查发现只需要更新 value
,然后再一次性更新数据和Dom,避免无效更新。
总之,Vue 的 数据更新 和 DOM更新 都是异步的,Vue 会将数据变更添加到队列中,在下一个事件循环中进行批量更新,然后异步地将变更应用于实际的 DOM 元素,以保持视图与数据的同步。
Vue官方文档也印证了我们的想法,如下:
Vue 在更新 DOM 时是
异步
执行的。只要侦听到数据变化
,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作是非常重要的。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
详细可见:Vue官方文档 - 异步更新队列
看例子,比如当 DOM 内容改变后,我们需要获取最新的元素高度。
<template>
<div>{{ name }}div>
template>
<script>
export default {
data () {
return {
name: ''
}
},
methods: {},
mounted () {
console.log(this.$el.clientHeight)
this.name = '铁锤妹妹'
console.log(this.name, 'name')
console.log(this.$el.clientHeight)
this.$nextTick(() => {
console.log(this.$el.clientHeight)
})
}
}
script>
从打印结果可以看出,name数据虽然更新了,但是前两次元素高度都是0,只有在 nextTick 中才能拿到更新后的 Dom 值,具体是什么原因呢?下面就分析下它的原理吧。
这个实例也可参考学习:watch监听和$nextTick结合使用处理数据渲染完成后的操作方法
在执行 this.name = '铁锤妹妹'
的时候,就会触发 Watcher
更新,watcher 会把自己放入一个队列。
// src/core/observer/watcher.ts
update () {
if (this.lazy) {
// 如果是计算属性
this.dirty = true
} else if (this.sync) {
// 如果要同步更新
this.run()
} else {
// 将 Watcher 对象添加到调度器队列中,以便在适当的时机执行其更新操作。
queueWatcher(this)
}
}
用队列的原因是比如多个数据变更,直接更新视图多次的话,性能就会降低,所以对视图更新做一个异步更新的队列,避免不必要的计算和 DOM 操作。在下一轮事件循环的时候,刷新队列并执行已去重的工作(nextTick的回调函数),组件重新渲染,更新视图。
然后调用 nextTick()
,响应式派发更新的源码如下:
// src/core/observer/scheduler.ts
export function queueWatcher(watcher: Watcher) {
// ...
// 因为每次派发更新都会引起渲染,所以把所有 watcher 都放到 nextTick 里调用
nextTick(flushSchedulerQueue)
}
function flushSchedulerQueue () {
queue.sort(sortCompareFn)
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
watcher.run()
// ...省略细节代码
}
}
这里参数 flushSchedulerQueue
方法就会被放入事件循环,主线程任务执行完之后就会执行这个函数,对 watcher 队列排序
、遍历
、执行 watcher 对应的 run()
方法,然后render,更新视图。
也就是说 this.name = '铁锤妹妹'
的时候,任务队列简单理解成这样 [flushSchedulerQueue]
。
下一行 console.log(this.name, 'name')
检验下 name 数据是否更新。
然后下一行 console.log(this.$el.clientHeight)
,由于更新视图任务 flushSchedulerQueue
在任务队列中还没有执行,所以无法拿到更新后的视图。
然后执行 this.$nextTick(fn)
时候,添加一个异步任务,这时任务队列简单理解成这样 [flushSchedulerQueue, fn]
。
然后 同步任务
都执行完毕,接着按顺序执行任务队列中的 异步任务
。第一个任务执行就会更新视图,后面自然能得到更新后的视图了。
主要判断用哪个宏任务或者微任务,因为宏任务耗费时间大于微任务,所以优先使用 微任务
,判断顺序如下:
Promise =》 MutationObserver =》 setImmediate =》 setTimeout
// src/core/util/next-tick.ts
export let isUsingMicroTask = false // 是否启用微任务开关
const callbacks: Array<Function> = [] //回调队列
let pending = false // 异步控制开关,标记是否正在执行回调函数
// 该方法负责执行队列中的全部回调
function flushCallbacks() {
// 重置异步开关
pending = false
// 防止nextTick里有nextTick出现的问题
// 所以执行之前先备份并清空回调队列
const copies = callbacks.slice(0)
callbacks.length = 0
// 执行任务队列
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// timerFunc就是nextTick传进来的回调等... 细节不展开
let timerFunc
// 判断当前环境是否支持原生 Promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {
const p = Promise.resolve()
timerFunc = () => {
p.then(flushCallbacks)
if (isIOS) setTimeout(noop)
}
isUsingMicroTask = true
} else if (
!isIE &&
typeof MutationObserver !== 'undefined' &&
(isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]')
) {
// 当原生 Promise 不可用时,timerFunc 使用原生 MutationObserver
// MutationObserver不要在意它的功能,其实就是个可以达到微任务效果的备胎
let counter = 1
const observer = new MutationObserver(flushCallbacks)
const textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
characterData: true
})
timerFunc = () => {
// 使用 MutationObserver
counter = (counter + 1) % 2
textNode.data = String(counter)
}
isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
// 使用setImmediate,虽然也是宏任务,但是比setTimeout更好
timerFunc = () => {
setImmediate(flushCallbacks)
}
} else {
// 最后的倔强,timerFunc 使用 setTimeout
timerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
然后进入核心的 nextTick。
这里代码不多,主要逻辑就是:
callbacks
。timeFunc
,就会遍历 callbacks
执行相应的回调函数了。export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
let _resolve
// 把回调函数放入回调队列
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e: any) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
if (!pending) {
// 如果异步开关是开的,就关上,表示正在执行回调函数,然后执行回调函数
pending = true
timerFunc()
}
// 如果没有提供回调,并且支持 Promise,就返回一个 Promise
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
可以看到最后有返回一个 Promise
是可以让我们在不传参的时候用的,如下
this.$nextTick().then(()=>{ ... })
- 在 vue 生命周期中,如果在
created()
钩子进行 DOM 操作,也一定要放在nextTick()
的回调函数中。- 因为在
created()
钩子函数中,页面的DOM
还未渲染
,这时候也没办法操作 DOM,所以,此时如果想要操作 DOM,必须将操作的代码放在nextTick()
的回调函数中
本文到此也就结束了,希望对大家有所帮助。