虚拟dom与diff算法详解

192 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情

虚拟dom是什么?

众所周知,vue中很重要的一个部分就是虚拟dom,那么它到底是什么?并且承担着什么样的角色呢?官方文档中是这样定义的:

虚拟dom是用一个原生的 JS 对象去描述一个 DOM 节点,不需要包含操作 DOM 的方法,所以它比创建一个 DOM 的代价要小很多。

对比一下真实dom和虚拟dom

  1. 真实dom
<div>
	<p>我是p元素</p>
  <div>
        我是div元素
  </div>
</div>
  1. 虚拟dom
const Vnode = {
    sel: 'div', //选择器
    data: {}, //属性
    // 子节点,与文本节点中的文本内容text互斥
    children: [
     	{
            sel: 'p',
            text: '我是p元素',
            data: {}
        },
        {
            sel: 'div',
            text: '我是div元素',
            data: {}
        }
    ]
}

为什么要虚拟dom?

vue是数据驱动视图的,数据发生变化视图就要随之更新,在更新视图的时候难免要操作DOM,而操作真实DOM又是非常耗费性能的。最直观的解决思路就是不要盲目的去更新视图,而是通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方。

虚拟dom的应用

  1. 能够维护视图和状态的关系;
  2. 复杂视图情况下提升渲染性能:注意是复杂,首次渲染时会额外地创建虚拟dom
  3. 能够跨平台使用,如浏览器中服务端渲染和小程序等等

虚拟dom的实现

vue2.0使用snabbdom来实现,源码link

那么接下来就来仔细分析一下snabbdom是如何实现虚拟dom映射到真实的 DOM, 核心就是patch过程。

源码核心步骤

  1. init() 设置模块,最后返回patch
  2. h() 创建vnode
  3. patch中比较新旧节点
  4. 把变化的内容更新到真实dom树中

init

function init (modules: Array<Partial<Module>>, domApi?: DOMAPI){
  ...
}
  1. 第一个参数是一个数组,传入是将来会使用到的模块,如attributesModuleeventListenersModule
  2. 第二个参数domapi,用来把vnode对象转化为其他平台下的元素,若不传,默认操作浏览器dom,这里分析不传该参数的情况

h函数

  1. 创建vnode对象
  2. 处理重载(处理参数),最后返回vnode
  3. 可以传递钩子函数
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 {
  // 处理参数,实现重载
  ...
  // 返回vnode
  return vnode(sel, data, children, text, undefined)
};

vnode

它是用key唯一标识的vnode对象,有六个属性:

export interface VNode {
  // 选择器
  sel: string | undefined
  // data中可以传递vnode钩子函数和真实dom的属性
  data: VNodeData | undefined
  // 与text互斥,子节点
  children: Array<VNode | string> | undefined
  // 存储vnode转换真实dom的元素
  elm: Node | undefined
  // 文本节点中的文本内容
  text: string | undefined
  // 唯一标识vnode
  key: Key | undefined
}

patch

  1. 第一个参数:真实dom,将来会通过emptyNodeAt()转换成vnode
  function emptyNodeAt (elm: Element) {
    const id = elm.id ? '#' + elm.id : ''
    const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
    // 返回vnode
    return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
  }
  1. 第二个参数:新的vnode
  2. 返回值:新的vnode,会作为下一次比较的旧vnode
  3. patch步骤
  • patch时判断是否为sameVnode(判断keysel分别一致)
    • 若相等,进入patchVnode
    • 若不相等,将vnode生成真实节点,插入到旧节点原来的位置上
  // 首次渲染时,patch的第一个参数传递的是element:真实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)) {
      // 传递的是第一个参数:dom对象,转化成vnode
      oldVnode = emptyNodeAt(oldVnode)
    }
    // 查找两个节点间的差异,并更新差异
    if (sameVnode(oldVnode, vnode)) {
      // 若相等,继续patchVnode
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      // 创建新的vnode的dom元素,新创建的插入到dom树中,并删除旧的元素
      elm = oldVnode.elm!
      // 找到elm的父元素
      parent = api.parentNode(elm) as Node
      // 创建vnode对应的dom元素
      createElm(vnode, insertedVnodeQueue)
      
      if (parent !== null) {
        // 指定一参考的位置,父元素的兄弟节点
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        // 删除旧节点
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }
    // 具有insert钩子函数的新的vnode节点,钩子函数是用户传递的,dom元素插入后执行
    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]()
    // 最后返回vnode
    return vnode
  }

patchVnode

