虚拟DOM和diff算法

289 阅读14分钟

虚拟DOM

虚拟DOM和Diff

是什么

是一个JS对象,包含了 tagpropschildren 三个属性,是对DOM的抽象,更加轻量级的DOM描述。

<div id="app">
  <p class="text">hello world!!!</p>
</div>

上面的 HTML 转换为虚拟 DOM 如下:

{
  tag: 'div',
  props: {
    id: 'app'
  },
  chidren: [
    {
      tag: 'p',
      props: {
        className: 'text'
      },
      chidren: [
        'hello world!!!'
      ]
    }
  ]
}

因为 DOM 是树形结构,所以使用 JavaScript 对象就能很简单的表示。而原生 DOM 因为浏览器厂商需要实现众多的规范(各种 HTML5 属性、DOM事件),即使创建一个空的 div 也要付出昂贵的代价。虚拟 DOM 提升性能的点在于 DOM 发生变化的时候,通过 diff 算法比对 JavaScript 原生对象,计算出需要变更的 DOM,然后只对变化的 DOM 进行操作,而不是更新整个视图。

为什么需要

减少DOM操作频率

不仅仅是DOM相对较慢,更因为频繁变动DOM会造成浏览器的回流或者重绘,这些都是性能的杀手,因此我们需要这一层抽象,在patch过程中尽可能地一次性将差异更新到DOM中,这样保证了DOM不会出现性能很差的情况。

减少手动的DOM操作

手动操作DOM无法保证程序性能,多人协作的项目中如果review不严格,可能会有开发者写出性能较低的代码,另一方面更重要的是省略手动DOM操作可以大大提高开发效率。

跨平台

最初的目的,就是更好的跨平台,比如Node.js就没有DOM,如果想实现SSR(服务端渲染),那么一个方式就是借助Virtual DOM,因为Virtual DOM本身是JavaScript对象。

虚拟DOM不一定快

“使用虚拟DOM会更快”这句话并不一定适用于所有场景。例如:一个页面就有一个按钮,点击一下,数字加一,那肯定是直接操作DOM更快。使用虚拟DOM无非白白增加了计算量和代码量。即使是复杂情况,浏览器也会对我们的DOM操作进行优化,大部分浏览器会根据我们操作的时间和次数进行批量处理,所以直接操作DOM也未必很慢。

但使用虚拟DOM可以提高代码的性能下限,并极大的优化大量操作DOM时产生的性能损耗。同时这些框架也保证了,即使在少数虚拟DOM不太给力的场景下,性能也在我们接受的范围内。

h函数

主流虚拟DOM库,都有一个h函数,在Vue中就是 render 方法中的 createElement(用于创建虚拟DOM的方法),Vue 是使用 vue-loader 将模版转为 h 函数渲染的形式,最终 HTML 代码会被转译成 h 函数的渲染形式。h 函数接受是三个参数,分别代表是 DOM 元素的标签名type、属性props、子节点children,最终返回一个虚拟 DOM 的对象。

function h(type, config, ...children) {
  const props = {}

  let key = null

  // 获取 key,填充 props 对象
  if (config != null) {
    if (hasValidKey(config)) {
      key = '' + config.key
    }

    for (let propName in config) {
      if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS[propName]) {
        props[propName] = config[propName]
      }
    }
  }

  return vnode(
    type,
    key,
    props,
    flattenArray(children).map(c => {
      return isPrimitive(c) ? vnode(undefined, undefined, undefined, undefined, c) : c
    })
  )
}

虚拟DOM的更新和渲染

Virtual DOM 归根到底是JavaScript对象,我们得想办法将Virtual DOM与真实的DOM对应起来,也就是说,需要我们声明一个函数,此函数可以将vnode转化为真实DOM.

