虚拟dom是当前前端最流行的两个框架(vue和react)都用到的一种技术,都说他能帮助vue和react提升渲染性能,提升用户体验。那么今天我们来详细看看虚拟dom到底是个什么鬼
大家一定要记住的一点就是,虚拟dom就是一个普通的js对象。是一个用来描述真实dom结构的js对象,因为他不是真实dom,所以才叫虚拟dom。


1、我们都知道,传统dom数据发送变化的时候,我们都需要不断的去操作dom,才能更新dom的数据,虽然后面出现了模板引擎这种东西,可以让我们一次性去更新多个dom。但模板引擎依旧没有一种可以追踪状态的机制,当引擎内某个数据发生变化时,他依然要操作dom去重新渲染整个引擎。
而虚拟dom可以很好的跟踪当前dom状态,因为他会根据当前数据生成一个描述当前dom结构的虚拟dom,然后数据发送变化时,又会生成一个新的虚拟dom,而这两个虚拟dom恰恰保存了变化前后的状态。然后通过diff算法,计算出两个前后两个虚拟dom之间的差异,得出一个更新的最优方法(哪些发生改变,就更新哪些)。可以很明显的提升渲染效率以及用户体验
2、因为虚拟dom是一个普通的javascript对象,故他不单单只能允许在浏览器端,渲染出来的虚拟dom可同时在node环境下或者weex的app环境下允许。有很好的跨端性
diff算法就是用于比较新旧两个虚拟dom之间差异的一种算法。具体详情,后面我们会说
目前虚拟dom的类库有多种,常见的有snabbdom和virtual-dom, vue以前用的是virtual-dom,从2.x版本后都是使用的snabbdom。(snabbdom源码下载) 今天,我们就通过snabbdom源码来解析vue的虚拟dom
首先我们看下snabbdom源码结构。

要搞清楚vue虚拟dom,我们就需要搞清楚几个核心的方法
这几个核心函数的源码,看着可能会比较累,我就不一一对源码做详细的介绍,我主要会介绍每个函数主要做了什么事情,然后后面再附上源码,会加点注释,看的懂得可以详细看看
h函数,看着是不是很眼熟? 他是在vue的什么阶段去调用的?

眼熟吧,是不是在这地方看过啊。没错,h函数就是在render函数内运行的。我们在前面vue生命周期的文章中就提过,vue在created–>beforeMount之间的时候会将模板编译成render函数,其实就是将模板编译成某种格式放在render函数内,然后当render函数运行得时候,就会生成虚拟dom。那么编译成什么格式呢。就是编译成h函数所认可的格式。那么我们来看看h函数需要什么格式

有的人可能会说,唉,这个h函数怎么定义了多个啊。没错,h函数是使用函数重载的方式定义的,那么什么是函数重载
函数重载就是定义多个重名函数,利用函数的参数个数以及参数类型来区分。当参数个数不同,参数类型不同时,函数内执行的代码也会相应不同。
下面,我们就来看下最典型得一种,也就是图中得第四种。
第一个参数sel 表示dom选择器,如: div#app.wrap ==》
第二个参数表示dom属性,是个对象如:{ class: ‘ipt’, value: ‘今天天气很好’ }
第三个参数表示子节点,子节点也可以是一个子虚拟节点,也可以是文本节点
const vdom = h(‘div’, { class: ‘vdom’}, [
h(‘p’, { class: ‘text’}, [‘hello word’]),
h(‘input’, { class: ‘ipt’, value: ‘今天星期二’ })
]) // 模板就是会编译成这种格式
console.log(vdom)
而h函数内最主要得就是执行了 vnode函数,vnode函数得主要作用就是将h函数传进来得参数转行为了js对象(即虚拟dom)

而vnode函数,我就不多说了,没几句代码,也很简单,反正就是执行了生成js对象(虚拟dom)的代码。直接上图

看到现在,我们心里应该要清楚虚拟dom是怎么生成的,什么时候生成的。如果不清楚,那么请往上滑,再看一遍,哈哈。下面我们总结下虚拟dom生成的过程。
1、diff 比较两个虚拟dom只会在同层级之间进行比较,不会跨层级进行比较。而用来判断是否是同层级的标准就是


不过,既然话都说到他的比较顺序了,我就想干脆,先整体将他每一步是如何比较的,让大家心里有一个总体的比较思路后,我们再去一步一步看patch函数,patchVnode函数和updateChildren函数
首先开始比较两个vdom时,这两个vdom肯定是都有各自的根节点的,且根节点必定是一个元素,不可能存在多个。我们首先要比较的肯定是根节点,那我们都知道根节点只有一个,就可以直接比较了。而一个节点的比较,通常分为3个部分
声明,下面所说的sel选择器相同,指的是标签名,id,class都相同。
例如’这样一个dom,他的sel是"div#app.abc"
可能大家看的有点懵,没关系,看完心里有个大概的步骤就好,下面我们再来详细讲每一步对应的函数
上面我们说了,patch是比较的开始,相当于是diff的入口,diff就是从这一步开始的。那么既然是开始,说明patch函数比较的肯定就是两个新旧vdom的根节点了。所以,两个vdom直接的比较,patch是只会触发一次的。
作用:比较两个虚拟dom根节点是否相同。下面我们看下主要的核心代码

