虚拟Dom详解-二

49次阅读

共计 10776 个字符,预计需要花费 27 分钟才能阅读完成。

第一篇文章中主要讲解了虚拟 DOM 基本实现,简单的回顾一下,虚拟 DOM 是使用 json 数据描述的一段虚拟 Node 节点树,通过 render 函数生成其真实 DOM 节点。并添加到其对应的元素容器中。在创建真实 DOM 节点的同时并为其注册事件并添加一些附属属性。

虚拟 Dom 详解 – (一)

在上篇文章中也曾经提到过,当状态变更的时候用修改后的新渲染的的 JavaScript 对象和旧的虚拟 DOMJavaScript对象作对比,记录着两棵树的差异,把差别反映到真实的 DOM 结构上最后操作真正的 DOM 的时候只操作有差异的部分的更改。然而上篇文章中也只是简简单单的提到过一句却没有进行实质性的实现,这篇文章主要讲述一下虚拟 DOM 是如何做出更新的。那就开始吧 …O(∩_∩)O

在虚拟 DOM 中实现更新的话是使用 DIFF 算法进行更新的,我想大多数小伙伴都应该听说过这个词,DIFF是整个虚拟 DOM 部分最核心的部分,因为当虚拟 DOM 节点状态发生改变以后不可能去替换整个 DOM 节点树,若是这样的话会出现打两个 DOM 操作,无非是对性能的极大影响,真的如此的话还不如直接操作 DOM 来的实际一些。

第一篇文章中是通过 render 对虚拟 DOM 节点树进行渲染的,但是在 render 函数中只做了一件事情,只是对虚拟 DOM 进行了新建也就是初始化工作,其实回过头来想一下,无论是新建操作还是修改操作,都应该通过 render 函数来做,在 react 中所有的 DOM 渲染都是通过其中的 render 函数完成的,那么也就得出了这个结论。

//  渲染虚拟 DOM
//    虚拟 DOM 节点树
//    承载 DOM 节点的容器,父元素
function render(vnode,container) {
  //  首次渲染
  mount(vnode,container);
};

既然更新和创建操作都是通过 render 函数来做的,在方法中又应该如何区分当前的操作到底是新建还是更新呢?毕竟在 react 我们并没有给出明确的标识来告诉其方法,当前是进行的哪个操作。在执行 render 函数的时候有两个参数,一个是传入的 vnode 节点树,还有一个就是承载真实 DOM 节点的容器,其实我们可以把其虚拟 DOM 节点树挂载在其容器中,若容器中存在其节点树则是更新操作,反之则是新建操作。

//  渲染虚拟 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 节点已经保存在了父容器中,直接使用即可。

//  更新函数
//    旧的虚拟 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 种情况:

  1. 旧元素只有一个
  2. 旧元素为空
  3. 旧元素为多个
  4. 新元素只有一个
  5. 新元素为空
  6. 新元素为多个
//  更新 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);
                }
            }
        }
        //  添加了这里
        //  更新子元素
        //      旧子元素类型
        //      新子元素类型
        //      旧子元素的 children
        //      新子元素的 children
        //      el 元素,容器
        this.patchChildren(
            oldVNode.childrenFlag,
            newVNode.childrenFlag,
            oldVNode.children,
            newVNode.children,
            el,
        );

    },
    //  更新子元素
    //      旧子元素类型
    //      新子元素类型
    //      旧子元素的 children
    //      新子元素的 children
    //      el 元素,容器
    patchChildren(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        switch(oldChildrenFlag){
            //  如果旧元素的子元素为一个
            case childTeyps.SINGLE:
                this.upChildSingle(...arg);
                break;
            //  如果旧元素的子元素为空
            case childTeyps.EMPTY:
                this.upChildEmpty(...arg);
                break;
            //  如果旧元素的子元素为多个
            case childTeyps.MULTIPLE:
                this.upChildMultiple(...arg);
                break;
        }
    },

    upChildSingle(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        //  循环新的子元素
        switch(newChildrenFlag){
            //  如果新元素的子元素为一个
            case childTeyps.SINGLE:
                patch(oldChildren,newChildren,container);
                break;
            //  如果新元素的子元素为空
            case childTeyps.EMPTY:
                container.removeChild(oldChildren.el);
                break;
            //  如果新元素的子元素多个
            case childTeyps.MULTIPLE:
                container.removeChild(oldChildren.el);
                for(let i = 0;i<newChildren.length;i++){mount(newChildren[i],container);
                }
                break;
        }
    },

    upChildEmpty(...arg){let [oldChildrenFlag,newChildrenFlag,oldChildren,newChildren,container] = arg;
        //  循环新的子元素
        switch(newChildrenFlag){
            //  如果新元素的子元素为一个
            case childTeyps.SINGLE:
                mount(newChildren,container);
                break;
            //  如果新元素的子元素为空
            case childTeyps.EMPTY:
                break;
            //  如果新元素的子元素多个
            case childTeyps.MULTIPLE:
                container.removeChild(oldChildren.el);
                for(let i = 0;i<newChildren.length;i++){mount(newChildren[i],container);
                }
                break;
        }
    },

    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[i].el);
                }
                mount(newChildren,container);
                break;
            //  如果新元素的子元素为空
            case childTeyps.EMPTY:
                for(let i = 0;i<oldChildren.length;i++){container.removeChild(oldChildren[i].el);
                }
                break;
            //  如果新元素的子元素多个
            case childTeyps.MULTIPLE:
                //  **
                //  暂时搁置 这里是所有节点的对比
                //  **
                break;
        }
    }

};

上面代码比较乱,因为嵌套了多层循环,大致逻辑就是使用上述六种情况一一对接配对并且使用其对应的解决方案。

上述六中情况,switch匹配逻辑:

新数据 旧数据
旧元素只有一个 新元素只有一个
旧元素只有一个 新元素为空
旧元素只有一个 新元素为多个
旧元素为空 新元素只有一个
旧元素为空 新元素为空
旧元素为空 新元素为多个
旧元素为多个 新元素只有一个
旧元素为多个 新元素为空
旧元素为多个 新元素为多个

最为复杂的就是最后一种情况,新旧元素各为多个,然而对于这一部分 reactvue的处理方式都是不一样的。以下借鉴的是 reactdiff算法。

在进行虚拟 DOM 替换时,当元素之间的顺序没有发生变化则原有元素是不需要进行任何改动的,也就是说,若原有顺序是 123456,新顺序为654321 则他们之间的顺序发生了变化这个时候需要对其进行变更处理,若其顺序出现了插入情况 192939495969 在每个数字后面添加了一个 9,其实这个时候也是不需要进行更新操作的,其实他们之间的顺序还是和原来一致,只是添加了一些元素值而已,如果变成了213456,这是时候只需要改变12 就好,其他的是不需要做任何改动的。接下来需要添加最关键的逻辑了。

//  更新 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[i].el);
        }
        // 添加新元素
        mount(newChildren, container);
        break;
      //  如果新元素的子元素为空
      case childTeyps.EMPTY:
        for (let i = 0; i < oldChildren.length; i++) {
          // 删除所有子元素  
          container.removeChild(oldChildren[i].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[i];
      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[i];
        //  新节点 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 算法有所帮助。

非常感谢大家用这么长时间来阅读本文章,文章中代码篇幅过长,若有错误请在评论区指出,我会及时做出改正。

正文完
 0