function createElm(vnode, insertedVnodeQueue) {
  let data = vnode.data
  let i
  // 省略 hook 调用
  let children = vnode.children
  let type = vnode.type

  /// 根据 type 来分别生成 DOM
  // 处理 comment
  if (type === 'comment') {
    if (vnode.text == null) {
      vnode.text = ''
    }
    vnode.elm = api.createComment(vnode.text)
  }
  // 处理其它 type
  else if (type) {
    const elm = vnode.elm = data.ns
      ? api.createElementNS(data.ns, type)
      : api.createElement(type)

    // 调用 create hook
    for (let i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)

    // 分别处理 children 和 text。
    // 这里隐含一个逻辑:vnode 的 children 和 text 不会/应该同时存在。
    if (isArray(children)) {
      // 递归 children,保证 vnode tree 中每个 vnode 都有自己对应的 dom;
      // 即构建 vnode tree 对应的 dom tree。
      children.forEach(ch => {
        ch && api.appendChild(elm, createElm(ch, insertedVnodeQueue))
      })
    }
    else if (isPrimitive(vnode.text)) {
      api.appendChild(elm, api.createTextNode(vnode.text))
    }
    // 调用 create hook;为 insert hook 填充 insertedVnodeQueue。
    i = vnode.data.hook
    if (i) {
      i.create && i.create(emptyNode, vnode)
      i.insert && insertedVnodeQueue.push(vnode)
    }
  }
  // 处理 text(text的 type 是空)
  else {
    vnode.elm = api.createTextNode(vnode.text)
  }

  return vnode.elm
}

上述函数其实工作很简单,就是根据 type 生成对应的 DOM,把 data 里定义的 各种属性设置到 DOM 上.

Diff算法

Vue的diff源码解读

diff的目的就是比较新旧Virtual DOM Tree找出差异并更新,diff是直接影响Virtual DOM 性能的关键部分。

要比较Virtual DOM Tree的差异,理论上的时间复杂度高达O(n^3),这是一个奇高无比的时间复杂度,很显然选择这种低效的算法是无法满足我们对程序性能的基本要求的。

好在我们实际开发中,很少会出现跨层级的DOM变更,通常情况下的DOM变更是同级的,因此在现代的各种Virtual DOM库都是只比较同级差异,在这种情况下我们的时间复杂度是O(n)。

Vue2.0阶段采用了snabbdom.js,采用双端比较算法,根据位置顺序进行移动的更新策略。

Vue3.0阶段借鉴inferno.js的算法进行优化,其中一个核心的思想就是利用LIS(最长递增子序列)的思想做动态规划,找到最小的移动次数。

diff大致流程

  1. 用JS对象模拟DOM(虚拟DOM)
  2. 把此虚拟DOM转成真实DOM并插入页面中(render)
  3. 如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
  4. 把差异对象应用到真正的DOM树上(patch)

Diff策略

按tree层级diff(level by level)

由于diff的数据结构是以DOM渲染为目标的模拟树状层级结构的节点数据,而在WebUI中很少出现DOM的层级结构因为交互而产生更新,因此VirtualDOM的diff策略是在新旧节点树之间按层级进行diff得到差异,而非传统的按深度遍历搜索,这种通过大胆假设得到的改进方案,不仅符合实际场景的需要,而且大幅降低了算法实现复杂度,从O(n^3)提升至O(n)

按类型进行diff

无论VirtualDOM中的节点数据对应的是一个原生的DOM节点还是vue或者react中的一个组件,不同类型的节点所具有的子树节点之间结构往往差异明显,因此对不同类型的节点的子树进行diff的投入成本与产出比将会很高昂,为了提升diff效率,VirtualDOM只对相同类型的同一个节点进行diff,当新旧节点发生了类型的改变时,则并不进行子树的比较,直接创建新类型的VirtualDOM,替换旧节点。

列表diff

当被diff节点处于同一层级时,通过三种节点操作新旧节点进行更新:插入,移动和删除,同时提供给用户设置key属性的方式调整diff更新中默认的排序方式,在没有key值的列表diff中,只能通过按顺序进行每个元素的对比,更新,插入与删除,在数据量较大的情况下,diff效率低下,如果能够基于设置key标识进行diff,就能够快速识别新旧列表之间的变化内容,提升diff效率。

