黑马程序员技术交流社区

标题: 虚拟DOM详解(二) [打印本页]

作者: 小江哥    时间: 2019-8-23 11:36
标题: 虚拟DOM详解(二)
第一篇文章中主要讲解了虚拟DOM基本实现,简单的回顾一下,虚拟DOM是使用json数据描述的一段虚拟Node节点树,通过render函数生成其真实DOM节点。并添加到其对应的元素容器中。在创建真实DOM节点的同时并为其注册事件并添加一些附属属性。
在上篇文章中也曾经提到过,当状态变更的时候用修改后的新渲染的的JavaScript对象和旧的虚拟DOM的JavaScript对象作对比,记录着两棵树的差异,把差别反映到真实的DOM结构上最后操作真正的DOM的时候只操作有差异的部分的更改。然而上篇文章中也只是简简单单的提到过一句却没有进行实质性的实现,这篇文章主要讲述一下虚拟DOM是如何做出更新的。那就开始吧...O(∩_∩)O
在虚拟DOM中实现更新的话是使用DIFF算法进行更新的,我想大多数小伙伴都应该听说过这个词,DIFF是整个虚拟DOM部分最核心的部分,因为当虚拟DOM节点状态发生改变以后不可能去替换整个DOM节点树,若是这样的话会出现打两个DOM操作,无非是对性能的极大影响,真的如此的话还不如直接操作DOM来的实际一些。
第一篇文章中是通过render对虚拟DOM节点树进行渲染的,但是在render函数中只做了一件事情,只是对虚拟DOM进行了新建也就是初始化工作,其实回过头来想一下,无论是新建操作还是修改操作,都应该通过render函数来做,在react中所有的DOM渲染都是通过其中的render函数完成的,那么也就得出了这个结论。
[AppleScript] 纯文本查看 复制代码
// 渲染虚拟DOM
//   虚拟DOM节点树
//   承载DOM节点的容器,父元素
function render(vnode,container) {
// 首次渲染
mount(vnode,container);
};
既然更新和创建操作都是通过render函数来做的,在方法中又应该如何区分当前的操作到底是新建还是更新呢?毕竟在react我们并没有给出明确的标识来告诉其方法,当前是进行的哪个操作。在执行render函数的时候有两个参数,一个是传入的vnode节点树,还有一个就是承载真实DOM节点的容器,其实我们可以把其虚拟DOM节点树挂载在其容器中,若容器中存在其节点树则是更新操作,反之则是新建操作。
[AppleScript] 纯文本查看 复制代码
// 渲染虚拟DOM
//   虚拟DOM节点树
//   承载DOM节点的容器,父元素
function render(vnode, container) {

if (!container.vnode) {
  // 首次渲染
  mount(vnode, container);
} else {
  // 旧的虚拟DOM节点
  // 新的DOM节点
  // 承载DOM节点的容器
  patch(container.vnode, vnode, container);
}
container.vnode = vnode;
};
既然已经确定了现在的render函数所需要进行的操作了,那么接下来就应该进行下一步操作了,如果想要做更新的话必须要知道如下几个参数,原有的虚拟DOM节点是什么样的,新的虚拟DOM又是什么样的,上一步操作中我们已经把原有的虚拟DOM节点已经保存在了父容器中,直接使用即可。
[AppleScript] 纯文本查看 复制代码
// 更新函数
//   旧的虚拟DOM节点
//   新的DOM节点
//   承载DOM节点的容器
function patch(oldVNode, newVNode, container) {
// 新节点的VNode类型
let newVNodeFlag = newVNode.flag;
// 旧节点的VNode类型
let oldVNodeFlag = oldVNode.flag;
// 如果新节点与旧节点的类型不一致
// 如果不一致的情况下,相当于其节点发生了变化
// 直接进行替换操作即可
// 这里判断的是如果一个是 TEXT 一个是 Element
// 类型判断
if (newVNodeFlag !== oldVNodeFlag) {
  replaceVNode(oldVNode, newVNode, container);
}
// 由于在新建时创建Element和Text的时候使用的是两个函数进行操作的
// 在更新的时候也是同理的
// 也应该针对不同的修改进行不同的操作
// 如果新节点与旧节点的HTML相同
else if (newVNodeFlag == vnodeTypes.HTML) {
  // 替换元素操作
  patchMethos.patchElement(oldVNode, newVNode, container);
}
// 如果新节点与旧节点的TEXT相同
else if (newVNodeFlag == vnodeTypes.TEXT) {
  // 替换文本操作
  patchMethos.patchText(oldVNode, newVNode, container);
}
}
// 更新VNode方法集
const patchMethos = {
  // 替换文本操作
  //   旧的虚拟DOM节点
  //   新的DOM节点
  //   承载DOM节点的容器
  patchText(oldVNode,newVNode,container){
      // 获取到el,并将 oldVNode 赋值给 newVNode
      let el = (newVNode.el = oldVNode.el);
      // 如果 newVNode.children 不等于 oldVNode.children
      // 其他情况就是相等则没有任何操作,不需要更新
      if(newVNode.children !== oldVNode.children){
          // 直接进行替换操作
          el.nodeValue = newVNode.children;
      }
  }
};
// 替换虚拟DOM
function replaceVNode(oldVNode, newVNode, container) {
// 在原有节点中删除旧节点
container.removeChild(oldVNode.el);
// 重新渲染新节点
mount(newVNode, container);
}
上述方法简单的实现了对Text更新的一个替换操作,由于Text替换操作比较简单,所以这里就先实现,仅仅完成了对Text的更新是远远不够的,当Element进行操作的时也是需要更新的。相对来说Text的更新要比Element更新要简单很多的,Element更新比较复杂所以放到了后面,因为比较重要嘛,哈哈~

