面试官:Vue的nextTick是怎么监听DOM树更新完毕的?
nextTick是全局vue的一个函数,在vue系统中,用于处理dom更新的操作。vue里面有一个watcher,用于观察数据的变化,然后更新dom,vue里面并不是每次数据改变都会触发更新dom,而是将这些操作都缓存在一个队列,在一个事件循环结束之后,刷新队列,统一执行dom更新操作。
通常情况下,我们不需要关心这个问题,而如果想在DOM状态更新后做点什么,则需要用到nextTick。在vue生命周期的created()钩子函数进行的DOM操作要放在Vue.nextTick()的回调函数中,因为created()钩子函数执行的时候DOM并未进行任何渲染,而此时进行DOM操作是徒劳的,所以此处一定要将DOM操作的JS代码放进Vue.nextTick()的回调函数中。而与之对应的mounted钩子函数,该钩子函数执行时所有的DOM挂载和渲染都已完成,此时该钩子函数进行任何DOM操作都不会有个问题。
Vue.nextTick(callback)
,当数据发生变化,更新后执行回调。
Vue.$nextTick(callback)
,当dom发生变化,更新后执行的回调。
废话少说,来看一个例子1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26<div id="app">
<span id='name' ref='name'>{{ name }}</span>
<button @click='change'>change name</button>
<div id='content'></div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
name: 'SHERlocked93'
}
},
methods: {
change() {
const $name = this.$refs.name
this.$nextTick(() => console.log('setter前:' + $name.innerHTML))
this.name = ' name改喽 '
console.log('同步方式:' + this.$refs.name.innerHTML)
setTimeout(() => this.console("setTimeout方式:" + this.$refs.name.innerHTML))
this.$nextTick(() => console.log('setter后:' + $name.innerHTML))
this.$nextTick().then(() => console.log('Promise方式:' + $name.innerHTML))
}
}
})
</script>
执行以下看看结果:1
2
3
4
5
6
同步方式:SHERlocked93
setter前:SHERlocked93
setter后:name改喽
Promise方式:name改喽
setTimeout方式:name改喽
再看一段代码:
1 | <template> |
打印的结果是start,为什么明明已经将text设置成了“end”,获取真实DOM节点的innerText却没有得到我们预期中的“end”,而是得到之前的值“start”呢?
源码解读
带着这个疑问,我们找到了Vue.js源码的Watch实现。当某个响应式数据发生变化的时候,它的setter函数会通知闭包中的Dep,Dep则会调用它管理的所有Watch对象。触发Watch对象的update实现。我们来看一下update的实现。
watcher
1 | /* |
我们发现Vue.js默认是使用异步执行DOM更新。
当异步执行update的时候,会调用queueWatcher函数。
1 | /** |
查看queueWatcher的源码我们发现,Watch对象并不是立即更新视图,而是被push进了一个队列queue,此时状态处于waiting的状态,这时候继续会有Watch对象被push进这个队列queue,等待下一个tick时,这些Watch对象才会被遍历取出,更新视图。同时,id重复的Watcher不会被多次加入到queue中去,因为在最终渲染时,我们只需要关心数据的最终结果。
flushSchedulerQueue
1 | vue/src/core/observer/scheduler.js |
1 | /** |
flushSchedulerQueue是下一个tick时的回调函数,主要目的是执行Watcher的run函数,用来更新视图
nextTick
vue.js提供了一个nextTick函数,其实也就是上面调用的nextTick。
nextTick的实现比较简单,执行的目的是在microtask或者task中推入一个funtion,在当前栈执行完毕(也行还会有一些排在前面的需要执行的任务)以后执行nextTick传入的funtion。
网上很多文章讨论的nextTick实现是2.4版本以下的实现,2.5以上版本对于nextTick的内部实现进行了大量的修改,看一下源码:
首先是从Vue 2.5+开始,抽出来了一个单独的文件next-tick.js来执行它。1
vue/src/core/util/next-tick.js
1 | /* |
MessageChannel VS setTimeout
为什么要优先MessageChannel创建macroTask而不是setTimeout?
HTML5中规定setTimeout的最小时间延迟是4ms,也就是说理想环境下异步回调最快也是4ms才能触发。
Vue使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel的延迟明显是小于setTimeout的。
说了这么多,到底什么是macrotasks,什么是microtasks呢?
两者的具体实现
macrotasks:
setTimeout ,setInterval, setImmediate,requestAnimationFrame, I/O ,UI渲染
microtasks:
Promise, process.nextTick, Object.observe, MutationObserver
1.在 macrotask 队列中执行最早的那个 task ,然后移出
2.再执行 microtask 队列中所有可用的任务,然后移出
3.下一个循环,执行下一个 macrotask 中的任务 (再跳到第2步)
那我们上面提到的任务队列到底是什么呢?跟macrotasks和microtasks有什么联系呢?
• 一个事件循环有一个或者多个任务队列;
• 每个事件循环都有一个microtask队列;
• macrotask队列就是我们常说的任务队列,microtask队列不是任务队列;
• 一个任务可以被放入到macrotask队列,也可以放入microtask队列;
• 当一个任务被放入microtask或者macrotask队列后,准备工作就已经结束,这时候可以开始执行任务了。
可见,setTimeout和Promises不是同一类的任务,处理方式应该会有区别,具体的处理方式有什么不同呢?
通俗的解释一下,microtasks的作用是用来调度应在当前执行的脚本执行结束后立即执行的任务。 例如响应事件、或者异步操作,以避免付出额外的一个task的费用。
microtask会在两种情况下执行:
任务队列(macrotask = task queue)回调后执行,前提条件是当前没有其他执行中的代码。
每个task末尾执行。
另外在处理microtask期间,如果有新添加的microtasks,也会被添加到队列的末尾并执行。
也就是说执行顺序是:
开始 -> 取task queue第一个task执行 -> 取microtask全部任务依次执行 -> 取task queue下一个任务执行 -> 再次取出microtask全部任务执行 -> … 这样循环往复
Promise一旦状态置为完成态,便为其回调(.then内的函数)安排一个microtask。
接下来我们看回我们上面的代码:
1 | setTimeout(function(){ |
按照上面的规则重新分析一遍:
当运行到setTimeout时,会把setTimeout的回调函数console.log(1)放到任务队列里去,然后继续向下执行。
接下来会遇到一个Promise。首先执行打印console.log(2),然后执行for循环,即时for循环要累加到10万,也是在执行栈里面,等待for循环执行完毕以后,将Promise的状态从fulfilled切换到resolve,随后把要执行的回调函数,也就是then里面的console.log(4)推到microtask里面去。接下来马上执行马上console.log(3)。
然后出Promise,还剩一个同步的console.log(5),直接打印。这样第一轮下来,已经依次打印了2,3,5。
现在第一轮任务队列已经执行完毕,没有正在执行的代码。符合上面讲的microtask执行条件,因此会将microtask中的任务优先执行,因此执行console.log(4)
最后还剩macrotask里的setTimeout放入的函数console.log(1)最后执行。
如此分析输出顺序是:
1 | 2 |
我们再来看一个:
当一个程序有:setTimeout, setInterval ,setImmediate, I/O, UI渲染,Promise ,process.nextTick, Object.observe, MutationObserver的时候:
1.先执行 macrotasks:I/O -》 UI渲染
2.再执行 microtasks :process.nextTick -》 Promise -》MutationObserver ->Object.observe
3.再把setTimeout setInterval setImmediate 塞入一个新的macrotasks,依次: setTimeout ,setInterval –》setImmediate
综上,nextTick的目的就是产生一个回调函数加入task或者microtask中,当前栈执行完以后(可能中间还有别的排在前面的函数)调用该回调函数,起到了异步触发(即下一个tick时触发)的目的。
1 | setImmediate(function(){ |
使用了nextTick异步更新视图有什么好处呢?
接下来我们看一下一个Demo:
1 | <template> |
现在有这样的一种情况,created的时候test的值会被++循环执行1000次。
每次++时,都会根据响应式触发setter->Dep->Watcher->update->patch。
如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。
所以Vue.js实现了一个queue队列,在下一个tick的时候会统一执行queue中Watcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。
保证更新视图操作DOM的动作是在当前栈执行完以后下一个tick的时候调用,大大优化了性能。