patchVnode 是用于比较两个相同节点的子级(文本,或子节点)的一个函数。故它的调用总是在sameVnode判断之后。只有判断当前比较的两个vnode相同时(这里我最后再解释一次,两个vnode相同仅仅代表key相同且sel选择器相同),才会被执行。
但,在比对之前,会先判断下oldVnode === vNode ,因为如果全等,代表子级肯定也完全相等,那么就没必要对比了,直接return;

作用:对比新旧两个节点,更新dom的子级(子级包含文本或者是子节点)
对比过程:
1、如果新vnode有text属性

2、如果新vnode没有text属性
如果新vnode和旧vnode都存在子节点时。是不是要深度对比两个vnode的子节点啊。这个时候会进入第三步,比较子节点(执行updateChildren)

如果只有新vnode有子节点,老vnode没有,那么很简单,执行添加节点的操作

如果只有旧vnode有子节点,新vnode没有子节点,很明显,要执行删除旧vnode子节点的操作

如果两个vnode上都没有子节点。但旧节点有text,那么很简单,说明原来有文本,现在没有了,清空vnode对应dom的text
下面,我们看下整体代码

终于到这最复杂的一步了。首先,我先说一下这一步的作用以及具体做了些什么
作用:用于比较新旧两个vnode的子节点
那具体做了什么,怎么比较的。
比较规则
声明:下文中所指的匹配上,指的就是判断是否是sameVnode,即上文中所说的,key相同,sel选择器相同
下面,我们通过图例来进一步理解

上图表示的就是oldCh 和 Ch。那么怎么来比较。我们一一来看每一种情况











