Snabbdom:虚拟DOM和Diff算法

·  阅读 434
Snabbdom:虚拟DOM和Diff算法

因为要学习Vue源码,它的diff算法是绕不过的一个知识点。学习过程中,有了解到 Snabbdom 这个库,Vue对其应该有所借鉴改良,故而了解一下可以更好的了解Vue的diff算法

Snabbdom

著名的虚拟DOM库,是diff算法的鼻祖

什么是 Diff 算法

diff 算法就是用来计算出 Virtual Dom 中被改变的部分。因为Vue React 框架都是 只用改变状态类影响视图自动更新,因此当数据状发生变化时候要计算出 对应的最小的变化的部分,而不是重新渲染政整个页面,以此达到节约性能的目的。

Diff 效果展示

描述:页面内容本来是 [A、B、C、D、E]数组中的 五个元素来渲染, 当点击按钮时候数据变为[Q、A、B、C、D、E] ,页面元素会相应发生变化并且使用的是最小的变化。怎么判断使用的最小变化。既然是最小变化,那相同的 A、B、C、D、E 节点肯定是没有做任何处理的,如果我们把这些节点在页面渲染初始化以后,通过控制台改变为任意内容,点击按钮后这些节点内容没有发生任何处理,我们就可以知道这些节点被diff处理跳过了,没有做任何处理,从而得出数据发生变化时,页面也采取了最小性能的更新操作的结论。

Tips: 如果不在乎性能,当然可以全部清空,然后用新的数据重新渲染。

效果如下图

Filmage 2021-06-28_154650.gif

什么是虚拟DOM

用 JS 对象描述 DOM 的层次结构。DOM 中的一切属性都在虚拟 DOM 中有对应的属性。

上面看到了Diff的过人之处,那么怎么又多了一个名词:虚拟DOM。因为这里涉及到一个新老节点的比较算法。那就为了性能就不能直接用真实 DOM 进行比较,需要转换为虚拟DOM. 虚拟DOM说白了就是 js对象,只不过他是按照一定规则来描述dom元素的结构,所以被称为虚拟DOM. 它的结构大概类似于下面结构。

页面中元素如下

<div id="app">
  <p class="text">hello world!!!</p>
</div>
复制代码

转换为虚拟DOM以后就是这样

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

我看了一些文章,结构大致类似,但是细节上有出入。不用在意,如果自己书写这个diff算法的话,这个由真实DOM变为虚拟DOM的对应规则完全可以自定义。

分析

  1. 虚拟DOM如何被渲染函数(h函数)产生

    h函数用来产生虚拟节点(vnode)

    1. 比如这样调用h函数

    2. h("li",{ key:'A',class: { class: 'mine' } }, "A")
      复制代码
    3. 将得到这样的虚拟节点

      image.png

    4. 它对应的真实DOM节点类似:

      <li class="mine">A</li>
      复制代码
    5. 一个虚拟节点有哪些属性呢

      {
        children: undefined,     // 子项
        data: {},                // 属性等
        elm: undefined,          // 对应的真实dom节点
        key: undefined,          // 唯一key属性
        sel: div,                // 表签名
        text: '我是一个空盒子'     // 内部文本内容
      }
      复制代码
  2. Diff 算法原理

    1. 核心函数patch
    2. 它接收两个参数:第一个参数可以为旧的虚拟节点或者元素选择器选择的元素,第二个参数为新的虚拟节点
    3. 当第一个参数为元素选择器选择的元素时候,直接将第二个参数的虚拟节点进行处理渲染页面即可
    4. 当第一个参数为虚拟节点时,需要和第二个虚拟节点参数进行比对,在其中进行处理不同的节点处理逻辑
  3. 虚拟DOM如何通过diff变为真正的DOM的

    实际上,虚拟DOM变回真正的DOM, 是涵盖在 diff 算法里面的

代码实现

h函数的实现

形态分析:

简单的处理,有三种形态(源码中有参数长度判断处理,这里简单为三个参数)

  1. 形态①: js h("div", {} ,"文字")
  2. 形态②: js h("div", {} , [])
  3. 形态③: js h("div", {} , h())

代码实现

// 首先有一个函数,根据接收到的参数,返回一个虚拟节点对象,如下
function vNode(sel, data, children, text, elm){
  return {
    sel, 
    data, 
    children, 
    text,
    elm, 
    key: data.key
  }
}

