在此,我准备先简单谈谈什么是dom和virtual dom,有助于后面diff算法的理解。
一. 那么什么是DOM? 在实际情况下,用户输入想要访问的网址在通过DNS解析后得到服务器地址,浏览器向服务器发起http请求,再经过tcp三次握手确认连接后服务器将需要的代码发回给浏览器,浏览器在接收代码后进行解析。
其中主要步骤:1.DOM构造 2.布局 3.渲染呈现
(在这之中也包括执行脚本js内容,但脚本可能需要访问或操作之前的HTML或样式,所以要等到先前的CSS节点构建完成)
1.DOM构造:由html解析器构建DOM树,DOM树是由浏览器由上到下,从左到右读取标签,把他们分解成节点,从而创建的;而DOM其实就是文档对象模型,HTML的文档document页面是一切的基础,没有它dom就无从谈起,当创建好一个页面并加载到浏览器时,DOM就悄然而生,它会把网页文档转换为一个文档对象,主要功能是处理网页内容。
之后再由css代码通过css解析器,将这些样式绑定到对应DOM树节点上。
2.布局:确定页面上所有元素的大小和位置;但并没有在页面可视化。
3.渲染呈现:最后将所有页面绘制出来,完成整个页面的渲染,将可视化页面展现给用户。
当我们为修改页面展示的内容的时候,通过操作真实的DOM,但DOM操作的执行速度和js的速度有差异,js远比DOM操作的执行快
并且dom 操作也会引起浏览器的回流和重绘,并且随着移动端的发展手机的参数都不同,也会引发性能的问题。
(回流:当页面中的元素的大小或是位置等发生改变,浏览器会根据改变对页面的结构重新计算
(重绘:当页面中元素的背景,颜色改变引发浏览器对元素重新描绘。)
虚拟DOM就是为了解决浏览器性能问题而出现,用js对象模拟DOM节点,把一些DOM操作保存到一个js对象,在改变dom之前,会先比较相应虚拟dom的数据,如果需要改变,才会将改变应用到真实dom上。
简单概括有三点:
1.用JavaScript模拟DOM树,并渲染这个DOM树。
2.比较新老DOM树,得到比较的差异对象。
3.把差异对象应用到渲染的DOM树。
首先diff算法并非vue专用,其他的一些框架都在用,凡是涉及虚拟DOM的多半都要用到diff算法。比如我们已经由html解析器构建DOM树,我们再根据真实的DOM生成一颗virtual DOM,当virtual DOM某个节点的数据改变后,期间就会进行新旧节点的对比,而这个对比的过程就是diff算法的一个过程。
对于diff算法的特点用以下图片可以很好的解释:
对于虚拟DOM是有父与子的区分,如果把父节点和孩子节点对比是没有意义的,所以diff过程整体策略:深度优先,同层比较。
在如上图所示先从最上面的节点比较,在比较根节点的时候做的第一件事是先判断新旧两个节点有没有子节点,都有孩子则比较他们的孩子,进入孩子层级,若发现又有孩子则一直往下找孩子,如上图直接进入到第三层级,当发现往下都没有孩子时,则进入同层比较,同时在左侧两个橙色框内的孩子比较完后,也会返回上一级再按这种方式进行比较。
在源码中patchVnode是diff发生的地方,下面是patchVnode的源码,看其中比较的过程代码:
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
/*两个VNode节点相同则直接返回*/
if (oldVnode === vnode) {
return
}
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))) {
vnode.elm = oldVnode.elm
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
//下面进行比较的代码
//判断是否元素
if (isUndef(vnode.text)) {
//判断双方都有孩子
if (isDef(oldCh) && isDef(ch)) {
/*新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren*/
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
/*如果老节点没有子节点而新节点存在子节点,先清空elm的文本内容,然后为当前节点加入子节点*/
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
/*当新节点没有子节点而老节点有子节点的时候,则移除所有ele的子节点*/
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
/*当新老节点都无子节点的时候,只是文本的替换,因为这个逻辑中新节点text没有,所以直接去除ele的文本*/
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
/*当新老节点text不一样时,直接替换这段文本*/
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
总得来说两个节点的比较策略就是:先看看双方是否都有孩子,如果都有则比较孩子;如果新节点有,旧节点没有孩子则是一种处理;旧节点有,新节点没有孩子是另一种处理,都没有则又是一种处理。
不仅仅是同层级对比,在diff算法中也会进行同key值对比和同组件对比。
对于同key值对比我举个例子说明:
p1
p2
var vm = new Vue({
el:"#box",
data:{
show:true
}
})
上面的例子中实现在点击按钮后可以实现给两个p标签显示和隐藏,放在transition(vue内置组件,name是获取过渡效果,其CSS代码就不放上了)组件内可以给两个标签在显示隐藏时有动画过渡效果。
那么话说回来动画会如我们预料那样显示吗?
显然不,因为在这里两个p标签同层级,diff算法会对两个标签进行对比,应为标签名相同会复用只修改内容,所以点击按钮并不会触发过渡效果,所以最终效果只会来回覆盖内容而不会有过渡效果。(有兴趣的可以测一测)
但如下图给他们两个不一样的key值后,又因为key值一样尽管是同标签但也会进行对比,动画效果又会出现。
p1
p2
当然在这里也可以把其中的一个p标签换成一个其他标签比如div标签那么动画也会会出现,因为前文已经说明同层级比较,标签不一致直接创建新的不复用。
在同层级对比是说到diff算法源码中的patchVnode方法,但其中对于孩子节点如何进行比较在于updateChildren的这个方法,这里我结合着key值作用来谈下。
由于updateChildren的源代码太长就不列出(有兴趣的可以去看),我简述下我的理解:在同层孩子比较是会进行首尾两侧的相同节点的猜测和判断,其中涉及到四种情况的判断,判断完后如果在首尾都没有找到相同节点,则会遍历查询,拿出新节点中的当前首个到旧节点中去尝试查找,找到了做相对应的操作,如果没有找到会认为是新增节点。最后操作完后,可能会有剩下节点,最后再处理剩下节点。
下面我在拿个都举过的例子结合着key值的作用说明下:
如当我们想在a,b,c,d,e这四个节点的d之前c之后插入一个z节点。
如上图所示,当我们不是给于key值的时候,由于没有key值不知道当前具体在更新谁,因此做的操作就是见到谁就更新谁。具体过程:先更新a,b,c(这三个的跟新同有key值更新操作一样),但因为没有key值到d的时候不清楚是不是自己,但只能认为是自己,所以只好覆盖更新再把后的e更新到d,更新完后再创建新的追加到后面。在这个过程中进行了五次更新操作,一次追加操作。
那么上图是使用key值的情况,前三个一样不再说,到d和z比较时发现不一样,由于源码中首尾判断假猜策略发现尾部的e和d对应相同,就从后开始更新,到最后只剩下z,再最后创建新的z追加到相应位置(c的后面)。在这个过程中也进行了五次更新操作,一次追加操作。
那么照操作次数来看难道就说明有不管有没有key值都是干着同一样的事吗?但其实并不是这样,主要的不是更新操作次数,而是更新到底有没有发生,虽然在有key值下尝试去更新5次,但实际上并未进行任何操作,因为前面5次都在更新完全相同的5个结点,实际上只有最后的一次创建操作。但不使用key值,则如上图d-z,e-d发生新旧节点更新。
简单总结下:
Vue的diff位于patch.js文件中,diff的过程就是调用patch函数,就像打补丁一样修改真实dom所以说key的作用主要是为了高效的更新虚拟DOM。
在patch的执行过程中当两个节点值得比较时,会调用patchVnode,在这里我们主要谈比较子节点,在比较子节点的时候就会调用其中的updateChildren方法,会去更新两个新旧的子元素,在这个过程中就可以通过key值来精确地判断两个节点是否为同一个,从而避免频繁更新不同元素,减少不必要的DOM操作,提高性能。
同时vue中在使用同标签名元素的过渡切换是也需要加key属性,让他们区分开来,否则如上过渡例子所说的只会替换内容,而不会发送过渡效果。内部优化的算法猜测首位结构的相似性(在新老两个VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。)
最后的总结:
1.diff算法是虚拟DOM的必然, 通过新旧虚拟DOM作对比,将变化的地方更新在真实的DOM上。
2.因为新旧DOM作对比,所以我们也需要diff算法的高效性两个树在比较的过程中如果用树形结构去比较,时间复杂度是O(n^3),为了降低整个复杂度引入diff算法使时间复杂度到O(n);
3.个人认为这些东西都是为了保证一件事,让页面的这些节点,能复用的尽量服用,减少重新创建的次数,减少对DOM的频繁操作。
(如果有理解有误的地方,欢迎指出)