总体过程就是这样,然后不停的patchVnode,updateChildren循环递归下去,知道oldVode和newVnode所有节点都对比完成。下面附上updateChildren函数源码(每一步都添加了详细注释)
function updateChildren (parentElm: Node,oldCh: VNode[],newCh: VNode[],insertedVnodeQueue: VNodeQueue) {
let oldStartIdx = 0
let 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: KeyToIndexMap | undefined
let idxInOld: number
let elmToMove: VNode
let before: any
// 通过while不断循环进行对比,直到oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) { // 这一步的操作是始终保证oldStartVnode为oldStartIdx指针所指向的那个节点
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) { // 这一步的操作是始终保证oldEndVnode为oldEndIdx指针所指向的那个节点
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) { // 这一步的操作是始终保证newStartVnode为newStartIdx指针所指向的那个节点
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) { // 这一步的操作是始终保证newEndVnode为newEndIdx指针所指向的那个节点
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) { // oldStart和newStart相同时,执行patchVnode进一步比较
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx] // 并将指针往中间移动
newStartVnode = newCh[++newStartIdx] // 并将指针往中间移动
} else if (sameVnode(oldEndVnode, newEndVnode)) { // oldEnd和newEnd相同时,执行patchVnode进一步比较
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx] // 并将指针往中间移动
newEndVnode = newCh[--newEndIdx] // 并将指针往中间移动
} else if (sameVnode(oldStartVnode, newEndVnode)) { // oldStart和newEnd相同时,执行patchVnode进一步比较
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
oldStartVnode = oldCh[++oldStartIdx] // 并将指针往中间移动
newEndVnode = newCh[--newEndIdx] // 并将指针往中间移动
} else if (sameVnode(oldEndVnode, newStartVnode)) { // oldEnd和newStart相同时,执行patchVnode进一步比较
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
oldEndVnode = oldCh[--oldEndIdx] // 并将指针往中间移动
newStartVnode = newCh[++newStartIdx] // 并将指针往中间移动
} else { // 这里面就是当前面4种情况都不匹配时的处理结果
if (oldKeyToIdx === undefined) {
// 存在key的情况下 得到oldCh中 key和idx的一个映射关系,格式为{key: idx},
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key as string] // 通过key,找到当前key的节点在oldCh中的位置,如果找不到会返回undefined
if (isUndef(idxInOld)) { // 如果是undefind,说明newStartVnode的节点的key在oldCh中不存在,或者newStartVnode没有key
// 此时会直接创建新的节点,所以key的设置,会优化比较步骤
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else { // 如果找到了newStartVnode的可以在oldCh中的位置,说明可能只是移动了位置。
elmToMove = oldCh[idxInOld] // 获取需要移动的旧节点
if (elmToMove.sel !== newStartVnode.sel) { // 如果旧节点和新节点的sel不同,代表变了(比如原来是div,现在变成了p)
// 新增新的节点
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else { // sel相等,说明是相同节点,那么patchVnode进一步进行比较
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined as any
// 真实节点移动到相应的位置
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
// newStart指针往中间移
newStartVnode = newCh[++newStartIdx]
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) { // 如果比对结束
if (oldStartIdx > oldEndIdx) { // newCh的start和end之间还有节点时,新增这些节点
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else { // 否则 oldCh的start和end之间还有节点时,移除这些节点
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
}
我们在最后整理一下步骤。
1、比较两个虚拟dom树,对根节点root进行执行patch(oldVnode,newVnode)函数,比较两个根节点是否是相同节点。如果不同,直接替换(新增新的,删除旧的)

2、如果相同,对两个节点执行patchVnode(oldVnode, newVnode),比较属性,文本,已经子节点。此时,要么新增,要么删除。要么直接修改文本内容。只有当都存在子节点时,并且oldVnode === newVnode 为false时。会执行updateChildren函数,去进一步比较他们的子节点。

3、比较分3大类。
第一类:oldStart === newStart, oldStart === newEnd,oldEnd === newStart,oldEnd === newEnd 这4种情况的比较。如果这4种情况中任何一种匹配。那么会执行patchVnode进一步比较。同时指针往中间移
第二类:oldStart > oldEnd 或者 newStart > newEnd时。表示匹配结束。此时,多余的元素删除,新增的元素新增。
第三类:上面几种情况都不匹配。那么这个时候key是否存在。就起到关键性作用了。存在key时,可以直接通过key去找到节点的原来的位置。如果没有找到,就新增节点。找到了,就移动节点位置。查找效率非常高
而如果没有key呢,那么压根就不会去原来的节点中查找了。而是直接新增这个节点。这就导致这个节点下的所有子节点都会被重新新增。会出现明显的性能损耗。所以,合理的应用key,也是一种性能上的优化。
总之一句话。diff的过程,就是一个 patch —> patchVnode —> updateChildren —> patchVnode —> updateChildren —> patchVnode… 这样的一个循环递归的过程
另外,我这里再插一句。可能有的人会疑惑了,那我通过这个虚拟dom的diff算法,就能精准的知道被更新的地方在哪,然后去更新变动的部分。那我vue靠这个就够了呀。我为什么还需要数据劫持呢,还需要getter,setter呢。没错,其实单单靠虚拟dom的diff确实是可以实现的。比如react就是这么做的。react精确查找数据的更新就是纯用虚拟dom的diff的。
但是这会产生一个什么问题呢。当项目非常大的时候,dom树是非常复杂的,如果每次一个小小的改动,就要通过diff算法去精确找到改动的地方,那么这个计算量是非常大的。产生的性能损耗也会巨大。这显然是不合理的。那么vue和react分别是怎么解决这个问题的
vue: 了解vueMVVM原理的人应该都清楚,vue通过Object.defineproperty的数据劫持,会劫持到每一个状态数据,给他们加上getter,setter。并且创建一个发布者Dep,同时,会给依赖这个状态数据的每个依赖者添加一个订阅者watcher。这样,当数据发生变化时,会触发对应的setter,从而Dep会发布通知,通知每一个订阅者watcher,然后watcher更新对应的数据。但是,如果任何一个数据的依赖我都增加一个watcher。那么项目中的watcher数量是会非常庞大的。细粒度太高,会带来内存和依赖关系维护的巨大消耗。这样一种情况下,vue采用响应式+diff的方式。通过响应式的getter,setter快速知道数据的变化发生在哪个组件中,然后组件内部再通过diff的方式去获取更详细的更新情况,并更新数据。
react: 而react却是通过他的一个生命周期函数shouldComponentUpdate实现的。通过手动在这个生命周期函数中判断当前组件的数据是否有发生变化,来决定当前组件是否需要更新。这样,没有发生状态数据变化的组件就不需要进行diff。从而缩小diff的范围。
好了,今天的文章就写到这了。如果有问题,欢迎评论!!
先自我介绍一下,小编13年上师交大毕业,曾经在小公司待过,去过华为OPPO等大厂,18年进入阿里,直到现在。深知大多数初中级java工程师,想要升技能,往往是需要自己摸索成长或是报班学习,但对于培训机构动则近万元的学费,着实压力不小。自己不成体系的自学效率很低又漫长,而且容易碰到天花板技术停止不前。因此我收集了一份《java开发全套学习资料》送给大家,初衷也很简单,就是希望帮助到想自学又不知道该从何学起的朋友,同时减轻大家的负担。添加下方名片,即可获取全套学习资料哦