虚拟dom和diff算法

一. 虚拟DOM

  • 什么是虚拟DOM
      Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。
      简单来说,可以把Virtual DOM 理解为一个简单的JS对象,并且最少包含标签名( tag)、属性(attrs)和子元素对象( children)三个属性。

  • 虚拟dom渲染函数
class Element {
  constructor (type, props, children) {
    this.type = type
    this.props = props
    this.children = children
  }
}

function createElement (type, props, children) {
  return new Element (type, props, children)
}

createElement ('ul', { class: 'list' }, [
  createElement ('li', { class: 'item' }, ['a']),
  createElement ('li', { class: 'item' }, ['b']),
  createElement ('li', { class: 'item' }, ['c']),
])
  • 虚拟dom


    虚拟dom和diff算法_第1张图片
    渲染函数创建出的虚拟dom
  • 真实dom


    将虚拟dom树转换成真实dom树
  • 虚拟dom的作用/优点
      在Web早期,页面的交互比较简单,不太需要频繁的操作DOM,随着时代的发展,页面上的功能越来越多,我们需要实现的需求也越来越复杂,DOM的操作也越来越频繁。通过js操作DOM的代价很高,因为会引起页面的重排重绘,增加浏览器的性能开销,降低页面渲染速度。
       有了虚拟dom之后,我们可以在虚拟节点映射到视图的过程之前,将虚拟节点与上一次渲染视图所使用的虚拟节点(oldVnode)做对比,找出真正需要更新的节点来进行DOM操作,避免了不必要的DOM操作,从而节省了浏览器的性能开销使得页面的渲染速度得到提升。


二. diff算法

  • 当数据发生变化时,vue是怎么更新节点的?
       我们先根据真实DOM生成一颗virtual DOM树,当virtual DOM某个节点的发生改变后会生成一个新的Vnode,然后新老节点进行对比,对比的过程就是调用名为patch的函数,patch函数会生成一个补丁包,这个补丁包就是用来描述新老节点改变的内容,然后将这个补丁打到真实dom上更新dom。
       在react进行patch时,是打包所有修改然后放入队列后集中处理,但是这样在早期浏览器上操作DOM时性能会有损失,因为 diff 过程中会遍历一次整棵树,patch 的时候又会遍历整棵树。而早期vue也是以这种形式对真实DOM进行patch,而现在vue中的patch是即时的,也就是 在diff的同时进行patch。 不过不管那种方式,现代浏览器对这样的DOM操作做了优化,二者已经并无太大差别。

虚拟dom和diff算法_第2张图片
  • diff的比较方式?
    diff算法在比较新老节点的时候,比较只会在同层级进行, 不会跨层级比较。

    虚拟dom和diff算法_第3张图片
    image.png

    层级相同的节点位置发生变化,diff时会复用这些节点而不是重新生成新的节点(通过节点的key来实现)
    虚拟dom和diff算法_第4张图片

    采用先序深度优先遍历
    虚拟dom和diff算法_第5张图片

    • patch补丁包
      1.patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点,在比较新老节点生成patch补丁包之前会先判断这两个节点是否值得深入比较
function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // some code 
    return vnode
}

sameVnode会根据新老节点的标签类型、key等确定这两个节点是否一致。如果两个节点都是一样的,那么就深入检查他们的子节点。如果两个节点不一样那就说明Vnode完全被改变了,就可以直接替换oldVnode。

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是的时候,type必须相同
  )
}

当确定两个节点值得比较之后就会把两个节点作为参数传入到patchVnode方法中

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el // 找到对应的真实dom
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return //如果Vnode和oldVnode同一个对象,那么直接return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) { //
        api.setTextContent(el, vnode.text) //如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。
    }else {
        updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) { //如果两者都有子节点,则执行updateChildren函数比较子节点
            updateChildren(el, oldCh, ch)
        }else if (ch){ //如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el
            createEle(vnode) //create el's children dom
        }else if (oldCh){ //如果oldVnode有子节点而Vnode没有,则删除el的子节点
            api.removeChildren(el)
        }
    }
}

updateChildren源码,oldVnode和vnode的子节点进行对比

  updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, 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
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   // 对于vnode.key的比较,会把oldVnode = null
            oldStartVnode = oldCh[++oldStartIdx] 
        }else if (oldEndVnode == 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)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        }else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode)
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }else {
           // 使用key时的比较
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                }else {
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

首先,在新老两个VNode节点的左右头尾两侧都有一个变量标记


虚拟dom和diff算法_第6张图片

在遍历时,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode会先进行两两比较,一共有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置。即:

  • oldStartVnode和newStartVnode匹配,位置不动,oldStartIdx,newStartIdx 指针后移。
  • oldEndVnode和newEndVnode匹配,位置不动,oldEndIdx,newEndIdx 指针前移。
  • oldStartVnode和newEndVnode匹配,oldStartVnode移动到newEndVnode所在位置,oldStartIdx指针前移,newEndIdx 指针后移。
  • oldEndVnode和oldStartVnode匹配,oldEndVnode移动到newStartVnode所在位置,oldEndIdx指针后移,newStartIdx 指针前移。

此时已完成了新旧节点首位子节点的匹配,倘若以上4种方式都没能匹配上,如果设置了key,就会用key进行比较,遍历剩下的节点,如果在newVnode中找到一致key的旧的VNode节点,并且同时满足sameVnode,patchVnode,那么这个节点将得到复用。
key 的作用 主要是 :
1.决定节点是否可以复用
2.建立key-index的索引,主要是替代遍历,提升性能
小提示:循环数据时尽量不使用index作为key,除非你能保证index的唯一性。

最后 通过 oldStartIdx > oldEndIdx ,来判断 oldCh 和 newCh 哪一个先遍历完成
oldStartIdx > oldEndIdx表示oldCh先遍历完,那么就将多余的vCh根据index添加到dom中去。
StartIdx > EndIdx表示vCh先遍历完,那么就在真实dom中将区间的多余节点删掉

附源码地址
https://github.com/vuejs/vue/blob/a702d1947b856cf3b9d6ca5fb27b2271a78a9a5b/src/core/vdom/patch.js#L70

你可能感兴趣的:(虚拟dom和diff算法)