Vue异步更新队列

异步更新队列指的是当状态发生变化时,Vue异步执行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
<div id="example">
<audio ref="audio" :src="url" controls="controls"></audio>
<div><button @click="changeUrl">Next</button></div>
</div>
const musicList = [
'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112003137.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112002493.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112004168.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3'
];
var vm = new Vue({
el: '#example',
data: {
index: 0,
url: ''
},
methods: {
changeUrl() {
this.index = (this.index + 1) % musicList.length
this.url = musicList[this.index];
this.$refs.audio.play();
}
}
});

当我们点击Next按钮后,并不能播放下一首音乐

1
Uncaught (in promise) DOMException: The element has no supported sources.

原因就在于audio.play()是同步的,而这个时候DOM更新是异步的,src属性还没有被更新,结果播放的时候src属性为空,就报错了。解决办法就是在play的操作加上this.$nextTick()。

1
2
3
this.$nextTick(function() {
this.$refs.audio.play();
});

在Vue的官方文档中有这样的说明:
可能你还没有注意到,Vue 异步执行 DOM 更新。只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。
之所以要采用异步方式更新DOM,是为了避免每次数据发生变化,就马上去重新渲染DOM,数据的状态可能会发生多次,只要计算最后一次状态的变化,这样可以大大减少性能的损耗。

Event Loop

Vue官方文档中提到在事件循环中执行DOM的刷新。我们先来了解下浏览器Event Loop机制。

浏览器Event Loop

javascript是一门单线程语言,JS的所有同步代码都在主线程(执行栈)中执行,当执行一个函数调用时,会创建一个新的执行环境并压到栈中开始执行函数中的代码,当函数中的代码执行完毕后将执行环境从栈中弹出,当栈空了,也就代表执行完毕。在主线程之外,还有一个任务队列(异步),事实上异步队列也分两种类型:微任务、宏任务。
浏览器Event Loop过程如图所示:
image

1
2
3
4
5
1. 浏览器中,先执行当前栈,执行完主线程中的任务。
2. 取出Microtask微任务队列中任务执行直到清空。
3. 取出Macrotask宏任务中 一个 任务执行。
4. 检查Microtask微任务中有没有任务,如果有任务执行直到清空。
5. 重复3和4。

属于微任务(microtask)的事件有以下几种:

1
2
3
4
1. Promise.then
2. MutationObserver
3. Object.observe(已废弃)
4. process.nextTick

属于宏任务(macrotask)的事件有以下几种:

1
2
3
4
5
6
7
1. setTimeout
2. setInterval
3. setImmediate
4. MessageChannel
5. requestAnimationFrame
6. I/O
7. UI交互事件

看下面的例子帮助我们理解这个过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
console.log(1)
// 栈
setTimeout(function(){
console.log(2)
// 微任务
Promise.resolve(100).then(function(){
console.log('promise')
})
})
// 栈
let promise = new Promise(function(resolve, reject){
console.log(7)
resolve(100)
}).then(function(data){
// 微任务
console.log(data)
})
// 栈
setTimeout(function(){
console.log(3)
})
//浏览器结果:1 7 5 100 2 promise 3

这是在浏览器中运行的结果。Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,所以js代码还能在Node中运行,此时Event Loop机制与浏览器略有不同。在这里我们暂时不探讨Node环境的机制。

Vue异步更新队列

vue异步更新队列事件循环的tick有可能是在当前的Tick微任务执行阶段执行,也可能是在下一个Tick执行,主要取决于nextTick函数到底是使用Promise/MutationObserver(现废弃采用MessageChannel)还是setTimeout。
在Vue 2.4之前的版本中,nextTick几乎都是基于microTask实现的,但是由于microTask的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,就会导致一些诡异的问题;但是如果全部都改成macroTask,对一些有重绘和动画的场景也会有性能的影响。所以最终nextTick采取的策略是默认走microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask。

应用场景

在操作DOM节点无效的时候,就要考虑操作的实际DOM节点是否存在,或者相应的DOM是否被更新完毕。比如说,在created钩子中涉及DOM节点的操作肯定是无效的,因为此时还没有完成相关DOM的挂载。解决的方法就是在nextTick函数中去处理DOM,这样才能保证DOM被成功挂载而有效操作。还有就是在数据变化之后要执行某个操作,而这个操作需要使用随数据改变而改变的DOM时,这个操作应该放进Vue.nextTick。