什么是虚拟 DOM?
虚拟 DOM 的解决方式是通过状态生成一个虚拟节点树,然后使用虚拟节点树进行渲染。在渲染之前,先缓存新生成的虚拟节点树,然后使用新生成的虚拟节点树和上一次生成的虚拟节点树进行对比,只渲染不同的部分。
访问和操作 DOM 的代价是非常昂贵的,如果我们不关心状态发生了什么变化,哪些状态发生了变化,把所有 DOM 全部删除,重新生成一份新的 DOM。这样也许可以解决问题,但是代价相当庞大,会造成相当多的性能浪费。目前各大主流框架都有自己的解决方案, Angular 是用脏检查, React 使用虚拟 DOM,Vue.js 1.0使用细粒度的绑定。
为什么需要使用虚拟 DOM?
Vue.js 在 1.0 版本的时候,当状态发生变化时,它是可以侦察到的,根本不需要比对就可以对这些节点进行更新操作。然而,通过这种细粒度的绑定来更新视图会造成很大的内存开销和依赖追踪的开销,对性能是一个很大的挑战。因此,Vue 从 2.0 开始选择一个中等粒度的解决方案,那就是引入虚拟 DOM。Vue 的状态变化只通知到组件,然后在组件内部通过虚拟 DOM 去进行比对和渲染。
虚拟 DOM 做了哪些事?
- 提供与真实 DOM 节点所对应的虚拟节点 vnode;(如何创建虚拟节点 vnode)
- 将虚拟节点 vnode 和旧虚拟节点 vnode 进行比对,然后更新视图。( patch 算法)。
如何创建虚拟节点 vnode?
vnode 的类型有以下几种:
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件
- 克隆节点
export default class VNode {
constructor(tag, data,children,text,elm,context,componentOptions,asyncFactory) {
this.tag = tag
this.data = data
......
// 以下省略其他VNode的其他属性
}
}
VNode 本质上是一个类,一个模拟真实 DOM 节点的类。 vnode 本质上是一个普通的对象,是从 VNode 类实例化的对象,我们用这个对象来描述一个真实的 DOM 元素。创建 vnode 的过程就是实例化 VNode 类。
const node = new VNode()
比如创建一个注释节点
export const createEmptyVNode = text => {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
注释节点只有 text 和 isComment 两个属性。
虚拟节点 vnode 的作用是什么?
每次渲染视图都要先创建 vnode,然后将它们缓存起来,之后每当需要重新渲染视图的时候,将新创建的 vnode 和上一次缓存的 vnode 进行对比,找出不一样的地方并基于此去修改真实的 DOM。 只修改组件不一样的地方而不是重新渲染整个组件的所有节点,这可以大大地减少性能浪费。
什么是 patch 算法?
patch 算法是虚拟 DOM 的核心,它的作用是比对新旧两个 vnode 之间有哪些不同,然后根据比对结果找出需要更新的节点进行更新。本质上是使用 JavaScript 的运算成本来替换 DOM 操作的执行成本,因为 DOM 操作的执行速度远远不如 JavaScript 的运算速度快。
patch 算法主要做了什么?
patch 对现有 DOM 的修改需要做三件事(增删改):
- 创建新增的节点;
- 删除已经废弃的节点;
- 修改需要更新的节点。
新增节点
新增节点主要发生在两种场景下:
- 首次渲染;
- vnode 和 oldVnode 完全不是同一个节点。
创建节点
只有元素节点,注释节点和文本节点这三种类型的节点会被创建并插入到 DOM 中。 创建流程如下:
- 判断 vnode 是否是元素节点(看它是否具有 tag 属性),若是,则创建元素节点,如果元素有子节点(children),需要递归创建子节点,最后再插入到父节点中;
- 若否,则判断 vnode 是否是注释节点(isComment 属性值为 true),是,则创建注释节点,并插入到父节点中;
- 若否,则创建文本节点,并插入到父节点中。 ### 删除节点 当一个节点只在 oldVnode 中存在的时候,我们需要把它从 DOM 中删除。 实现逻辑是将待删元素从父节点中删除。(比较简单,看看代码就好)
- 首先判断新旧两个虚拟节点是否是静态节点,是的话,直接跳出程序;
- 然后判断新虚拟节点是否有文本属性,如果有的话,则不用管旧虚拟节点的子节点是什么,直接将视图中的 DOM 该节点的内容改为新虚拟节点的 text 属性所保存的文字;
- 如果新虚拟节点没有文本属性的话,这时候就要看新虚拟节点有没有 children 属性,有的话,看旧虚拟节点。如果新旧虚拟节点都有 children 属性的话,则涉及到相对复杂的子节点比对过程,如果旧虚拟节点没有 children 属性的话,则递归新增新虚拟节点的子节点就好啦;
- 如果新虚拟节点没有 children 属性的话,则表示这是一个新标签,对旧虚拟节点有什么就删什么。
- 遍历新虚拟子节点,如果在旧虚拟子节点中没有找到相应的节点,则需要创建新节点,并将新节点插入到旧虚拟子节点(oldChildren)中所有**未处理的节点**;
- 当新旧两个子节点是同一个节点并且位置相同,则需要做更新子节点的操作;
- 当新旧两个子节点是同一个节点但是位置不同,则需要做移动子节点的操作,具体实现是该把旧虚拟子节点(对应原 DOM)移动到所有**未处理节点**的最前面;
- 删除子节点,本质上是删除那些在 oldChildren 中存在但是在 newChildren 中不存在的节点。那么,如何找到待删除的节点呢?当 oldChildren 中所有的节点都被循环了一遍之后,如果还有剩余的**未处理节点**,那这些节点就是被废弃的需要删除的节点。
- 新前和旧前;
- 新后和旧后;
- 新前和旧后;
- 新后和旧前;
- 新前:newChildren 中所有**未处理**的第一个节点;
- 新后:newChildren 中所有**未处理**的最后一个节点;
- 旧前:oldChildren 中所有**未处理**的第一个节点;
- 旧后:oldChildren 中所有**未处理**的最后一个节点。
- 新前和旧后:将节点移动到 oldChildren 中所有**未处理节点**的最前面;
- 新后和旧前:将节点移动到 oldChildren 中所有**未处理节点**的最后面。
- 如果 oldChildren 先循环完毕,newChildren 还有剩余节点,则这些节点都是需要被新增的节点;
- 如果 newChildren 先循环完毕,oldChildren 还有剩余节点,则这些节点都是需要被删除的节点;
- 当 newChildren 有节点剩余时,newStartIndex 肯定小于 newEndIndex,下标在 newStartIndex 和 newEndIndex 中的节点都是未处理的需要被新增的节点;
- 当 oldChildren 有节点剩余时,oldStartIndex 肯定小于 oldEndIndex,下标在 oldStartIndex 和 oldEndIndex 中的节点都是未处理的需要被删除的节点;
function removeVnodes(vnodes, startIndex, endIndex) {
for (; startIndex <= endIndex; ++startIndex) {
const ch = vnodes[startIndex]
if (isDef(ch)) {
removeVnode(ch.elm)
}
}
}
const nodeOps = {
// 跨平台
removeChild(node, child) {
node.removeChild(child)
},
}
function removeVnode(el) {
const parent = nodeOps.parentNode(el)
if (isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
更新节点
更新节点的流程如下:
如何更新子节点?
更新子节点可以分为4种操作:更新节点,新增节点,删除节点,移动节点位置。
常规的更新策略
优化后的更新策略
通常情况下,并不是所有的子节点都会发生移动,所以我们可以尝试使用相同位置的两个节点来比对是否是同一个节点,如果恰好是同一个节点,则进入更新操作,如果尝试失败了,再用循环的方式来查找节点。这在一定程度上,可以大大提升性能。
有以下4种快捷的查找方式:
概念:
其中,只有 新前和旧后 以及 新后和旧前 的比对,在节点相同的情况下,才需要移动节点,移动规则是:
哪些是未处理节点?
由于我们的逻辑都是在循环体内处理的,所以只要让循环条件保证只有只有未处理过的节点才能进入循环体内,就能忽略已处理的节点而只对未处理的节点进行比对和更新操作。
为了实现更大程度的性能优化,Vue 遍历子节点是从两边向中间循环遍历的。
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// doSomething
}
如果用优化后的更新策略也没有找到节点的话,则需要继续用循环遍历的方式找出节点。
建议为节点设置属性 key
属性 key 可以表示一个节点唯一的 id,更新子节点的时候,需要从 oldChildren 中循环去找节点,如果节点的 key 属性存在的话,则用哈希表就可以快速地找到该节点,而不用一个一个地去遍历查找,可以大大提升性能。
以上内容来自刘博文的 《深入浅出Vue.js》的读书笔记