// 低配版的h函数,必须接受三个参数,缺一不可
function h(sel,data,c){
  // 检查参数个数
  if(arguments.length !== 3){
    throw new Error('参数必须是三个')
  }
  // 检查c 的类型    
  if(typeof c === "string" || typeof c === "number"){
    //  形态1, 此时直接调用 vNode 函数返回结果
    return vNode(sel, data, undefined,c,undefined)
  }else if(Array.isArray(c)){
    // 形态2
    let children = []
    for (let index = 0; index < c.length; index++) {
      const element = c[index]; // 这里又重新调用了h函数,返回结果
      // element 必须是一个对象
      if(typeof element !=="object" && element.hasOwnProperty('sel')){
        throw new Error("传入的数组参数中有某项不是h函数")
      }
      // 这里不用执行 element,因为调用 hasOwnProperty 时候已经执行了 h 函数,此时的 element 为虚拟节点
      children.push(element)
    }
    return vNode(sel, data, children, undefined, undefined)
  }else if(typeof c === "object" && c.hasOwnProperty('sel')){
    // 形态3
    // 说明传入的是唯一的children, 不用执行c, c 在上面调用 hasOwnProperty 时候已经调用了h函数,并返回为虚拟节点
    let children = c
    return vNode(sel, data,children,undefined,undefined)
  }else {
    throw new Error("参数格式错误")
  }
}
复制代码

patch函数的实现

心得体会

由最开始的 diff gif图可以看出来,真的是最小量更新,当然 key 很重要,因为key 是这个节点的唯一标识,告诉 diff 算法,在更新前后它是同一个DOM

另外, 只有是同一个节点时候,才进行精细化比较,否则就是暴力的删除旧的,插入新的。延伸问题:如何定义是同一个虚拟节点??答:选择器相同并且key相同

只进行同层比较,不进行跨层比较 。即使是同一片虚拟节点,但是跨层了,对不起,精细化比较不 经过你,而是暴力删除旧的,插入新的

疑问: diff 并不是无微不至的,所以这样做真的不会影响效率吗??

答: 部分操作在实际vue开发中,上述2,3 基本不会遇到极端额情况,所以这是合理的优化机制

比如一般没有人会写如下的代码片段

<div>
  <section v-if="isFlag">
    <p>A</p>
    <p>B</p>
    <p>C</p>
  </section>
  <p v-if="!isFlag">A</p>
  <p v-if="!isFlag">B</p>
  <p v-if="!isFlag">C</p>
</div>
复制代码

步骤分析:

