Vue核心之虚拟DOM

2,124 阅读5分钟

「时光不负,创作不停,本文正在参加2021年终总结征文大赛

大家好,我是王大傻。想必大家在使用Vue时经常听人提起虚拟DOM,那么它到底是什么呢?带着疑问我来给大家讲解一下其中的原理

知识点

  1. 虚拟DOM
    1. 虚拟DOM是什么
    2. 为什么要使用虚拟DOM
      • 真实DOM的缺陷
      • 虚拟DOM解决那些事
      • 执行流程
    3. 虚拟DOM性能是不是比真实DOM好
  2. Diff算法
    1. 什么是Diff算法
    2. Snabbdom库(Vue中的虚拟DOM算法就是借鉴于此库)
      • init函数
      • h函数
      • patch函数
        • patchVNode
        • updateChildren

虚拟DOM

虚拟DOM是什么

虚拟 DOM 是相对于浏览器所渲染出来的真实 DOM ,在react,vue等技术出现之前,我们要改变页面展示的内容只能通过遍历查询 DOM 树的方式找到需要修改的 DOM 然后修改样式行为或者结构,来达到更新视图的目的。简而言之:在前端中,虚拟DOM就是一个用来描述真实DOM树的JavaScript对象。那么问题来了,我们为什么要使用虚拟DOM或者在什么样的场景下我们需要使用到虚拟DOM呢?且听我来分析

image.png

为什么要使用虚拟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算法)经过比对后,我们只去更新需要渲染的部分(新增,删除,修改)而相对于之前没有变化的部分我们则不去更替。

image.png 讲完了这些基本的内容,相信你对虚拟DOM已经有了一定的了解,那么接下来,进入了本文的高光时刻!!

image.png

Diff算法

什么是Diff算法

把树形结构(在此处特指我们的DOM树)按照层级分解,只比较同级元素。不同层级的节点只有创建和删除操作。给列表结构的每个单元添加唯一的key属性,方便比较。

image.png

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节点。

image.png

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来标识是否已经完成了节点对比,附图一张

image.png

  • 如图我们来解释下子节点的对比过程

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 库。

image.png