了解Virtual DOM 和 diff算法

986 阅读4分钟

Virtual DOM

Virtual DOM是什么

Virtual DOM 本质上是一个js对象, 拥有tag props text children等属性.用这些对象属性来描述DOM节点的属性. 最后可以通过一些列的操作这个js对象转化成真实dom. 下面就是一个虚拟dom对象

var a = {
  tag: 'div',
  props: {},
  children: [
    {tag: 'div', props: {}, text: 'hello world'}
  ]
}

// <div>
//   <div> hello world </div>
// </div>

为什么需要Virtual DOM

  1. 提高渲染性能。 一个真实的dom元素是非常庞大的,当我们大量频繁的操作dom,会造成性能问题。而虚拟dom能够通过diff算法对视图进行合理的更新
  2. 跨平台性。 Virtual DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node等。

如果你想更加了解虚拟dom. 你可以去看看snabbdom. vue中的虚拟DOM 就借鉴了 snabbdom

后文说的节点全部指的是虚拟节点

vue中怎么进行虚拟dom和diff算法

Diff算法的作用

当我们修改数据的时候,就去操作整个dom.这显然是非常消耗性能的。我们能不能够对比前后需要修改的变化。从而针对性的去修改dom。这样就能减少浏览器的重绘重排

diff的比较方式

在我们采用diff算法比较新旧两个节点,只会在同层级比较,不会跨级比较 具体可以看图

image.png

image.png 我们有如下第一个html转化成第二个html

<div>
  <ul>
      <li>A</li>
      <li>B</li>
      <li>C</li>
  </ul>
<div>
模版编译,转化成如下 
h('h1',{},[
  h('div',{key: 'A'}, 'A'),
  h('div',{key: 'B'}, 'B'),
  h('div', {key: 'C'}, 'C')
])

<div>
  <ul>
      <li>C</li>
      <li>B</li>
      <li>A</li>
  </ul>
</div>
模版编译,转化成如下
h('h1', {},[
  h('div',{key: 'C'}, 'C'),
  h('div',{key: 'B'}, 'B'),
  h('div', {key: 'A'}, 'A'),
])
// diff算法只会div和div进行比较,ul和ul进行比较. 不可能div和ul进行比较.

h函数

h函数的作用主要是把真正的节点转化成虚拟节点 这里我们实现一个简单的h函数。 h函数必须传递三个参数,为了代码简单, 只实现核心功能(vue中的h函数可以传递任意参数)

import vnode from './vnode.js'
// console.log(vnode(div))
export default function h(sel, data, c) {
  if(arguments.length !== 3) {
    throw new Error("请输入三个参数")
  }
  // 检查参数c的类型
  if(typeof c === 'string' || typeof c === 'number') {
    return vnode(sel, data, undefined, c, undefined)
  }else if (Array.isArray(c)) {
    let children = []
    for(let i=0;i<c.length;i++) {
      if( !typeof c[i] === 'object' && c.hasOwnProperty(sel)){
        throw new Error("请输入正确的参数")
      }
      children.push(c[i])
    }
    return vnode(sel, data, children, undefined, undefined)
  }else if(typeof c === 'object' && c.hasOwnProperty(sel)) {
    return vnode(sel, data, [c], undefined, undefined)
  }else {
    throw new Error("请输入正确的参数")
  }
}

VNode函数

VNode 生成真正的虚拟dom

export default function(sel,data,children,text,elm) {
  const key = data.key
  return {
    sel,data,children,text,elm,key
  }
}

经过上述操作,我们就把节点转化成虚拟dom

image.png

diff算法的大体思路

步骤1. 首先判断两个节点是否是同一个节点(tag,key相同)。如果不同,直接替换所有
步骤2. 判断两个节点完全相同, 如果相同 就不做任何操作
步骤3. 判断新节点是否有子元素。如果没有,直接替换text
步骤4. 判断旧节点是否含有子元素。如果没有,用新节点的子元素直接替换旧节点的子元素
步骤5. 如果旧节点也有子元素。那么就开始精细化比较(也是diff算法的核心部分)

image.png

patch方法

点击按钮,将两个虚拟节点进行比较,patch方法只是做一层简单的拦截,最开始的一个元素都不一样,直接暴力替换,如果一样,那么就调用patchvnode方法,进行上面的步骤 1 2 3 4

import vnode from './vnode.js'
import createElement from './createElement.js'
import patchVnode from './patchVnode.js'
export default function patch(oldVnode, newVnode) {
  if(oldVnode.sel == '' || oldVnode.sel === undefined) {
    // 说明是个真实的dom对象
   oldVnode = vnode(oldVnode.tagName.toLowerCase(),{},[],undefined, oldVnode)
  }
  if(oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    patchVnode(oldVnode, newVnode)
  }else {
    // 不是同一个节点
  let newVnodeElm = createElement(newVnode)
    if(oldVnode.elm != undefined) {
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
    }
    oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    // console.log("1111")
  }
}

patchVnode方法