diff流程

当数据发生变化的时候,会触发渲染 watcher 的回调函数,进而执行组件的更新过程;

组件的更新调用了 vm._update 方法,在这个方法里会执行patch函数,因为不是首次渲染,所以oldVnode不为空;

diff的过程就是调用patch函数,就像打补丁一样修改真实dom;

接下来会通过 sameVNode(oldVnode, vnode) 判断它们是否是相同的 VNode 来决定走不同的更新逻辑,如果两个 vnodekey 不相等,则是不同的;否则继续判断,对于同步组件,则判断 isCommentdatainput 类型等是否相同,对于异步组件,则判断 asyncFactory 是否相同。所以根据新旧 vnode 是否为 sameVnode,会走到不同的更新逻辑。

  1. 新旧节点不同,第一步创建新节点,以当前旧节点为参考节点,创建新的节点,并插入到 DOM 中;

    第二步更新父的占位符节点,找到当前 vnode 的父的占位符节点,先执行各个 moduledestroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 modulecreate 钩子函数。

    第三步删除旧节点,把 oldVnode 从当前 DOM 树中删除,如果父节点存在,则执行 removeVnodes 方法(递归遍历调用destroy函数,这里会触发组件周期执行 beforeDestroy & destroyed 两个钩子函数,最终是会调用平台的DOM API去真正移除节点)

  2. 新旧节点相同,会调用 patchVNode 方法,把新的 vnode patch 到旧的 vnode 上:

    • 当更新的 vnode 是一个组件 vnode 的时候,会执行 prepatch 的方法,拿到新的 vnode 的组件配置以及组件实例,并执行 updateChildComponent 方法,它会更新 vnode 对应的实例 vm 的一系列属性,包括占位符 vm.$vnode 的更新、slot 的更新,listeners 的更新,props 的更新等等。
    • 在执行完新的 vnodeprepatch 钩子函数,会执行所有 moduleupdate 钩子函数以及用户自定义 update 钩子函数;
    • 如果 vnode 是个文本节点且新旧文本不相同,则直接替换文本内容。如果不是文本节点,则判断它们的子节点;
    • 执行完 patch 过程后,会执行 postpatch 钩子函数,它是组件自定义的钩子函数,有则执行。

patch代码:

function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) { //判断两节点是否值得比较,值得比较则执行patchVnode
    	patchVnode(oldVnode, vnode)
    } else {  // 不值得比较,直接用vnode替换oldvnode
    	const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
    	let parentEle = api.parentNode(oEl)  // 父元素,真实dom
    	createEle(vnode)  // 根据Vnode生成新元素(真实元素)
    	if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
    	}
    }
    // some code 
    // patch最后会返回vnode,vnode和进入patch之前的不同,就是vnode.el,
    // 唯一的改变就是之前vnode.el = null, 而现在它引用的是对应的真实dom
    return vnode
}

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) // 当标签是<input>的时候,type必须相同
  )
}

/*
如果两个节点都是一样的,那么就深入检查他们的子节点。
如果两个节点不一样那就说明Vnode完全被改变了,就可以直接替换oldVnode。
确定两个节点值得比较之后我们会对两个节点指定patchVnode方法
*/
patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    // 引用一致,可以认为没有变化
    if (oldVnode === vnode) return
    // 文本节点的比较,需要修改,则会调用Node.textContent = vnode.text
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        //  两个节点都有子节点,而且它们不一样,这样我们会调用updateChildren函数比较子节点,这是diff的核心
    	if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
   // 新的节点有子节点,调用createEle(vnode),vnode.el已经引用了老的dom节点,createEle函数会在老dom节点上添加子节点
    	}else if (ch){
            createEle(vnode) //create el's children dom
        // 新节点没有子节点,老节点有子节点,直接删除老节点
    	}else if (oldCh){
            api.removeChildren(el)
    	}
    }
}