​patch( oldVNode, newVNode )

  1. patch 函数被调用时候,先判断 oldVnode是不是虚拟节点,如果是DOM节点,就将 oldVnode 包装为虚拟节点。

  2. 再判断,oldVNode, newVNode 是不是同一个节点,怎么算是同一个节点呢,之前有提到过: 标签 和 key 相等;

  3. 如果 oldVNode, newVNode不是同一个节点,就暴力删除旧的,插入新的

    注意:创建节点时候,它的子节点是需要递归创建插入的

  4. 如果 oldVNode, newVNode 是同一个节点,就需要进行精细化比较

  5. 继续精细化比较判断处理

  6. oldVNode, newVNode是不是内存中同一个对象,如果是就略过,什么也不用做

  7. 如果不是,在进行判断 newVNode中有没有text属性

  8. 如果newVNode中有text属性,在判断 newVNode中的text属性和 oldVNode 中的 text 属性是否相同

    1. 如果相同,就什么也不错
    2. 如果不同,就把oldVNode.elm中innerText 变为 newVNode 的 text 属性 (**注意,这里假如 oldVNode中有children属性而没有text属性,那么也没事,因为innerText一旦改变为 newVNode 的 text ,老节点中的children属性会自动删除 **)
  9. 如果 newVNode中没有text属性,意味着 newVNode 有children属性,在进行判断 oldVNode 中有没有children属性

  10. 如果 oldVNode 没有children属性,意味着 oldVNode 有text属性,此时要清空 oldVNode.elm 中的 innerText 并且把 children 子节点转换的 子DOM节点插入到 oldVNode.elm 中

  11. 如果 oldVNode 有 children 属性,这里就是 oldVNode newVNode都有children节点,此事要进行终结判断比较。

  12. 这里就需要提到四中命中查找了。newVNode 的头和尾 :新前和新后,oldVNode的头和尾:旧前和旧后。 为什么这种算法优秀,因为它符合人们的编程习惯。

  13. 定义四个指针 newStartIndex, newEndIndex, oldStartIndex, oldEndIndex , 同时四个指针对应四个节点:newStartNode,、newEndNode、oldStartNode、oldEndNode; 当 oldStartIndex<=oldEndIndex && newStartIndex <= newEndIndex 时候就进行while循环,

    ​ 比较顺序依次为 新前新后、新后旧后、新后旧前、新前旧后,命中一个就直接进行下次循环,否则进行下一个命中,如果四个都没有命中,在进行单独处理

    1. 新前新后:判断 newStartNode 和 oldStartNode 是不是同一个节点

      1. 如果是: 就调用 patchVNode( oldStartNode, newStartNode ) 函数进行相应节点处理,并且 newStartIndex++ oldStartIndex++ ,对应的newStartNode 和oldStartNode也要进行更新
      2. 如果不是:就进行第二步,比较 新后、旧后
    2. 新后旧后:判断 newEndNode 和 oldEndNode 是不是同一个节点

      1. 如果是:就调用 patchVNode(oldEndNode, newEndNode) 函数进行相应节点处理,并且 --oldEndIndex 和 --newEndIndex,对应的 oldEndNode 和 newEndNode也要进行更新
      2. 如果不是,就进行第三步,比较 新后、旧前
    3. 新后旧前:判断 newEndNode 和 oldStartNode 是不是同一个节点

      1. 如果是:就调用 patchVNode(oldStartNode, newEndNode) 函数进行相应节点处理,并移动旧前指向的节点到未处理的节点的后面(也就是oldEndVNode.elm的后面),注意:insertBefore 已有的节点会 删除原来位置的节点信息,并且 newEndIndex-- 和 ++oldStartIndex,对应的 oldStartNode 和 newEndNode 也要进行更新
      2. 如果不是,就进行第四步,比较 新前、旧后
    4. 新前旧后:判断 newStartNode 和 oldEndNode 是不是同一个节点

      1. 如果是:就调用 patchVNode(oldEndNode, newStartNode) 函数进行相应节点处理,并移动旧后节点到旧的未处理的及节点的前面(也就是oldStartVNode.elm的前面),注意:insertBefore 已有的节点会 删除原来位置的节点信息,并且 oldEndIndex-- 和 ++newStartIndex,对应的 oldEndNode 和 newStartNode 也要进行更新
      2. 如果不是,就需要单独处理,进行第五步
    5. 如果都没有命中,就需要判断,当前 newStartNode是否在老节点中出现过

      1. 如果不是,说明是新增加的,那么就把这 newStartNode 创建为 真实dom并插入到 oldStartVNode.elm 之前,然后更新指针 ++newStartIndex 以及 newStartNode
      2. 如果是,说明是位置发生了变化,那就取出 当前 newStartNode 在 oldChildren 中对应的节点,并进行比较更新(也就是找到 newStartNode 在 oldChildren中下标比如为 k, 调用 patchVNode( oldChildren[k], newStartNode ) 进行节点的更新处理),然后把当前 oldChildren 下标为 k 的这项设置为 undefined,下次循环时候如果遇到 undefined 就增加判断提跳过处理,另外还要 在当前 oldStartVNode.elm前插入这个节点信息(也就是插入到未处理的节点的前面),然后更新指针 ++newStartIndex 以及 newStartNode
      3. 疑问回答:为什么那项置为undefined以后,还有进行插入到未处理的节点的前面。因为更新信息后只是更新了 旧节点中的信息并没有移动位置,所以在老下标处的节点置为undefined以后,还有把之前存下来的更新后的节点信息移动到未处理的节点的前面
    6. 重复以上操作知道循环条件 oldStartIndex<=oldEndIndex && newStartIndex <= newEndIndex 不满足

    7. 循环结束后,还要进行判断

      1. 如果 新前 小于等于 新后 说明这些节点需要新增,新增位置是未处理的节点的前面,循环当期 newStartIndex和 newEndIndex之间节点,添加到 oldStartVNode.elm 的前面
        1. 疑问:为什么是 oldStartVNode.elm 的前面??
      2. 如果 旧前 小于等于 旧后说明这些节点需要删除

      image.png

流程图截图

上面看不清楚的可以参考下图:

下载.png

完成函数的编写