首先想要进行Element替换之前要确定哪些Data数据进行了变更,然后才能对其进行替换操作,这样的话需要确定要更改的数据,然后替换掉原有数据,才能进行下一步更新操作。

// 更新VNode方法集
const patchMethos = {
  // 替换元素操作
  //   旧的虚拟DOM节点
  //   新的DOM节点
  //   承载DOM节点的容器
  patchElement(oldVNode,newVNode,container){
      // 如果 newVNode 的标签名称与 oldVNode 标签名称不一样
      // 既然标签都不一样则直接替换就好了,不需要再进行其他多余的操作
      if(newVNode.tag !== oldVNode.tag){
          replaceVNode(oldVNode,newVNode,container);
          return;
      }
      // 更新el
      let el = (newVNode.el = oldVNode.el);
      // 获取旧的Data数据
      let oldData = oldVNode.data;
      // 获取新的Data数据
      let newData = newVNode.data;
      // 如果新的Data数据存在
      // 进行更新和新增
      if(newData){
          for(let attr in newData){
              let oldVal = oldData[attr];
              let newVal = newData[attr];
              domAttributeMethod.patchData(el,attr,oldVal,newVal);
          }
      }
      // 如果旧的Data存在
      // 检测更新
      if(oldData){
          for(let attr in oldData){
              let oldVal = oldData[attr];
              let newVal = newData[attr];
              // 如果旧数据存在,新数据中不存在
              // 则表示已删除,需要进行更新操作
              if(oldVal && !newVal.hasOwnProperty(attr)){
                  // 既然新数据中不存在,则新数据则传入Null
                  domAttributeMethod.patchData(el,attr,oldVal,null);
              }
          }
      }
  }
};
// dom添加属性方法
const domAttributeMethod = {
// 修改Data数据方法
patchData (el,key,prv,next){
  switch(key){
    case "style":
      this.setStyle(el,key,prv,next);
      // 添加了这里,看我看我 (●'◡'●)
      // 添加遍历循环
      // 循环旧的data
      this.setOldVal(el,key,prv,next);
      break;
    case "class":
      this.setClass(el,key,prv,next);
      break;
    default :
      this.defaultAttr(el,key,prv,next);
      break;
  }
},
// 遍历旧数据
setOldVal(el,key,prv,next){
  // 遍历旧数据
  for(let attr in prv){
      // 如果旧数据存在,新数据中不存在
      if(!next.hasOwnProperty(attr)){
          // 直接赋值为字符串
          el.style[attr] = "";
      }
  }
},
// 修改事件注册方法
addEvent(el,key,prev,next){
  // 添加了这里,看我看我 (●'◡'●)
  // prev 存在删除原有事件,重新绑定新的事件
  if(prev){
    el.removeEventListener(key.slice(1),prev);
  }
  if(next){
    el.addEventListener(key.slice(1),next);
  }
}
}
上面的操作其实只是替换Data部分,但是其子元素没有进行替换,所以还需要对子元素进行替换处理。替换子元素有共分为6种情况:
上面代码比较乱,因为嵌套了多层循环,大致逻辑就是使用上述六种情况一一对接配对并且使用其对应的解决方案。
上述六中情况,switch匹配逻辑:
新数据
旧数据

