虚拟DOM和真实DOM
相信大家都知道虚拟DOM和真实DOM的区别吧,简而言之就是虚拟DOM是一个人工创造的JS的对象,而真实DOM是document原生对象。我只言片语说不清,大家各自百度一下吧。
虚拟DOM的渲染和真实DOM的渲染不是在同一周期中,当然真实DOM渲染之前还有很多步骤不在实际考虑范围之内,我们只需要单独抽象出虚拟DOM和真实DOM的渲染步骤进行比较。
好就算你没看懂,那么也没关系,我将用几个实际例子和图文为你讲解。
Vue-Update
首先我们先了解一下事件循环和DOM渲染的关系,如果不了解微任务和宏任务关系建议还是先行百度。
先科普几个概念:
我们已经知道他们的执行顺序是: 微任务 - DOM渲染 - 宏任务。
而Vue的beforeUpdate() 、 update() 、 updated() 、nextTick() 都属于微任务。
真实DOM的更新和渲染是两个步骤!
虚拟DOM不存在渲染的概念,虚拟DOM只有更新步骤。
每当进行响应式数据发生变化,Vue就会调用update函数来对虚拟DOM树进行更新,当虚拟DOM树更新完毕后,会更新真实DOM树,但是浏览器不会立刻进行真实DOM渲染,而是得先清空微任务队列。
好,那么确认在清空微任务队列之后后面的问题就不在本篇文章所套路的范围之内,那么我们将所有问题聚焦到微任务队列中。
微任务队列中的顺序问题
首先这里先声明几个概念:
1. 微任务队列按照代码从上至下的逻辑顺序依次将任务插入微任务队列。
2. vue中数据变化会立刻将beforeUpdate() 、update() 、updated() 插入到微任务队列。
这里需要将第2点单独说明一下,这些步骤都在微任务队列中,说明是异步执行的任务,因此我们需要区分一点是,当代码改变数据时(如 this.name = ‘xxx’ ),不会立刻同步执行虚拟DOM更新操作,但是会立刻将更新的微任务全部按顺序插入微任务队列。下面的例子中会重点讲到。
3. nextTick() 不讲武德,直接插队在updated() 之后,即在所有更新任务完成后立刻执行。
好,下面开始我们的例子。
<template>
<div class="about">
<header>
<p ref="name">{{name}}</p>
<button @click="click">点击</button>
</header>
</div>
</template>
<script>
export default {
data(){
return {
name:"没变"
}
},
beforeUpdate(){
console.log("---beforeUpdate")
},
updated(){
console.log("---Updated")
},
methods:{
click(){
// Promise1 (对应图中callBack1)
new Promise(resolve=>resolve()).then(()=>console.log("Promise1:"+this.$refs.name.innerHTML))
// 数据改变准备更新 (对应图中第二行代码)
this.name = "改变";
// 同步打印虚拟dom
console.log(this.$refs.name)
// 同步打印dom的文本
console.log(this.$refs.name.innerHTML)
// Promise2 (对应图中callBack2)
new Promise(resolve=>resolve()).then(()=>console.log("Promise2:"+this.$refs.name.innerHTML))
// nextTick (对应图中nextTickCallBack)
this.$nextTick(()=>console.log("nextTick:"+this.$refs.name.innerHTML))
// 宏任务
setTimeout(()=>console.log("-----------\n宏任务:"+this.$refs.name.innerHTML),0)
}
}
}
</script>
结果
1.首先执行同步代码,先打印出dom节点,顺序上没什么问题,但是这里的内容是“改变”,比较特殊,这里下面会讲解。
2.打印出下一个同步代码,dom文本为“没变”因为没有执行更新操作,自然是没变。
3.执行第一个微任务Promise1 ,因为是先插入微任务队列的,自然优先执行。
4.依次执行 update 的三个周期,打印 beforeUpdate 和 updated。
5.执行 nextTick() ,nextTick代码在Promise2 下面,但是通过插队插入微任务队列。
6.执行 Promise2 , 本来顺序插入微任务队列的,但是前面插入了一个nextTick() 。
7.微任务执行完毕,执行宏任务。
证实了我的观点。
可以发现,在执行update()的三个周期 之前,所有的内容都是 “没变” ,但是更新之后,所有内容都发生了改变。但是有一个特例,第1个打印结果为 “改变”。
那么我们来讨论一下 this.$refs 的 DOM 是什么。
$refs 的DOM节点
首先抛出结论,就是虚拟DOM。
因此我们通过$refs 获取和操作的对象实际上都是vue 的 虚拟DOM 对象,而非真实DOM对象。
因此在update周期执行前,我们通过 $refs获取到的数据都是没有发生变化的。
并且,在更新操作执行之后、微任务队列执行结束之前,我们通过 $refs获取到的虚拟DOM对象 或是 document.getElementBy … 这些方法获取真实DOM对象,都是更新之后的节点,因为虚拟DOM树更新的同时,就会立刻更新真实DOM树,因为更新虚拟DOM树和真实DOM树是同时进行的,为了节省DOM树更新次数,这里就引出Vue的打包更新的知识了,我这里篇幅优先就不详解,具体百度。但还是注意真实DOM此时只更新还没渲染!
现在我们改写上面的例子来体会一下,通过在最后一个微任务执行之后执行 alert 阻塞DOM渲染,让我们看看,真实DOM渲染之前的打印结果。
<template>
<div class="about">
<header>
<p ref="name">{{name}}</p>
<button @click="click">点击</button>
</header>
</div>
</template>
<script>
export default {
data(){
return {
name:"没变"
}
},
beforeUpdate(){
console.log("---beforeUpdate")
},
updated(){
console.log("---Updated")
// 查看虚拟DOM树更新完成后,真实DOM树是否同时变化。
console.log("Updated:"+document.getElementsByTagName("p")[0].innerHTML)
},
methods:{
click(){
new Promise(resolve=>resolve()).then(()=>console.log("Promise1:"+this.$refs.name.innerHTML))
this.name = "改变";
console.log(this.$refs.name)
console.log(this.$refs.name.innerHTML)
//因为Promise2 是最后一个微任务,所以它执行完毕,同时打印真实DOM节点和其内容,并通过alert阻塞DOM渲染
new Promise(resolve=>resolve()).then(()=>{
console.log(document.getElementsByTagName("p")[0])
console.log("Promise2:"+document.getElementsByTagName("p")[0].innerHTML)
alert("STOP!")
})
this.$nextTick(()=>console.log("nextTick:"+this.$refs.name.innerHTML))
setTimeout(()=>console.log("-----------\n宏任务:"+this.$refs.name.innerHTML),0)
}
}
}
</script>
结果
- 第一次打印结果(真实DOM节点)不见了
- 2345行和原来一样
- 第6行update之后打印出的真实DOM节点内容 为 “改变” ,这里证实update之后,真实DOM树的确同步更新。
- 第7行相同,第8行尝试打印真实DOM节点 再次失败 , 第8行相同。
- alert弹出,发现界面中按钮上面的文字为 “没变” ,说明真实DOM还没渲染,我们通过getElementBy… 获取到的真实DOM节点的内容为更新之后的节点。
这里没有打印的真实DOM节点现在我认为只有一种可能,它对应的是 渲染之后的真实DOM节点 ,这是浏览器打印的特点,并非说明此时真实DOM没有更新,因为我们可以获取它的 innerHTML 内容。在update周期执行之前,innerHTML为 “没变”;update周期执行之后,innerHTML为 “改变”。
因此在浏览器打印真实DOM节点,在细分到步骤的过程中,不具有参考价值。因此这里也回答了之前为什么同步执行代码中打印的DOM节点中,显示的内容却是已改变后的结果的原因。
真实案例
这里不得不讲一下我为什么突然开始研究的这么深入了呢,那还不是平时遇到了坑,虽然没有深入到底层,而是随便拼运气解决了,但回想起来,这个问题带给了我很多的反思。
我有一个项目中,一个播放器<audio ref = ‘audio’ :src=‘url’>, src 是和 computed 中的数据绑定的。
我点击一个按钮,操作数据发生变化,导致computed返回的数据发生变化。
然后我下一步直接进行 this.$refs.audio.play().然后直接报错
这里可以看出,我先打印了当前 this. $refs.audio 对象,可以看出它的src 是正常的,我点击也可以正常跳转播放,但是直接play()却死活不能播放,还有报错
" The element has no supported sources."
反正就是不能正常播放,我思来想去几个小时,进行各种调试,但都没发现问题,既然问题已经缩小到这了,audio有src,src能播放,但是audio.play( ) 就是不能播放。然后我尝试了将
this.$refs.audio.play()
改成了
this.$nextTick(()=>this.$refs.audio.play())
问题解决了。
之前完全凭运气,但是现在终于理解了。
-
浏览器打印的DOM节点是DOM渲染完成之后的,不具有参考价值。
也就是此时浏览器真实的src其实是null 。 -
在数据修改后,要异步执行audio.play() ,因为数据修改之后还没执行update操作,如果强行同步执行audio.play(),此时src为null,自然会报错。因此只要在更新之后,即 this.$nextTick 中,执行该操作,就不会报错。
当然还可以在修改响应式数据之后 new Promise(…).then(…) 来执行,因为这个微任务会插入到更新微任务之后。
另外还可以通过setTimeout() 等宏任务来执行该操作,也是稳稳在DOM更新之后。