function patch(oldVNode, newVNode){
  // 判断传入的第一个参数,是Dom节点还是虚拟节点
  if(oldVNode.sel == "" || oldVNode.sel == undefined){
     // 传入的第一个参数是DOM节点,此时要包装为虚拟节点
    oldVNode = vNode(oldVNode.tagName.toLowerCase(), {}, [], undefined, oldVNode)
  }
  // 判断oldVNode 和newVnode 是不是同一个节点
  if(checkSameNode(newVNode, oldVNode)){
    patchVNode(oldVNode, newVNode)
  }else{
    // 不是同一个节点,需要 暴力插入新的,删除旧的
    // 在这里进行页面插入到老节点之前
    let newVNodeElm = creatElement(newVNode)
    if(oldVNode.elm.parentNode && newVNodeElm){
      oldVNode.elm.parentNode.insertBefore(newVNodeElm, oldVNode.elm)
      // 删除老节点
      oldVNode.elm.parentNode.removeChild(oldVNode.elm)
    }
  }
}

function vNode(sel, data, children, text, elm){
  return {
    sel, data, children, text, elm, key: data.key
  }
}

// 判断是否是同一个节点
function checkSameNode(a,b){
  return a.sel === b.sel && a.key === b.key
}

// 真正创建节点,vNode 是孤儿节点不进行插入
export default function createElement(vNode){
  let domNode = document.createElement(vNode.sel)
  // 有子节点还是有文本?
  if (vNode.text !== '' && (vNode.children == undefined || vNode.children.length === 0 )){
    // 它内部是文字
    domNode.innerText = vNode.text
  }else if(Array.isArray(vNode.children) && vNode.children.length > 0){
    // 它内部是子节点,需要进行递归
    for (let index = 0; index < vNode.children.length; index++) {
      // 得到当前这个 children
      const element = vNode.children[index];
      // 创建它的dom, 一旦调用createElement 意味着,创建出dom了,并且它的elm属性指向了
      // 创建出的dom,但是还没有上树,是一个孤儿节点
      let elementDom = createElement(element)
      domNode.appendChild(elementDom)
    }
  }
  // 补充elm属性
  vNode.elm = domNode
  // 返回 elm
  return vNode.elm
}

// 对比同一个虚拟节点
function patchVNode(oldVNode, newVNode){
  // 判断新旧VNode 是不是同一个对象
  if(oldVNode === newVNode){
    console.log("是同一个对象")
    return 
  }
  // 判断 newVNNode 里面有没有 text属性
  if(newVNode.text !== undefined && (newVNode.children === undefined || newVNode.children.length == 0)){
    // newVNode 有text属性
    if(newVNode.text !== oldVNode.text ){
      oldVNode.elm.innerText = newVNode.text
    }
  }else {
    // newVNode 没有 text 属性,即有children 属性
    // 判断 oldVNode 有没有 children
    if(oldVNode.children !== undefined && oldVNode.children.length >0){
      // 老的有children 此时就是最复杂的情况,就是 newVNode, oldVNode 都有children
      updateChildren(oldVNode.elm, oldVNode.children, newVNode.children)
    }else{
      // oldVNode 没有children,newVNode 有children
      oldVNode.elm.innerText = ""
      newVNode.children.forEach(item => {
        let domNode = createElement(item)
        oldVNode.elm.appendChild(domNode)
      })
    }
  }
}