旧元素只有一个新元素只有一个
旧元素只有一个新元素为空
旧元素只有一个新元素为多个
旧元素为空新元素只有一个
旧元素为空新元素为空
旧元素为空新元素为多个
旧元素为多个新元素只有一个
旧元素为多个新元素为空
旧元素为多个新元素为多个
最为复杂的就是最后一种情况,新旧元素各为多个,然而对于这一部分react和vue的处理方式都是不一样的。以下借鉴的是react的diff算法。
在进行虚拟DOM替换时,当元素之间的顺序没有发生变化则原有元素是不需要进行任何改动的,也就是说,若原有顺序是123456,新顺序为654321则他们之间的顺序发生了变化这个时候需要对其进行变更处理,若其顺序出现了插入情况192939495969在每个数字后面添加了一个9,其实这个时候也是不需要进行更新操作的,其实他们之间的顺序还是和原来一致,只是添加了一些元素值而已,如果变成了213456,这是时候只需要改变12就好,其他的是不需要做任何改动的。 接下来需要添加最关键的逻辑了。
[AppleScript] 纯文本查看 复制代码
// 更新VNode方法集
// 添加 oldMoreAndNewMore 方法
const patchMethos = {
upChildMultiple(...arg) {
  let [oldChildrenFlag, newChildrenFlag, oldChildren, newChildren, container] = arg;
  // 循环新的子元素
  switch (newChildrenFlag) {
    // 如果新元素的子元素为一个
    case childTeyps.SINGLE:
      for (let i = 0; i < oldChildren.length; i++) {
        // 遍历删除旧元素
        container.removeChild(oldChildren.el);
      }
      // 添加新元素
      mount(newChildren, container);
      break;
    // 如果新元素的子元素为空
    case childTeyps.EMPTY:
      for (let i = 0; i < oldChildren.length; i++) {
        // 删除所有子元素  
        container.removeChild(oldChildren.el);
      }
      break;
    // 如果新元素的子元素多个
    case childTeyps.MULTIPLE:
      // 修改了这里 (●'◡'●)
      this.oldMoreAndNewMore(...arg);
      break;
},
oldMoreAndNewMore(...arg) {
  let [oldChildrenFlag, newChildrenFlag, oldChildren, newChildren, container] = arg;
  let lastIndex = 0;
  for (let i = 0; i < newChildren.length; i++) {
    let newVnode = newChildren;
    let j = 0;
    // 新的元素是否找到
    let find = false;
    for (; j < oldChildren.length; j++) {
      let oldVnode = oldChildren[j];
      // key相同为同一个元素
      if (oldVnode.key === newVnode.key) {
        find = true;
        patch(oldVnode, newVnode, container);
        if (j < lastIndex) {
          if(newChildren[i-1].el){
            // 需要移动
            let flagNode = newChildren[i-1].el.nextSibling;
            container.insertBefore(oldVnode.el, flagNode);
          }
          break;
        }
        else {
          lastIndex = j;
        }
      }
    }
    // 如果没有找到旧元素,需要新增
    if (!find) {
      // 需要插入的标志元素
      let flagNode = i === 0 ? oldChildren[0].el : newChildren[i-1].el;
      mount(newVnode, container, flagNode);
    }
    // 移除元素
    for (let i = 0; i < oldChildren.length; i++) {
      // 旧节点
      const oldVNode = oldChildren;
      // 新节点key是否在旧节点中存在
      const has = newChildren.find(next => next.key === oldVNode.key);
      if (!has) {
        // 如果不存在删除
        container.removeChild(oldVNode.el)
      }
    }
  }
}
};
// 修改mount函数
//     flagNode   标志node 新元素需要插入到哪里
function mount(vnode, container, flagNode) {
// 所需渲染标签类型
let { flag } = vnode;
// 如果是节点
if (flag === vnodeTypes.HTML) {
  // 调用创建节点方法
  mountMethod.mountElement(vnode, container, flagNode);
} // 如果是文本
else if (flag === vnodeTypes.TEXT) {
  // 调用创建文本方法
  mountMethod.mountText(vnode, container);
};
};
// 修改mountElement
const mountMethod = {
// 创建HTML元素方法
//   修改了这里 (●'◡'●) 添加 flagNode 参数
mountElement(vnode, container, flagNode) {
  // 属性,标签名,子元素,子元素类型
  let { data, tag, children, childrenFlag } = vnode;
  // 创建的真实节点
  let dom = document.createElement(tag);
  // 添加属性
  data && domAttributeMethod.addData(dom, data);
  // 在VNode中保存真实DOM节点
  vnode.el = dom;
  // 如果不为空,表示有子元素存在
  if (childrenFlag !== childTeyps.EMPTY) {
    // 如果为单个元素
    if (childrenFlag === childTeyps.SINGLE) {
      // 把子元素传入,并把当前创建的DOM节点以父元素传入
      // 其实就是要把children挂载到 当前创建的元素中
      mount(children, dom);
    } // 如果为多个元素
    else if (childrenFlag === childTeyps.MULTIPLE) {
      // 循环子节点,并创建
      children.forEach((el) => mount(el, dom));
    };
  };
  // 添加元素节点 修改了这里 (●'◡'●)
  flagNode ? container.insertBefore(dom, flagNode) : container.appendChild(dom);
}
}
最终使用:

const VNODEData = [
  "div",
  {id:"test",key:789},
  [
    createElement("p",{
      key:1,
      style:{
        color:"red",
        background:"pink"
      }
    },"节点一"),
    createElement("p",{
      key:2,
      "@click":() => console.log("click me!!!")
    },"节点二"),
    createElement("p",{
      key:3,
      class:"active"
    },"节点三"),
    createElement("p",{key:4},"节点四"),
    createElement("p",{key:5},"节点五")
  ]
];
let VNODE = createElement(...VNODEData);
render(VNODE,document.getElementById("app"));

const VNODEData1 = [
  "div",
  {id:"test",key:789},
  [
    createElement("p",{
      key:6
    },"节点六"),
    createElement("p",{
      key:1,
      style:{
        color:"red",
        background:"pink"
      }
    },"节点一"),
    createElement("p",{
      key:5
    },"节点五"),
    createElement("p",{
      key:2
    },"节点二"),
    createElement("p",{
      key:4
    },"节点四"),
    createElement("p",{
      key:3,
      class:"active"
    },"节点三")
  ]
];

setTimeout(() => {
let VNODE = createElement(...VNODEData1);
render(VNODE,document.getElementById("app"));
},1000)
上面代码用了大量的逻辑来处理其中使用大量计算,会比较两棵树之间的同级节点。这样就彻底的降低了复杂度,并且不会带来什么损失。因为在web应用中不太可能把一个组件在DOM树中跨层级地去移动。


在计算中会尽可能的引用之前的元素,进行位置替换,其实无论是React还是Vue在渲染列表的时候需要给其元素赋值一个key属性,因为在进行diff算法时,会优先使用其原有元素,进行位置调整,也是对性能优化的一大亮点。
结语
本文也只是对diff算法的简单实现,也许不能满足所有要求,React的基本实现原理则是如此,希望这篇文章能对大家理解diff算法有所帮助。






欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) 黑马程序员IT技术论坛 X3.2