目录
- 前言
- 一、Vue异步更新队列
- 二、nextTick 用法
- 三、原理分析
- 四、nextTick 源码解析
- 1)环境判断
- 2)nextTick()
- 五、补充
前言
在我们使用Vue的过程中,基本大部分的 watcher
更新都需要经过 异步更新
的处理。而 nextTick
则是异步更新的核心。
官方对其的定义:
在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。
一、Vue异步更新队列
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 = iconsole.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官方文档 - 异步更新队列
二、nextTick 用法
看例子,比如当 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.tsexport 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]
。
然后 同步任务
都执行完毕,接着按顺序执行任务队列中的 异步任务
。第一个任务执行就会更新视图,后面自然能得到更新后的视图了。
四、nextTick 源码解析
1)环境判断
主要判断用哪个宏任务或者微任务,因为宏任务耗费时间大于微任务,所以优先使用 微任务
,判断顺序如下:
Promise =》 MutationObserver =》 setImmediate =》 setTimeout
// src/core/util/next-tick.tsexport 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.xMutationObserver.toString() === '[object MutationObserverConstructor]')
) {// 当原生 Promise 不可用时,timerFunc 使用原生 MutationObserver// MutationObserver不要在意它的功能,其实就是个可以达到微任务效果的备胎let counter = 1const observer = new MutationObserver(flushCallbacks)const textNode = document.createTextNode(String(counter))observer.observe(textNode, {characterData: true})timerFunc = () => {// 使用 MutationObservercounter = (counter + 1) % 2textNode.data = String(counter)}isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {// 使用setImmediate,虽然也是宏任务,但是比setTimeout更好timerFunc = () => {setImmediate(flushCallbacks)}
} else {// 最后的倔强,timerFunc 使用 setTimeouttimerFunc = () => {setTimeout(flushCallbacks, 0)}
}
然后进入核心的 nextTick。
2)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 = truetimerFunc()}// 如果没有提供回调,并且支持 Promise,就返回一个 Promiseif (!cb && typeof Promise !== 'undefined') {return new Promise(resolve => {_resolve = resolve})}
}
可以看到最后有返回一个 Promise
是可以让我们在不传参的时候用的,如下
this.$nextTick().then(()=>{ ... })
五、补充
- 在 vue 生命周期中,如果在
created()
钩子进行 DOM 操作,也一定要放在nextTick()
的回调函数中。- 因为在
created()
钩子函数中,页面的DOM
还未渲染
,这时候也没办法操作 DOM,所以,此时如果想要操作 DOM,必须将操作的代码放在nextTick()
的回调函数中
本文到此也就结束了,希望对大家有所帮助。