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
的微任务阶段)的时候调用,大大优化了性能。