vdom & diff算法

169 阅读3分钟
  • 前言

    偶然看到在CSDN上看到有人总结的vdom和真实dom的区别,第一条就是real dom 更新缓慢,virtual dom更新更快,然而在这个链接案例中操作真实dom是渲染速度是最快的,对于这样的情况,我们需要从vdom本身来进行研究。

  • snabbdom

  • snabbdom案例

    • 安装snabbdom

          npm i snabbdom -D
      
    • 安装webpack并进行配置

          npm i webpack@5 webpack-cli@3 webpack-dev-server@3 -D
          //webpack.config.js
          module.exports = {
              entry:'./src/index.js',
              output:{
                  publicPath:'dist',
                  filename:'index.js',
              },
              devServer:{
                  contentBase:'public',
                  port:'8080'
              },
          }
      
    • 引入案例

      // 引入案例
       import {
        init,
        classModule,
        propsModule,
        styleModule,
        eventListenersModule,
        h,
      } from "snabbdom";
      
      const patch = init([
        // Init patch function with chosen modules
        classModule, // makes it easy to toggle classes
        propsModule, // for setting properties on DOM elements
        styleModule, // handles styling on elements with support for animations
        eventListenersModule, // attaches event listeners
      ]);
      
      const container = document.getElementById("container");
      
      const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
        h("span", { style: { fontWeight: "bold" } }, "This is bold"),
        " and this is just normal text",
        h("a", { props: { href: "/foo" } }, "I'll take you places!"),
      ]);
      // Patch into empty DOM element – this modifies the DOM as a side effect
      patch(container, vnode);
      
      const newVnode = h(
        "div#container.two.classes",
        { on: { click: anotherEventHandler } },
        [
          h(
            "span",
            { style: { fontWeight: "normal", fontStyle: "italic" } },
            "This is now italic type"
          ),
          " and this is still just normal text",
          h("a", { props: { href: "/bar" } }, "I'll take you places!"),
        ]
      );
      // Second `patch` invocation
      patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
      
  • 虚拟dom和h函数

    • 虚拟dom
      • 虚拟dom是用来描述dom层次结构的一个javascript对象,dom中的一切属性都在vdom中有相对应的属性
      • diff算法是发生的虚拟dom上的,新虚拟dom和老虚拟dom进行diff算法处理,算出应该如何最小量更新,最后反映到真实dom上
    • h函数
      • h函数用来产生虚拟节点
            // 这样调用h函数
            h('a',{props:{href:'https://www.jd.com'},'京东')
            
            // 将得到这样的虚拟dom
            {
                sel:'a',
                data:{
                    props:{
                        href:'https://www.jd.com',
                    }
                },
                text:'京东',
                key:undefined,
                elm:undefined,
                children:undefined
            }
            
            
            
            //它表示一个真正的dom节点
            <a href='https://www.jd.com'>京东</a>
        
      • h函数也可以嵌套使用
            h('ul',{},[        h('li',{key:'vue'},'vue'),        h('li',{key:'react'}, 'react'),        h('li',{key:'knockout'},'knockout')    ])
            
            
            
            //将会得到如下一个vdom
           {
               sel:'ul',
               data:{},
               children:[
                   {sel:'li',key:'vue','text':'vue'},
                   {sel:'li',key:'react','text':'react'},
                   {sel:'li',key:'knockout','text':'knockout'},
               ]
           }
        
      • h函数解析
        • export function h(sel, b, c) {
              let data = {};
              let children;
              let text;
              let i;
              if (c !== undefined) {
                  if (b !== null) {
                      data = b;
                  }
                  if (is.array(c)) {
                  //判断c是否为array
                      children = c;
                  }
                  else if (is.primitive(c)) { 
                  //判断c是否是string 或者number
                      text = c.toString();
                  }
                  else if (c && c.sel) {
                      children = [c];
                  }
              }
              // if c doesn't exist, like h('div',['html','js','css'])
              else if (b !== undefined && b !== null) {
                  if (is.array(b)) {
                      children = b;
                  }
                  else if (is.primitive(b)) {
                      text = b.toString();
                  }
                  else if (b && b.sel) {
                      children = [b];
                  }
                  else {
                      data = b;
                  }
              }
              if (children !== undefined) {
                  for (i = 0; i < children.length; ++i) {
                      if (is.primitive(children[i]))
                          children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
                  }
              }
              if (sel[0] === "s" &&
                  sel[1] === "v" &&
                  sel[2] === "g" &&
                  (sel.length === 3 || sel[3] === "." || sel[3] === "#")) {
                  addNS(data, children, sel);
              }
              return vnode(sel, data, children, text, undefined);
          }
          
          export function vnode(sel, data, children, text, elm) {
              const key = data === undefined ? undefined : data.key;
              return { sel, data, children, text, elm, key };
          }
          
  • diff算法

    之前说到,diff算法是发生在虚拟dom上的,所以diff总的来说是一种对比算法,对比两者是旧的虚拟dom和新的虚拟dom,对比出是哪个虚拟节点更新了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他没发生改变的节点,实现精准的更新真实dom。

    • diff算法原理 新旧虚拟dom对比的时候,diff算法只会在同级进行比较,不会跨级进行比较

    • diff对比流程 以vue2.x为例,当数据改变是,会触发setter,并且通过Dep.notify()去通知所有watcher,watcher会调用patch方法,给真实dom打补丁,更新相应的视图

      diff.png

    • patch函数

      •   function patch(oldVnode,vnode){
            if(sameVnode(oldVnode,vnode)){
              patchVnode(oldVnode,vnode)
            }else{
              const oEl = oldVnode.elm 
              let parentEle = api.parentNode(oEl)  //父元素
              createEle(vnode)
              if(parentEle !== null){
                 api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) 
                 api.removeChild(parentEle, oldVnode.el)  
                 oldVnode = null
              }
            }
            return newVnode
          }
        
        
      • patch函数接收两个参数 oldVnode和vNode 分别代表旧的vdom和新的vdom image-20220531175454118.png

      • sameNode判断是否为统一节点

      • 如果不是同一个节点,执行createEle创建一个新节点,用新的节点替换旧的节点 image-20220531194343074.png

      • 如果是同一个节点,执行patchVnode函数

      • patchVnode函数 image-20220531194449606.png

        1. 找到对应真实dom,赋值给el
        2. 判断vnode和oldVnode是否相等,如果相等直接return
        3. 如果都没有子节点,将el文本设置为vNode.text,如果oldVnode有子节点而Vnode有,将vnode子节点真实化之后添加到el,如果vnode没有子节点而oldVnode有,删除el的子节点
        4. 如果都有子节点,则执行updateChildren方法比较子节点
      • updateChildren函数

            function updateChildren(parentElm, oldCh, newCh) {
            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
            while (newStartIdx <= newEndIdx && oldStartIdx <= oldEndIdx) {
                console.log('---come into diff---')
                if (oldCh[oldStartIdx] == undefined) {
                    oldStartVnode = oldCh[++oldStartIdx]
                } else if (oldCh[oldEndIdx] == undefined) {
                    oldEndVnode = oldCh[--oldEndIdx]
                } else if (newCh[newStartIdx] == undefined) {
                    newStartVnode = newCh[++newStartIdx]
                } else if (newCh[newEndIdx] == undefined) {
                    newEndVnode = newCh[--newEndIdx]
                }
                // 1  匹配成功 oldStartIdx ++ ;newStartIdx ++,不成功走下一步
                else if (sameVnode(oldStartVnode, newStartVnode)) {
                    patchVnode(oldStartVnode, newStartVnode)
                    newStartVnode = newCh[++newStartIdx]
                    oldStartVnode = oldCh[++oldStartIdx]
                }
                // 2匹配成功 oldEndIdx -- ;newEndIdx --; 不成功走下一步
                else if (sameVnode(oldEndVnode, newEndVnode)) {
                    patchVnode(oldEndVnode, newEndVnode)
                    newEndVnode = newCh[--newEndIdx]
                    oldEndVnode = oldCh[--oldEndIdx]
                }
                // 3 匹配成功 oldStartIdx -- ;newEndIdx++; 移动节点 ;不成功走下一步
                else if (sameVnode(oldStartVnode, newEndVnode)) {
                    patchVnode(oldStartVnode, newEndVnode)
                    parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
                    newEndVnode = newCh[--newEndIdx]
                    oldStartVnode = oldCh[++oldStartIdx]
                }
                // 4 匹配成功 oldEndIdx -- newStartIdx ++ 移动节点 不成功走下一步
                else if (sameVnode(oldEndVnode, newStartVnode)) {
                    patchVnode(oldEndVnode, newStartVnode)
                    parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
                    newStartVnode = newCh[++newStartIdx]
                    oldEndVnode = oldCh[--oldEndIdx]
                } else {
                    // catch oldVnode
                    if (!keyMap) {
                        // init keyMap
                        keyMap = {}
                        for (let i = oldStartIdx; i < oldEndIdx; i++) {
                            const key = oldCh[i].data.key
                            //  key !== undefined
                            if (!key) keyMap[key] = i
                        }
                    }
                    let idInOld = keyMap[newStartIdx.data]
                        ? keyMap[newStartIdx.data.key]
                        : undefined
        
                    if (idInOld) {
                        let moveElm = oldCh[idInOld]
                        patchVnode(moveElm, newStartVnode)
                        oldCh[idInOld] = undefined
                        parentElm.insertBefore(moveElm.elm, oldStartVnode.elm)
                    } else {
                        console.log('add new node')
                        parentElm.insertBefore(createElm(newStartVnode), oldStartVnode.elm)
                    }
        
                    newStartVnode = newCh[++newStartIdx]
                }
            }
            if (newStartIdx <= newEndIdx) {
                let beforeFlag = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1] : null
                for (let i = newStartIdx; i <= newEndIdx; i++) {
                    parentElm.insertBefore(createElm(newCh[i]), beforeFlag)
                }
            } else if (oldStartIdx <= oldEndIdx) {
                for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                    if (oldCh[i].elm) parentElm.removeChild(oldCh[i].elm)
                }
            }
        }
        
        1. updateChildren函数设置四个指针,分别指向新旧节点子节点的首尾
        2. 根据 新前-旧前 新后-旧后 旧前-新后 新前-旧后节点进行比较
        3. 如果上面四种匹配都没命中,则会根据oldCh的key生成一个hash表,用newStartIdx的key与hash表做匹配,匹配成功就取出酒店接中当前项,调用patchVnode进行比较,再将旧节点当前项设置为undefind,移动当前项到旧前之前,匹配不成功则说明是要增加的项,将newStartNode生成real dom之后,插入到旧前之前
        4. 判断循环结束时机
          • 如果oldStartIdx > oldEndIdx先发生,新增新前与新后之间的节点
          • 如果newStartIdx > newEndIdx先发生,删除旧前与旧后之间的节点
  • 总结:虚拟dom比真实dom更新更快的正确性,或者更严谨的说法

    • 没有任何框架可以比纯手动的优化dom操作更快,框架的dom操作层需要应对任何上层可能产生的操作,它的实现必须是普遍适用的

    • 和dom操作比起来,,js计算是极其便宜的,它保证了不管数据变化多少,每次重绘的性能都可以接受

    • 在构建一个实际应用中,如果有大量数据进行修改,直接重置innerhtml还算一个合理的操作,如果只有一行数据变了,也重置整个innerhtml,显然会有大量的浪费,vDom + diff 给你的保证是,在不需要手动优化的时候,依然可以提供过得去的性能。