patchNode做了以下事情:

  • 找到对应的真实dom,称为el

  • 判断VnodeoldVnode是否指向同一个对象,如果是,那么直接return

  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为Vnode的文本节点。

  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点

  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el

  • 如果两者都有子节点,则执行updateChildren函数比较子节点:

    • Vnode的子节点VcholdVnode的子节点oldCh提取出来
    • oldChvCh各有两个头尾的变量StartIdxEndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldChvCh至少有一个已经遍历完了,就会结束比较。
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)
    }
}

怎么打的补丁?

  • 用一个变量来得到传递过来的所有补丁allPatches

  • patch方法接收两个参数(node, patches)

    • 在方法内部调用walk方法,给某个元素打上补丁
  • walk方法里获取所有的子节点

    • 给子节点也进行先序深度优先遍历,递归walk
    • 如果当前的补丁是存在的,那么就对其打补丁(doPatch)
  • doPatch打补丁方法会根据传递的patches进行遍历,判断补丁的类型来进行不同的操作:

    • 属性ATTR for in去遍历attrs对象,当前的key值如果存在,就直接设置属性setAttr; 如果不存在对应的key值那就直接删除这个key键的属性

    • 文字TEXT 直接将补丁的text赋值给node节点的textContent即可

    • 替换REPLACE 新节点替换老节点,需要先判断新节点是不是Element的实例,是的话调用render方法渲染新节点;

    ​ 不是的话就表明新节点是个文本节点,直接创建一个文本节点就OK了。

    ​ 之后再通过调用父级parentNode的replaceChild方法替换为新的节点

    • 删除REMOVE 直接调用父级的removeChild方法删除该节点

Vue中的Diff算法图解

图解

将它们取出来并分别用s和e指针指向它们的头child和尾child

现在分别对oldS、oldE、S、E两两做sameVnode比较,有四种比较方式,当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置:

  • 如果是oldS和E匹配上了,那么真实dom中的第一个节点会移到最后
  • 如果是oldE和S匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
  • 如果四种匹配没有一对是成功的,分为两种情况
    • 如果新旧子节点都存在key,那么会根据oldChild的key生成一张hash表,用S的key与hash表做匹配,匹配成功就判断S和匹配节点是否为sameNode,如果是,就在真实dom中将成功的节点移到最前面,否则,将S生成对应的节点插入到dom中对应的oldS位置,S指针向中间移动,被匹配old中的节点置为null。
    • 如果没有key,则直接将S生成新的节点插入真实DOM(ps:这下可以解释为什么v-for的时候需要设置key了,如果没有key那么就只会做四种匹配,就算指针中间有可复用的节点都不能被复用了)

比较步骤:

old:[a,b,d]
new:[a,c,d,b]
  • 第一步
oldS = a, oldE = d;
S = a, E = b;

oldSS匹配,则将dom中的a节点放到第一个,已经是第一个了就不管了,此时dom的位置为:a b d

  • 第二步
oldS = b, oldE = d;
S = c, E = b;

oldSE匹配,就将原本的b节点移动到最后,因为E是最后一个节点,他们位置要一致,这就是上面说的:当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置,此时dom的位置为:a d b

  • 第三步
oldS = d, oldE = d;
S = c, E = d;

oldEE匹配,位置不变此时dom的位置为:a d b

  • 第四步
oldS++;
oldE--;
oldS > oldE;

遍历结束,说明oldCh先遍历完。就将剩余的vCh节点根据自己的的index插入到真实dom中去,此时dom位置为:a c d b

一次模拟完成。

这个匹配过程的结束有两个条件:

  • oldS > oldE表示oldCh先遍历完,那么就将多余的vCh根据index添加到dom中去(如上图)
  • S > E表示vCh先遍历完,那么就在真实dom中将区间为[oldS, oldE]的多余节点删掉

设置Key和不设置Key的区别

不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。