这里patchVnode主要执行的是我们的 diff 1 2 3 4步骤
步骤5(后文有讲 就是updateChildren方法)
patchVonde方法主要是进行第一层的比较, 如果下面都有children,就走到步骤5(updateChildren)

import createElement from './createElement.js'
import updateChildren from './updateChildren.js'
export default function patchVnode(oldVnode, newVnode) {
  if(oldVnode === newVnode) return 
    if(newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
         // 新节点有text
         if(newVnode.text != oldVnode.text) {
          oldVnode.elm.innerText = newVnode.text
         }
    }else {
      if(oldVnode.children != undefined && oldVnode.children.length > 0) {
      // 如果都有子元素,进行精细化比较
        updateChildren(oldVnode.elm, oldVnode.children, newVnode.children)
      }else {
        oldVnode.elm.innerHTML = ''
        for(let i = 0;i<newVnode.children.length;i++) {
          let dom = createElement(newVnode.children[i])
          oldVnode.elm.appendChild(dom)
        }
      }
    }
}

createElement 方法

这个方法是用来将我们的虚拟节点转化成真实dom

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){
    //  createElement()
    for (let i = 0; i < vnode.children.length; i++) {
      const ch = vnode.children[i]
      let chDom = createElement(ch)
      domNode.appendChild(chDom)
    }
  }
  vnode.elm = domNode
  return vnode.elm
}

精细化比较(步骤5)

我们把旧节点的一个元素称为旧前节点 旧节点的最后一个元素称为旧后节点
新节点的一个元素称为新前节点 新节点的最后一个元素称为新后节点
精细化比较主要分为五种情况

  1. 旧前节点 === 新前节点
  2. 旧后节点 === 新后节点
  3. 新后节点 === 旧前节点
  4. 新前节点 === 旧后节点
  5. 以上四种情况都不满足,遍历旧节点所有子元素,寻找是否有新节点的元素
    以上五种情况顺序执行。满足其中一种情况,后续的就不在比较,就会去下一个节点进行比较

情况一

旧前节点=== 新前节点

image.png

情况二

旧后节点 === 新后节点

image.png

情况三

新后节点 === 旧前节点

image.png

情况四

新前节点 === 旧后节点

image.png

情况五

以上四种情况都不满足

image.png

updateChildren

import patchVnode from "./patchVnode.js";
import createElement from "./createElement.js";
function checkSameVnode(a, b) {
  return a.sel === b.sel && a.key === b.key;
}
export default function updateChildren(parentElm, oldCh, newCh) {
  // console.log('我是update')
  // 旧前
  let oldStartIdx = 0;
  // 新前
  let newStartIdx = 0;
  // 旧后
  let oldEndIdx = oldCh.length - 1;
  // 新后
  let newEndIdx = newCh.length - 1;
  // 旧前节点
  let oldStartVnode = oldCh[0];
  // 旧后节点
  let oldEndVnode = oldCh[oldEndIdx];
  // 新前节点
  let newStartVnode = newCh[0];
  // 新后节点
  let newEndVnode = newCh[newEndIdx];

  let keyMap = null;
  // console.log('33333')
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // console.log(oldStartVnode, )
    // 新前和旧前相同
    if (oldCh[oldStartIdx] === void 0 || oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx];
    } else if (oldEndVnode === null || oldCh[oldEndIdx] === void 0) {
      oldEndVnode = oldCh[++oldEndIdx];
    } else if (newStartVnode === null || newCh[newStartIdx] == void 0) {
      newStartVnode = newCh[++newStartIdx];
    } else if (newEndVnode === null || newCh[newEndIdx] === void 0) {
      newEndVnode = newCh[++newEndIdx];
    } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
      // console.log('4454')
      patchVnode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
      // console.log('1111111')
      // 新后与旧后
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
      //  新后与旧前
      patchVnode(oldStartVnode, newEndVnode);
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
      //  新前与旧后
      patchVnode(oldEndVnode, newStartVnode);
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      //  都没有匹配
      if (!keyMap) {
        keyMap = {};
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
          const key = oldCh[i].key;
          if (key !== void 0) {
            keyMap[key] = i;
          }
        }
      }
      const idxInOld = keyMap[newStartVnode.key];
      if (idxInOld === void 0) {
        // 全新
        parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
      } else {
        //  不是全新
        const eleToMove = oldCh[idxInOld];
        patchVnode(eleToMove, newStartVnode);
        oldCh[idxInOld] = void 0;
        parentElm.insertBefore(eleToMove.elm, oldStartVnode.elm);
      }
      newStartVnode = newCh[++newStartIdx];
    }
  }
  //  结束的时候
  if (newStartIdx <= newEndIdx) {
    //  还有剩余节点需要处理

    for (let i = newStartIdx; i <= newEndIdx; i++) {
      parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
    }
  } else if (oldStartIdx <= oldEndIdx) {
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if (oldCh[i]) {
        parentElm.removeChild(oldCh[i].elm);
      }
    }
  }
}

完整代码实现请看主页的github