Virtual DOM
熟悉我的人想必都知道,我的主要技术栈是Vue。虽然平时没怎么接触过React,但是原理和Vue是差不多的–都是通过构建虚拟DOM,然后利用diff算法更新真实DOM实现页面的更新。今天我们就一起来学习虚拟DOM。
相信从事过前端开发的童靴都知道,操作Virtual DOM会比操作原生DOM要快。但是为什么说操作原生DOM慢呢?
为什么操作原生DOM慢?
我们知道执行JS代码有一个JS引擎,执行渲染也有一个渲染引擎。
一方面当我们使用JS去操作DOM时,涉及到两个线程之间的通信,会产生一部分性能的损耗。如果操作DOM的次数过多,那么就相当于这两个线程一直在进行通信。
另一方面当我们操作DOM的时候,很容易就引起重绘和回流操作,对性能会产生很大的影响。
虚拟DOM
相较于直接操作DOM,操作JS对象会快很多。Virtual DOM就是用一个原生JS对象去描述一个DOM节点,当你使用jQuery或者原生api去操作DOM时,浏览器很可能会出现重绘和回流操作。
比如当你在一次操作时,需要更新100个DOM节点,理想情况是DOM节点更新完毕后,浏览器进行一次回流操作,将修改的节点信息渲染出来。
但是浏览器没有那么智能,当它收到第一个更新DOM的请求时,并不知道后来还有99个,因此会马上执行更新操作,这样就会出现100次更新和100次回流。
Virtual DOM就是为了解决类似问题而被设计出来的,虚拟DOM不会立即操作DOM,而是将这100次操作作用在虚拟DOM上,最终通过diff函数生成真实DOM的补丁用来更新真实DOM,通知浏览器去执行绘制工作。
在Vue中,Virtual DOM是用VNode这样的一个类去表示。
VNode对象
一个VNode的实例对象包括以下属性
1 | export interface VNode { |
下面我们来简单模拟一段代码
1 | <ul class='list'> |
以上代码转换成VNode就是
1 | const ul = { |
用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,操作内存中的js对象的速度显然要快多了。等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。
那么具体是怎么实现的呢?我们来看看Vue中createElement方法
createElement解析
1 | const SIMPLE_NORMALIZE = 1 |
或许看上面的代码有点晕,所以我在网上找了张图可以对照着理解上述代码
patch 实现
关于patch的实现,代码量太大了,所以就不在这儿贴出来了,在这里我简述一下它的比较过程,感兴趣的童靴可以去看看Vue源码,源码位于/src/core/vdom/patch.js
首先patch接收四个参数
- oldVnode:旧的虚拟节点或者当前真实节点
- vnode:新的虚拟节点
- hydrating:是否要和真的dom混合
- removeOnly:特殊flag,用于
组件
patch函数的执行过程:
- 如果vnode不存在,但是oldVnode存在,说明需要删除此节点,调用invokeDestroyHook(oldVnode)
- 如果vnode存在但是oldVnode不存在,说明此节点是新加的节点,调用createElm(vnode, insertedVnodeQueue)创建新节点
- 如果vnode和oldVnode都存在的情况,就需要比较节点值了。当两个节点值的比较的时候,就调用patchVnode函数。节点的比较主要分为5种情况:
- 当它们的引用一致时,可以认为是没有变化,不需要进行操作。
1 | if (oldVnode === vnode) { |
- 文本节点的比较,需要修改则调用setTextContent。
1 | if (oldVnode.text !== vnode.text) { |
- 两个节点都有子节点,而且它们不一样,这是就调用updateChildren进行子节点的比较。
1 | if (isDef(oldCh) && isDef(ch)) { |
- 只有新的节点有有子节点,调用addVnodes方法。
1 | if (isDef(ch)) { |
- 新节点没有子节点,老节点有子节点,直接删掉老节点。
1 | if (isDef(oldCh)) { |
1245步执行的操作都比较简单,最重要的还是updateChildred,为了形象的展示updateChildren所进行的操作,我们来看看这样的一张图。

过程可以概括为:oldCh和newCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和newCh至少有一个已经遍历完了,就会结束比较。
在updateChildren中,算法如下:
- 分别获取oldVnode和vnode的firstChild、lastChild,赋值给oldStartVnode、oldEndVnode、newStartVnode、newEndVnode
- 如果oldStartVnode和newStartVnode是同一节点,调用patchVnode进行patch,然后将oldStartVnode和newStartVnode都设置为下一0个子节点,重复上述流程。

- 如果oldEndVnode和newEndVnode是同一节点,调用patchVnode进行patch,然后将oldEndVnode和newEndVnode都设置为上一个子节点,重复上述流程。

- 如果oldStartVnode和newEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldStartVnode.elm移动到oldEndVnode.elm之后,然后把oldStartVnode设置为下一个节点,newEndVnode设置为上一个节点,重复上述流程。

- 如果newStartVnode和oldEndVnode是同一节点,调用patchVnode进行patch,如果removeOnly是false,那么可以把oldEndVnode.elm移动到oldStartVnode.elm之前,然后把newStartVnode设置为下一个节点,oldEndVnode设置为上一个节点,重复上述流程

如果以上都不匹配,就尝试在oldChildren中寻找跟newStartVnode具有相同key的节点,如果找不到相同key的节点,说明newStartVnode是一个新节点,就创建一个,然后把newStartVnode设置为下一个节点
如果上一步找到了跟newStartVnode相同key的节点,那么通过其他属性的比较来判断这2个节点是否是同一个节点,如果是,就调用patchVnode进行patch,如果removeOnly是false,就把newStartVnode.elm插入到oldStartVnode.elm之前,把newStartVnode设置为下一个节点,重复上述流程

- 如果在oldChildren中没有寻找到newStartVnode的同一节点,那就创建一个新节点,把newStartVnode设置为下一个节点,重复上述流程
- 如果oldStartVnode跟oldEndVnode重合了,并且newStartVnode跟newEndVnode也重合了,这个循环就结束了。
Vue源码中这一块还是比较复杂的,本文只是抛砖引玉,希望小伙伴们还是能够亲自去看一看。
参考文档:
vue虚拟dom实现
Vue.js 技术揭秘
面试官:你了解vue的diff算法吗?
解析vue2.0的diff算法