「时光不负,创作不停,本文正在参加2021年终总结征文大赛」
大家好,我是王大傻。想必大家在使用Vue时经常听人提起虚拟DOM,那么它到底是什么呢?带着疑问我来给大家讲解一下其中的原理
知识点
- 虚拟DOM
- 虚拟DOM是什么
- 为什么要使用虚拟DOM
- 真实DOM的缺陷
- 虚拟DOM解决那些事
- 执行流程
- 虚拟DOM性能是不是比真实DOM好
- Diff算法
- 什么是Diff算法
- Snabbdom库(Vue中的虚拟DOM算法就是借鉴于此库)
- init函数
- h函数
- patch函数
- patchVNode
- updateChildren
虚拟DOM
虚拟DOM是什么
虚拟 DOM 是相对于浏览器所渲染出来的真实 DOM ,在react,vue等技术出现之前,我们要改变页面展示的内容只能通过遍历查询 DOM 树的方式找到需要修改的 DOM 然后修改样式行为或者结构,来达到更新视图的目的。简而言之:在前端中,虚拟DOM就是一个用来描述真实DOM树的JavaScript对象。那么问题来了,我们为什么要使用虚拟DOM或者在什么样的场景下我们需要使用到虚拟DOM呢?且听我来分析
为什么要使用虚拟DOM
首先先来说一下真实DOM的缺陷:
- 牵一发而动全身,频繁操作DOM(当我们明明想修改个标签内容,但却还需要使其父级元素也去渲染)
- 每次操作DOM,GUI渲染引擎都需要进行重排、重绘或者合成等操作
- 对于 DOM 的不当操作还有可能引发强制同步布局和布局抖动的问题
- 大大降低渲染效率
结合以上情况,我们来了解一下虚拟DOM给我们带来了什么便利吧
- 将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上()
- 虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟DOM 的内部状态
- 在虚拟 DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。
- 最最重要一点,运用虚拟DOM函数可以支持我们跨端渲染,也就是我们一个虚拟DOM将来可以通过不同的API处理演化为不同系统平台的具体内容
虚拟DOM性能是不是比真实DOM好
是但不全是,虚拟dom相当于在js和真实dom中间加了一个缓存,利用dom diff算法避免了没有必要的dom操作,从而提高了性能.那么如果dom本来就少,操作也不多,我们使用了虚拟DOM无疑会使的加载更慢
最后再来简单描述一下虚拟DOM的执行流程
如图,当我们的数据发生变化时候,会先去通知我们的虚拟DOM算法,此时我们会根据新的数据去渲染出来一个虚拟DOM树,渲染完毕后我们拿新的DOM树和老的DOM树进行比对(Diff算法)经过比对后,我们只去更新需要渲染的部分(新增,删除,修改)而相对于之前没有变化的部分我们则不去更替。
讲完了这些基本的内容,相信你对虚拟DOM已经有了一定的了解,那么接下来,进入了本文的高光时刻!!
Diff算法
什么是Diff算法
把树形结构(在此处特指我们的DOM树)按照层级分解,只比较同级元素。不同层级的节点只有创建和删除操作。给列表结构的每个单元添加唯一的key属性,方便比较。
Snabbdom库
前面我们提到过,在Vue中我们的虚拟DOM就是借鉴于这个库,那么接下来,让我们一起来了解下它的原理吧
init函数
首先我们先来看看使用
const patch = init([
// 初始化patch函数并且可以选择加入一些模块
classModule, // 确保可以更简单的去进行类型切换
propsModule, // 用于设置DOM元素的属性
styleModule, // 处理元素的样式设置,并支持动画
eventListenersModule, // 事件侦听器模块
]);
当然,我们一般在测试学习时候是用不到这么多的模块加入。那么init究竟做了些什么呢?让我们带着疑问来看一下官方的源码。
// 定义钩子变量数组
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
// 首先定义了两个变量 i,j
let i: number
let j: number
// 定义了钩子
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: []
}
// 定义了api执行是遵循什么规则 默认时候我们是浏览器环境下的DOM
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
// 将钩子初始化
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
const hook = modules[j][hooks[i]]
if (hook !== undefined) {
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
// 接下来初始化了一些工具函数 我们就不一一举例说明了有兴趣的可以直接看源码这块
// ·········
// 返回了我们的主要patch函数 接收两个参数 旧的DOM节点 和 新的DOM节点 此处我们在下一段再去介绍
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
removeVnodes(parent, [oldVnode], 0, 0)
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
return vnode
}
}
总结,init在初始化阶段根据我们导入的模块以及API的选择去做配置初始化并在最后返回给我们一个patch函数
h函数
相比于init函数,h函数我们更为亲切,尤其是使用Vue作为技术栈的同学,那么此处的h函数其实和Vue里面使用几乎一致。
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
const patch = init([])
// h函数参数介绍
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的文本内容
let vnode = h('div#container.cls',{
hook: {
init (vnode) {
console.log(vnode.elm)
},
create (emptyNode, vnode) {
console.log(vnode.elm)
}
}
}, 'Hello World')
let app = document.querySelector('#app')
// patch函数参数介绍
// 第一个参数:旧的 VNode,可以是 DOM 元素
// 第二个参数:新的 VNode
// 返回新的 VNode
// 模拟数据更新
let oldVnode = patch(app, vnode)
vnode = h('div#container.xxx', 'Hello Snabbdom')
patch(oldVnode, vnode)
那么官方给的源码中也有相应的内容,在此还要值得一提的是函数重载的概念
- 函数重载
- 函数重载是一种特殊情况,允许在同一作用域中声明几个类似的同名函数,这些同名函数的形参列表(参数个数,类型,顺序)必须不同,常用来处理实现功能类似数据类型不同的问题。
- 当然在我们的JS中是没有这个概念的 需要是强类型语言
// 首先是定义h函数 对于传递不同的参数生成不同的函数内容
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}// 定义数据
var children: any// 定义子节点
var text: any// 定义文本节点
var i: number
if (c !== undefined) {// 判断是否有子节点
if (b !== null) {// 如果有的话是否有数据
data = b
}
if (is.array(c)) {// 判断子节点是否为数组
children = c
} else if (is.primitive(c)) {// 源码中的方法 判断是否为数字或者字符串
text = c
} else if (c && c.sel) {// 都不是的话判断是否有选择器
children = [c]
}
} else if (b !== undefined && b !== null) {// 没有子节点并且有数据的情况
if (is.array(b)) {
children = b
} else if (is.primitive(b)) {
text = b
} else if (b && b.sel) {
children = [b]
} else { data = b }
}
if (children !== undefined) {// 如果子节点存在 将其初始化为我们的VNode节点
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {// 初始化svg节点 特殊节点
addNS(data, children, sel)
}
return vnode(sel, data, children, text, undefined)// 返回我们的VNode函数
};
看完示例,想必大家都明白了h函数的作用,那就是将我们写的内容去转化为VNode,并在此通过patch函数去和我们的oldVNode进行比对并返回给我们最终的VNode函数而VNode函数的返回值就是一个VNode节点。
patch函数
最后就是我们的重中之重patch函数,那么根据上面两块内容,我们大致了解了patch函数做的事情就是对比新旧节点并返回最终的VNode节点,说到这,有些同学肯定有疑问,那么究竟在什么时候才渲染到我们页面上来呀,别急,且听我娓娓道来。
function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node // 定义基本变量
const insertedVnodeQueue: VNodeQueue = []// 定义插入VNode的队列
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()// 熟悉的hooks中的pre钩子
if (!isVnode(oldVnode)) {// 先来判断是否为我们的VNode节点 不是的话创建一个空节点
oldVnode = emptyNodeAt(oldVnode)
}
if (sameVnode(oldVnode, vnode)) {// 判断是否为相同的节点 此处判断我们在下面说明
patchVnode(oldVnode, vnode, insertedVnodeQueue)// 如果是的话执行我们的对比VNode
} else {
elm = oldVnode.elm! // 确保我们传入一定有值
parent = api.parentNode(elm) as Node// 找到父节点作为我们的节点
createElm(vnode, insertedVnodeQueue)// 创建一个dom节点并插入到我们的VNode
if (parent !== null) {// 如果父节点不为空的情况下
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) // 将我们传入的新节点插入到此处
removeVnodes(parent, [oldVnode], 0, 0)// 删除原来的节点
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {// 遍历我们需要插入的节点队列
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()// 调用我们的post钩子
return vnode
}
关于是否为相同的节点判断,是这样的
function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
// 通过对比两个节点的key值以及sel标签是否一致 (知道v-for为什么要传key了吧)
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
那么接下来就来讲下我们的patchVNode方法
patchVNode函数
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook// 首先获取下我们节点中的钩子函数
hook?.prepatch?.(oldVnode, vnode)// 执行下我们的prepatch钩子 并传入新旧节点 标识两个节点开始对比
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]// 旧节点的子节点
const ch = vnode.children as VNode[] // 新节的子节点
// 初始化一些使用的变量
if (oldVnode === vnode) return // 如果新旧节点一样 完全相等 那就没有什么更新必要 不去更改
if (vnode.data !== undefined) {// 如果新节点的data中有数据 调用我们update钩子去更新数据
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
if (isUndef(vnode.text)) {// 判断新节点的文本节点是否为undefined
if (isDef(oldCh) && isDef(ch)) {// 判断新旧子节点是否存在
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)// 如果存在且不相等的情况下去更新新旧子节点
} else if (isDef(ch)) {// 如果只存在新节点的子节点
if (isDef(oldVnode.text)) api.setTextContent(elm, '')// 那么就设置新的子节点并把text标识为空
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)// 插入新的子节点
} else if (isDef(oldCh)) {// 如果只存在旧节点的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)// 找到并删除
} else if (isDef(oldVnode.text)) {// 如果存在旧节点的子节点
api.setTextContent(elm, '')// 标识为空
}
} else if (oldVnode.text !== vnode.text) {// 如果新旧子节点的内容不相同
if (isDef(oldCh)) {// 如果旧节点存在
removeVnodes(elm, oldCh, 0, oldCh.length - 1)// 首先移除旧的节点
}
api.setTextContent(elm, vnode.text!)// 插入新节点的文本节点
}
hook?.postpatch?.(oldVnode, vnode)// 调用postpatch钩子 标识新旧节点已经对比完毕
}
看完这个代码,我们已经知道了,最最最重要的节点对比函数就在此处进行,分别通过prepatch 以及 postpatch来标识是否已经完成了节点对比,附图一张
- 如图我们来解释下子节点的对比过程
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) {
// 当且仅当 旧开始节点小于旧结束节点 新开始节点小于新结束节点同时成立时执行
if (oldStartVnode == null) {// 如果旧开始节点为null
oldStartVnode = oldCh[++oldStartIdx] // 旧开始节点进行右移操作 指向下一个
} else if (oldEndVnode == null) {// 旧结束节点为null时候
oldEndVnode = oldCh[--oldEndIdx]// 旧结束节点进行左移操作 指向上一个
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {// 判断新旧开始节点是否为相同节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) // 如果是进行patchVnode处理
oldStartVnode = oldCh[++oldStartIdx]// 旧开始节点右移
newStartVnode = newCh[++newStartIdx]// 新开始节点右移
} else if (sameVnode(oldEndVnode, newEndVnode)) {// 判断新旧结束节点是否为相同节点
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]// 旧结束节点左移
newEndVnode = newCh[--newEndIdx]// 新结束节点左移
} else if (sameVnode(oldStartVnode, newEndVnode)) { // 对比旧开始节点和新结束节点是否为相同节点
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
// 将旧开始节点插入到 旧结束节点之后
oldStartVnode = oldCh[++oldStartIdx]// 此时旧开始节点右移
newEndVnode = newCh[--newEndIdx]// 新结束节点左移
} else if (sameVnode(oldEndVnode, newStartVnode)) { // 对比旧结束节点和新开始节点是否为相同节点
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
// 将旧结束节点插入到 旧开始节点之前
oldEndVnode = oldCh[--oldEndIdx]// 此时旧开始节点左移
newStartVnode = newCh[++newStartIdx]// 新结束节点右移
} else {
if (oldKeyToIdx === undefined) {// 没有key
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 创建key并作为数组标识
}
idxInOld = oldKeyToIdx[newStartVnode.key as string]
if (isUndef(idxInOld)) { // 如果不存在新节点的key 说明是一个新节点
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)// 那么将创建一个新的VNode节点并插入到旧节点开始位置
} else {
elmToMove = oldCh[idxInOld]// 如果存在 赋值给变量
if (elmToMove.sel !== newStartVnode.sel) {// 如果新旧节点的标签不相等
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)// 创建新节点 插入到旧节点开始位置
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined as any// 将原先旧节点标记为undefined
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)// 插入我们处理后的节点
}
}
newStartVnode = newCh[++newStartIdx]// 新开始节点自增
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {// 判断比对完毕后还有节点剩余的情况
if (oldStartIdx > oldEndIdx) {// 如果旧开始子节点大于旧结束子节点 说明新子节点有剩余
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
// 将新子节点剩余部分插入到节点中
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
// 旧子节点有剩余 就删除剩余的节点
}
}
}
至此,我们的新旧子节点的比对工作也已经完成(Diff算法),那么通过这个处理后,我们就完成了Diff算法并获得最终的计算结果VNode,如果我们想要渲染到页面 那么我们还需要其他的转换工具,如 snabbdom-to-html 库。