patchVnode是如何比较两个节点的呢?

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // 触发prepatch和update钩子函数
    const hook = vnode.data?.hook
    hook?.prepatch?.(oldVnode, vnode)
    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) {
      // 用户修改的可以覆盖模块的
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      vnode.data.hook?.update?.(oldVnode, vnode)
    }
    // 新节点text属性未定义
    if (isUndef(vnode.text)) {
      //新旧节点都有children
      if (isDef(oldCh) && isDef(ch)) {
        // 新旧节点的children不相等
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
      } else if (isDef(ch)) {
        // 只有新节点有children
        // 清空文本节点的textContent,同时添加新节点
        if (isDef(oldVnode.text)) api.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 只有老节点有children,新节点既没有children也没有text,移除所有的老节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 只有老节点有text,新节点既没有children也没有text,清空文本节点的textContent
        api.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 新节点有text属性,且不等于旧节点的text属性
      // 只有旧节点有children属性
      if (isDef(oldCh)) {
        // 删除旧节点的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      }
      // dom元素未重新创建,重用原来的,仅仅是更新了文本的内容,
      // 设置为新节点的textContent内容
      api.setTextContent(elm, vnode.text!)
    }
    // 触发postpatch,可以从dom获取到最新的数据
    hook?.postpatch?.(oldVnode, vnode)
  }

updatechildren

在进入updateChildren之前,先了解一下diff算法。

diff算法

它有不同的实现形式,在传统的diff算法中会去对比每一个节点,如跨级别操作节点,如父节点移动到子节点的位置,而snabbdom根据dom的特点对传统的diff算法做了优化:只比较同级别的节点,降低了比较次数。

snbbdom中的diff过程

  1. 设置四个索引值
  • 旧开始oldstart
  • 旧结束oldend
  • 新开始newstart
  • 新结束newend

node1.gif

🤔:连线表示四种比较,每次依次比较这四种情况

  1. 进行四种比较
  • 比较oldstartnewstart是否为samevnode,若是

    • 调用patchVnode对比和更新节点
    • oldstart++,newstart++

node2.gif

  • oldstartnewstart不是samevnode,开始比较oldendnewend是否为samevnode,若是
    • 调用patchvnode对比和更新节点
    • oldend--,newend--

node3.gif

  • oldendnewend不是samevnode,开始比较oldstartnewend是否为samevnode,若是

    • 调用patchvnode对比和更新节点
    • 把旧开始对应的dom元素移动到旧节点list的最后
    • oldstart++,newend--
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))

node4.gif

  • oldstartnewend不是samevnode,开始比较oldendnewstart是否为samevnode,若是
    • 调用patchvnode对比和更新节点
    • 把旧开始对应的dom元素移动到旧节点list的最前面
    • oldstart--,newend++
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)

node5.gif

  1. 四种比较都不满足
  • 从新节点开始遍历,在旧节点的数组中查到是否有和新节点数组key值相同的节点;
  • 如果在旧节点中找不到时,此时的开始节点是新的节点,创建新的dom元素插入到旧节点最前面;
  • 如果在旧节点中找到时,将旧节点赋值给常量elmtomove
    • 判断elmtomove与新节点的sel是否相同;
      • sel不相同,说明节点被修改,创建新的开始节点对应的dom元素插入到旧的开始节点之前
      • 若相同,elmtomove和新节点通过patchvnode比较差异,然后将elmtomove插入到旧节点最前面
if (oldKeyToIdx === undefined) {
  // 存储老节点的key和索引
  oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// 找到老节点的索引,不一定找到,新的开始节点可能不在老的节点中
idxInOld = oldKeyToIdx[newStartVnode.key as string]
if (isUndef(idxInOld)) { // New element
  // 新节点在老节点中没有对应的值
  api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
  // 若找到了,将老节点赋值赋值给elmtomove
  elmToMove = oldCh[idxInOld]
  if (elmToMove.sel !== newStartVnode.sel) {
    // 节点被修改,创建新的开始节点对应的dom元素插入到老的开始节点之前
    api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
  } else {
    patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
    // 节点被处理后
    oldCh[idxInOld] = undefined as any
    api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
  }
}
newStartVnode = newCh[++newStartIdx]
  1. 当循环结束后
  • 旧节点的所有子节点先遍历完成,新节点有剩余,向旧节点中插入剩余节点
  • 旧节点有剩余时,删除剩余的节点

key的意义

为什么要设置key呢,不设置key的时候明明从代码上来看,会最大程度地重用元素,对比新旧节点认为是相同的(sel相同,key都是undefined),当text不同只会修改dom的内容,这样效率不是更高吗?那为什么还要多此一举呢?

因为最大程度重用元素是有问题的,下面以checkbox为例进行说明。

function view(data) {
    let arr =[]
    data.forEach(item => {
        // 不设置key的情况
        arr.push(h('li', [h('input', {attrs: {type: 'checkbox'}}), h('span',item)]))
        // 设置key的情况
        // arr.push(h('li', {key: item},[h('input', {attrs: {type: 'checkbox'}}), h('span',item)]))
    });
    let vnode = h('div#app',[
        h('button',{on:{click: function(){
            data.unshift(100)
            vnode = view(data)
            oldVnode = patch(oldVnode,vnode)
        }}},'按钮'),h('ul',arr)])
    return vnode
}

let app = document.querySelector("#app")
oldVnode = patch(app,view(data))

这里创建了四个列表项,包含checkout类型input,假定已选中了第一项,当点击按钮的时候,列表项最上面添加了一项100,会发现,新添加的一项被选中了,而原来被选中的取消了选中。

  1. 当未设置key

1和100两个li节点比较,key都是undefinedsel相等,于是patchVnode两个节点,在比较到1和100两个text节点时,key依旧相等,patchVnode两个值,直接改变oldVnodetext为100,但是该节点被选中的属性checked并未改变。

node6.gif

  1. 当设置key

1和100两个li节点的key不同,于是移动索引,开始倒序比较,直到新节点有剩余,在最前面插入100。

node7.gif

总结:当设置key时,对比新旧开始节点,key相同才会重用元素,key值不同,会重新创建dom元素。