// oldVNode 和 newVNode 都拥有 children属性
function updateChildren(parentElm, oldChildren, newChildren){
  // 旧前
  let oldStartIndex = 0
  // 新前
  let newStartIndex = 0
  // 旧后
  let oldEndIndex = oldChildren.length - 1
  // 新后
  let newEndIndex = newChildren.length - 1
  // 旧前节点
  let oldStartVNode = oldChildren[0]
  // 旧后节点
  let oldEndVNNode = oldChildren[oldEndIndex]
  // 新前节点
  let newStartVNode = newChildren[0]
  // 新后节点
  let newEndVNode = newChildren[newEndIndex]
  // 老节点中的key集合
  let keyMap
  while(oldStartIndex<=oldEndIndex && newStartIndex <= newEndIndex){
    // 如果旧的开始节点不存在,也就是之前设置了 undefined
    if(oldStartVNode == null){ // 注意 undefined == null  为true
      oldStartVNode = oldChildren[++oldStartIndex]
    }else if(oldEndVNNode == null){
      oldEndVNNode = oldChildren[--oldEndIndex]
    }else if(newStartVNode == null){
      newStartVNode = newChildren[++newStartIndex]
    }else if(newEndVNode == null){
      newEndVNode = newChildren[--newEndIndex]
    }
    if(checkSameNode(newStartVNode, oldStartVNode)){
      // 新前与旧前是同一个节点
      console.log("①新前与旧前相同")
      patchVNode(oldStartVNode, newStartVNode)
      oldStartVNode = oldChildren[++oldStartIndex]
      newStartVNode = newChildren[++newStartIndex]
    }else if(checkSameNode(newEndVNode, oldEndVNNode)){
      // 新后与旧后进行比较,是同一个节点
      console.log("②新后与旧后相同")
      patchVNode(oldEndVNNode, newEndVNode)
      oldEndVNNode = oldChildren[--oldEndIndex]
      newEndVNode = newChildren[--newEndIndex]
    }else if(checkSameNode(newEndVNode, oldStartVNode)){
      // 新后与旧前是同一个节点
      console.log("③新后与旧前相同")
      patchVNode(oldStartVNode, newEndVNode)
      // 当新后与旧前命中的时候,此时需要移动节点,移动 新前 指向的这个节点到老节点的旧后的后面
      // TODO疑问:为什么这里不用设置undefined? insertBefore移动后 节点会被删除是否有影响??
      parentElm.insertBefore(oldStartVNode.elm, oldEndVNNode.elm.nextSibling)
      oldStartVNode = oldChildren[++oldStartIndex]
      newEndVNode = newChildren[--newEndIndex]
    }else if(checkSameNode(newStartVNode, oldEndVNNode)){
      // 新前与旧后是同一个节点
      console.log("④新前与旧后相同")
      // 此时要移动节点,移动新前节点到老节点的旧前的前面
      patchVNode(oldEndVNNode, newStartVNode)
      // TODO疑问:为什么这里不用设置undefined? insertBefore移动后 节点会被删除是否有影响??
      // 注意 insertBefore 已有的节点会 删除原来位置的节点信息
      parentElm.insertBefore(oldEndVNNode.elm, oldStartVNode.elm)
      oldEndVNNode = oldChildren[--oldEndIndex]
      newStartVNode = newChildren[++newStartIndex]
    }else {
      console.log("都没有命中")
      // 继续看看有没有剩下的
      if(!keyMap){
        keyMap = {}
        for (let index = oldStartIndex; index < oldEndIndex; index++) {
          const key = oldChildren[index].key
          if(key !== undefined){
            keyMap[key] = index
          }
        }
      }
      // 寻找当前这项(newStartIndex)这项在 keyMap 中的映射的位置序号
      const idxInOld = keyMap[newStartVNode.key]
      if(idxInOld == null){
        // 判断,如果 idxInOld 是 undefined 表示他是全新的项目
        parentElm.insertBefore(createElement(newStartVNode), oldStartVNode.elm)
      }else{
        console.log("如果不是 undefined 说明不是全新的项目,需要移动")
        // 如果不是 undefined 说明不是全新的项目,需要移动
        const elmToMove = oldChildren[idxInOld]
        patchVNode(elmToMove, newStartVNode)
        // 把这项设置为 undefined, 表示已经处理完这项
        oldChildren[idxInOld] = undefined
        // 移动,调用 insertBefore 也可以实现移动 
        // 移动到 oldStartIndex 前面
        parentElm.insertBefore(elmToMove.elm, oldStartVNode.elm)
      }
      newStartVNode = newChildren[++newStartIndex]
    }
  }
  // 循环结束时候,新前 小于等于 新后说明这些节点需要新增
  // 在新前前插入 需要新增的节点
  if(newStartIndex <= newEndIndex){
    console.log('新节点需要有新增的')
    for (let index = newStartIndex; index <= newEndIndex; index++) {
      parentElm.insertBefore(createElement(newChildren[index]), oldStartVNode.elm)
    }
  }else if(oldStartIndex <= oldEndIndex){
    console.log("旧节点需要有删除的")
    // 循环结束时候,旧前 小于等于 旧后说明这些节点需要删除
    for (let index = oldStartIndex; index <= oldEndIndex; index++) {
      // 这里有标记为 undefined 的节点
      oldChildren[index] && parentElm.removeChild(oldChildren[index].elm)
    }
  }
}

复制代码

结束

参考资料

Vue源码解析系列课程之虚拟DOM和diff算法

推荐阅读

神仙朱的【Vue原理】Diff - 源码版 之 Diff 流程

分类:
前端
标签: