彻底搞懂 Vue 虚拟 Dom 和 diff 算法
前言
使用过 Vue 和 React 的小伙伴肯定对虚拟 Dom 和 diff 算法很熟悉,它扮演着很重要的角色。由于小编接触 Vue 比较多,React 只是浅学,所以本篇主要针对 Vue 来展开介绍,带你一步一步搞懂它。
虚拟 DOM
什么是虚拟 DOM?
虚拟 DOM(Virtual Dom),也就是我们常说的虚拟节点,是用 JS 对象来模拟真实 DOM 中的节点,该对象包含了真实 DOM 的结构及其属性,用于对比虚拟 DOM 和真实 DOM 的差异,从而进行局部渲染来达到优化性能的目的。
真实的元素节点:
VNode:
为什么使用虚拟 DOM?
简单了解虚拟 DOM 后,是不是有小伙伴会问:Vue 和 React 框架中为什么会用到它呢?好问题!那来解决下小伙伴的疑问。
起初我们在使用 JS/JQuery 时,不可避免的会大量操作 DOM,而 DOM 的变化又会引发回流或重绘,从而降低页面渲染性能。那么怎样来减少对 DOM 的操作呢?此时虚拟 DOM 应用而生,所以虚拟 DOM 出现的主要目的就是为了减少频繁操作 DOM 而引起回流重绘所引发的性能问题的!
虚拟 DOM 的作用是什么?
兼容性好。因为 Vnode 本质是 JS 对象,所以不管 Node 还是浏览器环境,都可以操作;
减少了对 Dom 的操作。页面中的数据和状态变化,都通过 Vnode 对比,只需要在比对完之后更新 DOM,不需要频繁操作,提高了页面性能;
虚拟 DOM 和真实 DOM 的区别?
说到这里,那么虚拟 DOM 和真实 DOM 的区别是什么呢?总结大概如下:
虚拟 DOM 不会进行回流和重绘;
真实 DOM 在频繁操作时引发的回流重绘导致性能很低;
虚拟 DOM 频繁修改,然后一次性对比差异并修改真实 DOM,最后进行依次回流重绘,减少了真实 DOM 中多次回流重绘引起的性能损耗;
虚拟 DOM 有效降低大面积的重绘与排版,因为是和真实 DOM 对比,更新差异部分,所以只渲染局部;
可以发现,都是围绕频繁操作真实 DOM 引起回流重绘,导致页面性能损耗来说的。不过框架也不一定非要使用虚拟 DOM,关键在于看是否频繁操作会引起大面积的 DOM 操作。
那么虚拟 DOM 究竟通过什么方式来减少了页面中频繁操作 DOM 呢?这就不得不去了解 DOM Diff 算法了。
DIFF 算法
当数据变化时,vue 如何来更新视图的?其实很简单,一开始会根据真实 DOM 生成虚拟 DOM,当虚拟 DOM 某个节点的数据改变后会生成一个新的 Vnode,然后 VNode 和 oldVnode 对比,把不同的地方修改在真实 DOM 上,最后再使得 oldVnode 的值为 Vnode。
diff 过程就是调用 patch 函数,比较新老节点,一边比较一边给真实 DOM 打补丁(patch);
对照 vue 源码来解析一下,贴出核心代码,旨在简单明了讲述清楚,不然小编自己看着都头大了 O(∩_∩)O
patch
那么 patch 是怎样打补丁的?
从上面可以看出,patch 函数是通过判断新老节点是否为同一节点:
如果是同一节点,执行 patchVnode 进行子节点比较;
如果不是同一节点,新节点直接替换老节点;
那如果不是同一节点,但是它们子节点一样怎么办嘞?OMG,要牢记:diff 是同层比较,不存在跨级比较的!简单提一嘴,React 中也是如此,它们只是针对同一层的节点进行比较。
patchVnode
既然到了 patchVnode 方法,说明新老节点为同一节点,那么这个方法做了什么处理?
参考 vue 实战视频讲解:进入学习
如果两个节点不一样,直接用新节点替换老节点;
如果两个节点一样,
新老节点一样,直接返回;
老节点有子节点,新节点没有:删除老节点的子节点;
老节点没有子节点,新节点有子节点:新节点的子节点直接 append 到老节点;
都只有文本节点:直接用新节点的文本节点替换老的文本节点;
都有子节点:updateChildren
最复杂的情况也就是新老节点都有子节点,那么 updateChildren 是如何来处理这一问题的,该方法也是 diff 算法的核心,下面我们来了解一下!
updateChildren
由于代码太多了,这里先做个概述。updateChildren 方法的核心:
提取出新老节点的子节点:新节点子节点 ch 和老节点子节点 oldCh;
ch 和 oldCh 分别设置 StartIdx(指向头)和 EndIdx(指向尾)变量,它们两两比较(按照 sameNode 方法),有四种方式来比较。如果 4 种方式都没有匹配成功,如果设置了 key 就通过 key 进行比较,在比较过程种 startIdx++,endIdx--,一旦 StartIdx > EndIdx 表明 ch 或者 oldCh 至少有一个已经遍历完成,此时就会结束比较。
下面结合图来理解:
第一步:
此时 oldStartIdx 和 newStarIdx 匹配,所以将 dom 中的 A 节点放到第一个位置,此时 A 已经在第一个位置,所以不做处理,此时真实 DOM 顺序:A B C;
第二步:
此时 oldEndIdx 和 newStartIdx 匹配,将原本的 C 节点移动到 A 后面,此时真实 DOM 顺序:A C B;
第三步:
此时遍历结束,oldCh 已经遍历完,那么将剩余的 ch 节点根据自己的 index 插入到真实 DOM 中即可,此时真实 DOM 顺序:A C B D;
所以匹配过程中判断结束有两个条件:
oldStartIdx > oldEndIdx 表示 oldCh 先遍历完成,如果 ch 有剩余节点就根据对应 index 添加到真实 DOM 中;
newStartIdx > newEndIdx 表示 ch 先遍历完成,那么就要在真实 DOM 中将多余节点删除掉;
看下图这个实例,就是新节点先遍历完成删除多余节点:
最后,在这些子节点 sameVnode 后如果满足条件继续执行 patchVnode,层层递归,直到 oldVnode 和 Vnode 中所有子节点都比对完成,也就把所有的补丁都打好了,此时更新到视图。
总结
dom 的 diff 算法时间复杂度为 o(n^3),如果使用在框架中性能会很差。Vue 使用的 diff 算法,时间复杂度为 o(n),简化了很多操作。
最后,用一张图来记忆整个 Diff 过程,希望你能有所收获!
彩蛋
因为 React 只是简单学了基础,这里作为对比来概述一下:
1.React 渲染机制:React 采用虚拟 DOM,在每次属性和状态发生变化时,render 函数会返回不同的元素树,然后对比返回的元素树和上次渲染树的差异并对差异部分进行更新,最后渲染为真实 DOM。
2.diff 永远都是同层比较,如果节点类型不同,直接用新的替换旧的。如果节点类型相同,就比较他们的子节点,依次类推。通常元素上绑定的 key 值就是用来比较节点的,所以一定要保证其唯一性,一般不采用数组下标来作为 key 值,因为当数组元素发生变化时 index 会有所改动。
3.渲染机制的整个过程包含了更新操作,将虚拟 DOM 转换为真实 DOM,所以整个渲染过程就是 Reconciliation。而这个过程的核心又主要是 diff 算法,利用的是生命周期 shouldComponentUpdate 函数。
评论