(注意 文章很长,请耐心看完)
一 虚拟dom
在react,vue等技术出现之前,我们要改变页面展示的内容只能通过遍历查询 dom 树的方式找到需要修改的 dom 然后修改样式行为或者结构,来达到更新 ui 的目的。
这种方式相当消耗计算资源,因为每次查询 dom 几乎都需要遍历整颗 dom 树,
并且这个过程又会不可避免的出现大量DOM操作,而DOM的变化又会引发整个文档重排或重绘,从而降低页面渲染性能
此时就产生一个问题,有没有可能我们只更新我们修改的那一部分dom而不要更新去整个dom结构
此时虚拟DOM应运而生 ,如果建立一个与 dom 树对应的虚拟 dom 对象( js 对象),以对象嵌套的方式来表示 dom 树,那么每次 dom 的更改就变成了 js 对象的属性的更改,这样一来查找 js 对象的属性变化就要比查询 dom 树的性能开销小。
例如,真实dom是这样的
<div>
<p>123</p>
</div>
那用javascript对象来描述就是这样
var Vnode = {
tag: 'div',
children: [
{ tag: 'p', text: '123' }
]
};
具体流程
- 首先vue会利用javascript对象结构来表示一个dom树的的结构,也就是Vnode虚拟dom,然后根据这个虚拟dom构建一个真正的dom树,并绘制到文档当中
- 然后当原有的虚拟dom(OldVnode)的数据发生改变时,它就会重新创建一个新虚拟dom树(Vnode),然后用新的虚拟dom(Vnode)和旧的虚拟dom(OldVnode)进行比较(这个过程就叫diff),同时记录这两棵虚拟dom树的差异。
- 然后在把这个差异应用到真正的dom之上(patch),视图就完成更新了
- 而这个差异比较就是通过diff算法来实现的。
二 diff具体实现
第一步
首先vue会运行一个patch方法,来判断两个节点Vnode和OldNode是否值得比较
function patch (oldVnode, vnode) {
// 判断是否值得比较
if (sameVnode(oldVnode, vnode)) {
// 值得比较则进入patchVnode
patchVnode(oldVnode, vnode)
} else {
// 不值得比较则直接替换掉原来的oldVnode
const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
let parentEle = api.parentNode(oEl) // 父元素
createEle(vnode) // 根据Vnode生成新元素
if (parentEle !== null) {
api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
api.removeChild(parentEle, oldVnode.el) // 移除以前的旧元素节点
oldVnode = null
}
}
return vnode
}
function sameVnode(a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data , data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
首先patch方法会接收两个参数,oldVnode和Vnode ,分别代表旧的虚拟dom,和新的虚拟dom,然后通过sameVnode方法进行判断,如果两个节点是一样的,就会进入patchVnode方法去深入比较它们的子节点之间的差异,如果不一样说明Vnode完全将oldVnode改变了,此时就直接替换掉oldVnode即可。
第二步
当两个节点值得比较时会进入patchVnode方法,在这个方法中对他们的子节点进行比较
patchVnode(oldVnode, vnode) {
// 这里el就
const el = vnode.el = oldVnode.el
let i, oldCh = oldVnode.children , ch = vnode.children
if (oldVnode === vnode) return
if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
api.setTextContent(el, vnode.text)
} else {
updateEle(el, vnode, oldVnode)
if (oldCh && ch && oldCh !== ch) {
updateChildren(el, oldCh, ch)
} else if (ch) {
createEle(vnode) //create el's children dom
} else if (oldCh) {
api.removeChildren(el)
}
}
}
此时 在这个方法中它会多次判断
- 首先它会找到对应的真实dom,赋值为el
- 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return即可,如果为一个对象就不需要比了
- 如果他们都有文本节点并且不相等,那么将el的文本节点改为为Vnode的文本节点。
- 如果oldVnode有子节点而Vnode没有,则删除el的子节点
- 如果oldVnode没有子节点而Vnode有,则直接将Vnode的子节点更新到真实dom(el)之上
- 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要 ,此时,在这一步会进入 updateChildren 方法,在这里做更加细致的比较,这就到了diff的核心部分了
第三步
进入 updateChildren 方法,进行核心diff比较
updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0, newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx
let idxInOld
let elmToMove
let before
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 对于vnode.key的比较,会把oldVnode = null
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) {
patchVnode(oldStartVnode, newEndVnode)
api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 使用key时的比较
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]
if (!idxInOld) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
newStartVnode = newCh[++newStartIdx]
} else {
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
} else {
patchVnode(elmToMove, newStartVnode)
oldCh[idxInOld] = null
api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
}
newStartVnode = newCh[++newStartIdx]
}
}
}
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
这部分代码比较多,我们先重点关注开头的几个变量声明的部分,我对他们做了注释
let oldStartIdx = 0 // 旧节点开始下标
let oldEndIdx = oldCh.length - 1 // 旧节点结束下标
let oldStartVnode = oldCh[0] // 旧节点开始vnode
let oldEndVnode = oldCh[oldEndIdx] // 旧节点结束vnode
let newStartIdx = 0 // 新节点开始下标
let newEndIdx = newCh.length - 1 // 新节点结束下标
let newStartVnode = newCh[0] // 新节点开始vnode
let newEndVnode = newCh[newEndIdx] // 新节点结束vnode
这部分主要做了这些事:
它会将Vnode的子节点vChildren(下面统称Vch) 和oldVnode的子节点oldChildren(下面统称oldCh) 提取出来,并分别给他们赋予两个属性startIndex 和 endIndex,分别代表节点的开始位置下标和结束位置下标,也就是头尾变量。
然后diff会对它们的两个变量所在的节点互相比较,一共有四种比较方式:
(下面oldStartIdx用oldS代表,newStartIdx 用newS代表)
(下面oldEndIdx 用oldE代表,newEndIdx 用newE代表)
1 oldS和newS进行比较
2 oldE和newE进行比较
3 oldS和newE进行比较
4 oldE和newS进行比较
一共是这4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较(这里不懂没关系,看下面的图示就明白了)。
还需要注意两个原则
第一点 先处理头尾节点相同的
- 在比较的时候会先处理旧头和新头相同的节点,以及旧尾和新尾相同的节点
- **即:oldS == newS的节点, **oldE == newE的节点
- 其次处理旧头和新尾以及旧尾和新头相同的节点
- 即:oldS == newE的节点,oldE == newS的节点
第二点 节点复用原则
如果oldS和newS相同,或者oldE和newE节点相同时(这里的相同指的是只要节点类型相同即可,引用地址不同没关系,例如oldS是div类型,newS也是div类型),则不会进行位置调换,只会直接复用该节点,然后更新节点内容 ,这样可以节省调换节点时所产生的内存消耗。
下面请看图例
**第一排是真实dom,第二排是旧虚拟dom(oldVnode)此时旧虚拟dom和真实dom的顺序结构还是一样的,第三排是更新后的新虚拟dom(Vnode)。 **下面我们来演示diff算法。
1 首先第一步,先比较旧头和新头相同的节点。
此时oldS和newS它们本就相同,因此不需调换,将newS的内容更新到真实dom的A节点即可,然后oldS和newS向后移动一位。
比较后的结构图如下:
2 第二步 oldS和newS比较完了,然后再比较旧尾和新尾,即oldE和newE
oldE和newE也相同,因此也不需要做调换操作,将newE所在的节点内容更新到对应的真实dom的K节点上就行。然后oldE和newE向前移动一位
比较后的结构图如下:
3 第三步,然后开始比较旧头和新尾,即oldS和newE。
此时oldS和newS相同,则将真实dom的oldS所在的节点B移动到newE所在节点的位置,
移动完成之后oldS向后移动一位,newE向前移动一位,
比较后的结构图:
注意这个时候真实dom中的节点B已经移动了
4 第四步,之后再比较旧尾和新头,即oldE和newS,
oldE和newS相同,则将真实dom中oldE对应的节点 I 移动到newS所在的节点位置,移动完成之后oldE往前移动一位,newS往后移动一位,
此时结构图如下
5 第五步,此时看newS所在的节点L,此时L很明显是新增的,oldVnode中没有这个节点,那么便直接将L添加到真实dom中newS所在的位置
添加完成后newS往后移动一位
此时结构图如下
6 第六步
此时newS所在的节点是G,那G节点oldVnode中显然是有的,但此时oldS和oldE并没有指向它,因此直接在真实dom中将G节点移动到newS所在的位置,
但由于oldS和oldE此时都没有指向G,
因此在G节点做个标志,(在真实代码中这里它会采用undefined来标明,将此处设置为undefined)
标明这个节点已经处理过了,同时在将newS向后移动一位。
此时结构图如下
7 第七步,
此时经过我们发现,oldS和newS又相同了,那么相同就不用调换,将newS所在的节点C的内容更新到真实dom的c节点上即可,然后oldS和newS直接往后移动一位即可。
移动后的结果图如下
8 第八步
移动之后我们发现oldS和newS还是相同,那么在此重复上述操作。oldS和newS再往后移动一位
移动后的结构图如下
移动后我们发现他们还是一样的,因此再次重复上述操作,oldS和newS再往后移动一位
emmm......,此时移动后的位置的节点仍然是一样的,但此时我们注意,这个时候newE和newS已经碰头了,然后再次重复上述操作,oldS和newS再往后移动一位
9 第九步
此时,到了最后一步了,这个时候newS已经超过newE了
我们上面说过 一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较 ,注意这个时候newE的下标是8,newS的下标是9。
这个时候newStartIdx大于newEndidx了,说明vCh已经先比较完,那么这个时候比较就会结束。
既然Vch都比较完了,oldCh的starIdx和endIdx还没碰头,此时就说明,oldS和oldE之间的节点都是不匹配的,可以直接将真实dom中对应的节点删除,但G节点我们之前已经做过标记了,表明该节点已经处理过了,因此只删除H节点就行
好,到此时真实dom和Vnode中的节点已经完全一模一样了,
因此最后再将Vnode赋值给oldVnode
oldVndoe = Vnode,此时oldVnode再次和真实dom保持一致了,然后再次等待下一次的数据更新,然后再次进行差异比较。
到此,整个diff比较就已完成了,这里还有一点需要注意,我上述最后演示的Vch即新虚拟dom先比较完,如果最后是oldCh先比较的话,则直接将Vnode剩余的节点插入到真实dom当中对应的位置即可,就类似第五步一样,因为oldCh先比较完则说明剩下的Vch节点都是新增的节点,因此参考第五步,直接插入进去即可。
三 Vue中的Key
这里可能会有同学问? Vue里面diff比较时不是还有个key吗,这个key是干什么用的?
先说一个概念:
Vue中的key是vnode 的唯一标记,它就是为了更高效的更新虚拟dom,可以使虚拟dom的更新变得更加准确和快速。
打个比方,现在有一组节点,这组节点都是div类型的,它们的内容分别是A,B,C,D,E
现在我要在这组节点A和B之间插入个新的div节点,内容为0,我们上面讲过一个节点复用原则,如果节点类型相同,则直接复用该节点,然后更新它们的内容即可。
那么按照这个原则,现在他们的类型都是div,那正常情况下它会这么插入
1-首先A节点仍然是A节点,
2-但是B节点就不同,它会复用B节点,然后将B节点的内容改为0
3-之后再复用C节点,将C节点的内容改为B,然后复用D节点,将D节点的内容改为C,这样以此类推,直至最后插入E节点。
这种做法在列表内容不多的情况下没有问题,但如果列表内容很多,那这么做明显速度会比较慢。
这个时候key的作用就出来了:
在设置了key时,它会对比key,如果key相同,说明这两个节点不仅类型相同,内容也相同,也就是说它们完全是一个节点,此时它就会完全复用该节点,连内容也跟着复用,也就是不对这个节点做任何变化,然后直接在指定位置插入新节点,那么这个时候插入的速度就快多了。
总结一下,插值时:
(1)不加key:会复用dom但是会更新dom属性;
(2)加key:会完全复用之前的dom及其属性,因为添加了唯一标识的缘故可以准确定位到要插入dom节点的位置;
\