# 概述
Vue 自从 2.0 版本开始引入了虚拟 DOM 技术,而虚拟 DOM 技术也是 React 的核心技术之一,引入了虚拟 DOM 之后,Vue 的初始渲染速度提升了 2~4 倍。
虚拟 DOM 之所以称为虚拟 DOM,是因为他不是真实的 DOM,而是用 JavaScript 数据结构表示成的虚拟节点树,然后使用虚拟节点树进行渲染。
就是,先生成一个虚拟节点树,然后用它和上一次生成的虚拟节点树进行对比,只渲染不同的部分。
简单来说,就是生成 -> 对比 -> 渲染。
# 为什么引入虚拟 DOM?
在前面一篇讲 Vue 的变化侦测的博客里,也提到过。React 的变化侦测是比较暴力的,它不知道哪些地方需要变化,就只能通过虚拟 DOM 的比对,然后销毁、重建。
但其实在 Vue 中,Vue 是知道哪些状态发生了变化的。Vue 可以通过更细粒度的感知来更新视图,不需要进行比对。并且,Vue 在更新 DOM 的时候是异步执行的:当侦听到数据变化,Vue 会开启一个队列,并且缓冲同一事件循环中发生的所有变更。(当数据变化,会通知对应的所有依赖 Watcher 实例更新)当一个 watcher 被多次触发,只会被推入到队列一次,能够减少不必要的计算和 DOM 操作。在下一个的事件循环 tick 中,Vue 刷新队列且执行实际(已去重)工作。
看起来并没有什么引入虚拟 DOM 的必要,性能上似乎也还好。但是这么设计有个代价,每个状态对应一个 Watcher 实例来观察,内存开销以及依赖追踪开销在大型项目之中非常的大。
于是,Vue2.0 就选择了一个中等粒度的折中方案:引入虚拟 DOM。把 Watcher 实例观察的级别从状态改成组件级别,也就是说当状态发生变化的时候,只能通知到组件级别。然后组件内部通过虚拟 DOM 去进行比对和渲染。
而且这个改动似乎也不大,因为前面的博客我们讲到,Watcher 只是一个中介,调整外界到 Watcher 的粒度,应该是对响应式核心的改动不大。
# Vue 中的虚拟 DOM
在 Vue 之中,使用模板来描述状态和 DOM 的映射。Vue 会通过模板编译,将模板转换成渲染函数,通过执行这个函数就能够得到一个虚拟节点树。
每次属性发生变化,会调用组件渲染函数生成新的虚拟节点树,然后将新生成的虚拟节点树与上一次渲染视图使用的旧虚拟节点树进行对比(diff),再把要更新的地方进行 DOM 操作(patch)。最后缓存这一次渲染视图使用的虚拟节点树的 VNode
# VNode
在虚拟 DOM 中,VNode 即虚拟节点,在 Vue 中存在一个 VNode 类,用来实例化不同类型的 vnode 实例来表示不同类型的 DOM 节点。
export default class VNode{ | |
constructor(tag, data, children, text, elm, context, componentOptions, asyncFactory){ | |
this.tag = tag; | |
this.data = data; | |
this.children = children; | |
.... | |
} | |
... | |
} |
VNode 实际上就是一个 JavaScript 对象。在渲染视图之中,Vue 会先创建 VNode,然后再使用它去生成真实 DOM,然后插入到页面渲染视图。
VNode 的类型有以下几种:
- 注释节点
- 文本节点
- 元素节点
- 组件节点
- 函数式组件
- 克隆节点
# patch
patch 算法又叫 patching 算法,它主要是通过对比新旧 vnode 找到需要更新的节点进行更新。本质上其实就是用 JavaScript 的运算速度换 DOM 操作的执行成本。
主要是两个算法:patchVnode 和 updateChildren。
patch 算法的运行流程如下:
- 检测 oldVnode 是否存在。若不存在,则使用 vnode 创建节点插入视图。存在则进入下一步
- 检测 oldVnode 和 vnode 是否是同一个节点。如果是则使用 patchVnode 进行更详细的对比与更新操作。
- 若第二步不是,则使用 vnode 创建真实节点并插入到视图中旧节点的旁边,并将视图中的旧节点删除
# patchVnode
是同一个节点的情况下,会进入该算法进行更详细的对比和更新,patchVnode 的算法流程大致如下:
- 检测 vnode 与 oldVnode 是否完全一样?一样则退出,否则 2
- vnode 和 oldVnode 是静态节点?是则退出,否则 3
- vnode 有 text 属性?没有则代表是元素节点,有则检查 oldVnode 和 vnode 文本是否相同,不同就用 vnode 的文本替换真实 DOM 节点的内容否则 4
- 如果 vnode 和 oldVnode 都存在子节点,且子节点不相同,就进入 updateChildren。否则进入 567 步逐个检测
- 如果只有 vnode 子节点存在,则清空 DOM 中的文本并将 vnode 的子节点添加到 DOM 中。
- 如果只有 oldVnode 存在子节点,则清空 DOM 中的子节点
- 如果 oldVnode 中有文本,则清空 DOM 中的文本
总而言之,尽量 vnode 为准来更新视图。
# updateChildren
更新子节点,也是 diff 的核心。主要是 4 种操作:更新节点、新增节点、删除节点、移动节点。并且在循环中进行比对
新增子节点。
这个好理解,当没有在 oldChildren 中找到本次循环所指向的新子节点的节点,就新建一个节点插入到所有未处理节点的前面
更新子节点。同一个节点且同一位置
移动子节点。同一个节点,但是位置不同,则把需要移动的节点移动到所有未处理节点的前面。
删除子节点。本质上是删除哪些 oldChildren 存在但 newChildren 不存在的节点。
为了实现两端向中间遍历(即分辨出哪些节点被处理过 or 未处理过),这里 vue 用了四个变量来计算已处理和未处理的节点,oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。分别表示 oldChildren 开始位置的下标和结束位置的下标、newChildren 开始位置的下标和结束位置的下标。
因为查找对应节点这个过程是通过循环,比较耗时,因此 vue 对这个过程做了个优化策略,也就是双端对比。同时从新旧 children 的两端开始比较,借助 key 值找到可复用的节点,再进行相关操作。
大概有四个快捷查找方式:
- 新前与旧前
- 新后与旧后
- 新后与旧前
- 新前与旧后
新前指的是 newChildren 中所有未处理节点的第一个节点,新后指的是 newChildren 中所有未处理节点的最后一个节点。旧前与旧后以此类推
其实就是顾名思义,先通过这四个快捷查找试探,如果找到了就不必循环查找,如果没找到才循环查找。
即通过对比新前 / 后与旧前 / 后位置的节点,看看是不是同一个节点,是则不用循环查找。这样可以减少移动节点的次数和减少不必要的性能损耗。
# 总结
虚拟 DOM 中最关键的部分就是 patch。通过 patch 可以对比新旧两个虚拟 DOM,并只针对发生了变化的节点进行更新视图的操作。
虚拟 DOM 也是 React 的核心技术之一。自从 Vue2.0 引入虚拟 DOM 后,初始渲染速度比 Vue1.0 提升了 2~4 倍,且大大降低了内存消耗。
# 参考资料
刘博文著的《深入浅出 Vue.js》