Vue2.0源码之虚拟dom和diff算法的原理
一.虚拟dom介绍
-
现在使用的Vue和React,都有虚拟dom这个说法,既然是虚拟dom,肯定和真实dom有区别但又有关联。所以虚拟dom是什么呢?
-
虚拟dom就是真实dom的所映射的一个JS对象。简单来说就是把页面的dom节点树通过JS对象的形式来表示出来。
-
为什么要用虚拟dom的方式来设计Vue框架呢?因为不管在原生JS或者是JQuery时,我们会在开发中操作大量的dom节点,dom的大量操作会让页面的性能降低。所以虚拟dom就是为了让dom的操作尽可能的减少。
虚拟dom有点像JSX语法,下面的举个简单的例子:
<!-- 真实dom对应的标签 -->
<div>
<span>文字</span>
</div>
//JS映射的虚拟dom
let vdom = {
tag:'div',
children:[
{
tag:"span",
text:'文字'
}
]
}
- 通过这种虚拟dom对象,来比对需要更新的真实dom。最后一次性修改需要更新的dom,并在真实dom中进行排版与重绘,减少过多dom节点排版与重绘损耗。可以理解为按需更新。
- 怎样通过虚拟dom来实现真实dom的按需更新,就需要用到大名鼎鼎的 diff 算法了。
二.diff算法
vue中diff的流程:当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。
看到这么一句话,说的非常贴切:
diff的过程就是调用名为patch的函数,比较(sameVnode)新旧节点,值得比较就打补丁(patch),不值得比较就直接替换
diff算法是通过节点的逐层比较,降低了算法的复杂度。若使用递归比较,算法复杂度呈指数级的上升,效率很低。
1.比较不同节点(不值得比较)
通过sameVnode函数来判断节点是否值得比较,如果发现新旧两个节点类型不同时,diff算法会直接删除旧的节点及其子节点并插入新的节点,这是由于前面提出的不同组件产生的dom结构一般是不同的,所以可以不用浪费时间去比较。注意的是,删除节点意味着彻底销毁该节点,并不会将该节点去与后面的节点相比较。
2.比较相同节点(值得比较)
若是两个节点类型相同时,则认为节点是值得比较的。
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
}
patchVnode函数的逻辑:
- 找到对应的真实 dom,称为 el
- 判断 vnode 和 oldVnode 是否指向同一个对象,如果是,那么直接 return
- 如果他们都有文本节点并且不相等,那么将 el 的文本节点设置为 vnode 的文本节点。
- 如果 oldVnode 有子节点而 vnode 没有,则删除 el 的子节点
- 如果 oldVnode 没有子节点而 vnode 有,则将 vnode 的子节点真实化之后添加到 el
- 如果两者都有子节点,则执行 updateChildren 函数比较子节点,这一步很复杂也很重要
3.updateChildren比较子节点
updateChildren方法的代码比较多:
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)
}
}
函数做的事情:
-
将Vnode的子节点Vch和oldVnode的子节点oldCh提取出来
-
oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的 2 个变量相互比较,一共有 4 种比较方式。如果 4 种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。
具体的比较过程没时间写了… 看到这有兴趣的可以去看看具体过程
下面附上一张广为流传的vue的diff算法过程图: