Vue使用异步更新队列
DOM的异步更新
异步更新队列指的是当状态发生变化时,Vue异步执行DOM更新。
我们在项目开发中会遇到这样一种场景:当我们将状态改变之后想获取更新后的DOM,往往我们获取到的DOM是更新前的旧DOM,我们需要使用vm.$nextTick方法异步获取DOM,例如:
1 | Vue.component('example', { |
我们都知道这样做很麻烦,但为什么Vue还要这样做呢?
首先我们假设Vue是同步执行DOM更新,会有什么问题?
如果同步更新DOM将会有这样一个问题,我们在代码中同步更新数据N次,DOM也会更新N次,伪代码如下:
1 | this.message = '更新完成' // DOM更新一次 |
但事实上,我们真正想要的其实只是最后一次更新而已,也就是说前三次DOM更新都是可以省略的,我们只需要等所有状态都修改好了之后再进行渲染就可以减少一些无用功。
而这种无用功在Vue2.0开始变得更为重要,Vue2.0开始引入了Virtualdom,每一次状态发生变化后,状态变化的信号会发送给组件,组件内部使用VirtualDOM进行计算得出需要更新的具体的DOM节点,然后对DOM进行更新操作,每次更新状态后的渲染过程需要更多的计算,而这种无用功也将浪费更多的性能,所以异步渲染变得更加至关重要。
组件内部使用VIrtualDOM进行渲染,也就是说,组件内部其实是不关心哪个状态发生了变化,它只需要计算一次就可以得知哪些节点需要更新。也就是说,如果更改了N个状态,其实只需要发送一个信号就可以将DOM更新到最新。例如:
1 | this.message = '更新完成' |
代码中我们分三次修改了三种状态,但其实Vue只会渲染一次。因为VIrtualDOM只需要一次就可以将整个组件的DOM更新到最新,它根本不会关心这个更新的信号到底是从哪个具体的状态发出来的。
那如何才能将渲染操作推迟到所有状态都修改完毕呢?很简单,只需要将渲染操作推迟到本轮事件循环的最后或者下一轮事件循环。也就是说,只需要在本轮事件循环的最后,等前面更新状态的语句都执行完之后,执行一次渲染操作,它就可以无视前面各种更新状态的语法,无论前面写了多少条更新状态的语句,只在最后渲染一次就可以了。
将渲染推迟到本轮事件循环的最后执行渲染的时机会比推迟到下一轮快很多,所以Vue优先将渲染操作推迟到本轮事件循环的最后,如果执行环境不支持会降级到下一轮。
当然,Vue的变化侦测机制决定了它必然会在每次状态发生变化时都会发出渲染的信号,但Vue会在收到信号之后检查队列中是否已经存在这个任务,保证队列中不会有重复。如果队列中不存在则将渲染操作添加到队列中。
之后通过异步的方式延迟执行队列中的所有渲染的操作并清空队列,当同一轮事件循环中反复修改状态时,并不会反复向队列中添加相同的渲染操作。
所以我们在使用Vue时,修改状态后更新DOM都是异步的。
Watcher队列
update
在Watcher的源码中,我们发现watcher的update其实是异步的。(注:sync属性默认为false,也就是异步)
1 | update () { |
queueWatcher
queueWatcher(this)函数的代码如下:
1 | /*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/ |
这段源码有几个需要注意的地方:
- 首先需要知道的是
watcher执行update的时候,默认情况下肯定是异步的,它会做以下的两件事:- 判断
has数组中是否有这个watcher的id - 如果有的话是不需要把
watcher加入queue中的,否则不做任何处理。
- 判断
- 这里面的
nextTick(flushSchedulerQueue)中,flushScheduleQueue函数的作用主要是执行视图更新的操作,它会把queue中所有的watcher取出来并执行相应的视图更新。 - 核心其实是
nextTick函数了,下面我们具体看一下nextTick到底有什么用。
nextTick
nextTick函数其实做了两件事情,一是生成一个timerFunc,把回调作为microTask或macroTask参与到事件循环中来。二是把回调函数放入一个callbacks队列,等待适当的时机执行。(这个时机和timerFunc不同的实现有关)
首先我们先来看它是怎么生成一个timerFunc把回调作为microTask或macroTask的。
1 | if (typeof Promise !== 'undefined' && isNative(Promise)) { |
值得注意的是,它会按照Promise、MutationObserver、setTimeout优先级去调用传入的回调函数。前两者会生成一个microTask任务,而后者会生成一个macroTask。(微任务和宏任务)
之所以会设置这样的优先级,主要是考虑到浏览器之间的兼容性(IE没有内置Promise)。另外,设置Promise最优先是因为Promise.resolve().then回调函数属于一个微任务,浏览器在一个Tick中执行完macroTask后会清空当前Tick所有的microTask再进行UI渲染,把DOM更新的操作放在Tick执行microTask的阶段来完成,相比使用setTimeout生成的一个macroTask会少一次UI的渲染。
而nextTickHandler函数,其实才是我们真正要执行的函数。
1 | function nextTickHandler () { |
这里的callbacks变量供nextTickHandler消费。而前面我们所说的nextTick函数第二点功能中“等待适当的时机执行”,其实就是因为timerFunc的实现方式有差异,如果是Promise\MutationObserver则nextTickHandler回调是一个microTask,它会在当前Tick的末尾来执行。如果是setTiemout则nextTickHandler回调是一个macroTask,它会在下一个Tick来执行。
还有就是callbacks中的成员是如何被push进来的?从源码中我们可以知道,nextTick是一个自执行的函数,一旦执行是return了一个queueNextTick,所以我们在调用nextTick其实就是在调用queueNextTick这个函数。它的源代码如下:
1 | return function queueNextTick (cb?: Function, ctx?: Object) { |
可以看到,一旦调用nextTick函数时候,传入的function就会被存放到callbacks闭包中,然后这个callbacks由nextTickHandler消费,而nextTickHandler的执行时间又是由timerFunc来决定。
回看Watcher这里面的nextTick(flushSchedulerQueue)中的flushSchedulerQueue函数其实就是watcher的视图更新。调用的时候会把它push到callbacks中来异步执行。
另外,关于waiting变量,这是很重要的一个标志位,它保证flushSchedulerQueue回调只允许被置入callbacks一次。
也就是说,默认waiting变量为false,执行一次后waiting为true,后续的this.xxx不会再次触发nextTick的执行,而是把this.xxx相对应的watcher推入flushSchedulerQueue的queue队列中。
所以,也就是说DOM确实是异步更新,但是具体是在下一个Tick更新还是在当前Tick执行microTask的时候更新,具体要看nextTcik的实现方式,也就是具体跑的是Promise/MutationObserver还是setTimeout。
为什么要异步更新
1 | <template> |
1 | export default { |
现在有这样的一种情况,mounted的时候test的值会被++循环执行1000次。 每次++时,都会根据响应式触发setter->Dep->Watcher->update->run。 如果这时候没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。 所以Vue实现了一个queue队列,在下一个Tick(或者是当前Tick的微任务阶段)的时候会统一执行queue中Watcher的run。同时,拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。 保证更新视图操作DOM的动作是在当前栈执行完以后下一个Tick(或者是当前Tick的微任务阶段)的时候调用,大大优化了性能。