vue中的VDOM和diff算法

693 阅读5分钟

为啥会有VDOM?

真实DOM是怎么工作的

真实DOM的渲染大致分为5步,创建DOM树——创建StyleRules——创建Render树——布局——绘制。

  1. 用HTML分析器,分析HTML元素,构建一颗DOM树(标记化和树构建)。
  2. 用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表。
  3. 第三步,将DOM树和样式表,关联起来,构建一颗Render树(这一过程又称为Attachment)。每个DOM节点都有attach方法,接受样式信息,返回一个render对象(又名renderer)。这些render对象最终会被构建成一颗Render树。
  4. 第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
  5. 第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。

真实DOM的更新

我们用jQuery操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。在一次操作中,我需要更新10个DOM节点,浏览器收到第一个DOM请求后并不知道还有9次更新操作,因此会马上执行流程,最终执行10次。例如,第一次计算完,紧接着下一个DOM更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算DOM节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作DOM的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。

于是乎,VDOM就应运而生了。用JS去模拟DOM结构,计算出最小的变更,然后再操作DOM。

用VNode模拟DOM结构

<div id='div1' class='container'>
    <p>vdom</p>
    <ul style='font-size: 20px;'>
        <li>a</li>
    </ul>
</div>

VNode模拟

{
    tag: 'div',
    props: {
        id: 'div1',
        className: 'container',
    },
    children: [
        {
            tag: 'p',
            children: 'vdom',
        },
        {
            tag: 'ul',
            props: {
                style: 'font-size: 20px'
            },
            children: [
                {
                    tag: 'li',
                    children: 'a'
                },
            ]
        }
    ]
}

diff算法

用VDOM来模拟DOM之后,我们可以用diff算法计算出Virtual DOM中真正变化的部分,并只针对该部分进行原生DOM操作,而非重新渲染整个页面。

传统diff算法

diff算法是一个广泛的概念,比如linux diff命令,git diff等。两个js对象也可以做diff。还有两棵树做diff,比如这里的vdom diff。

我们拿树的diff算法来说,通过对两棵树循环递归对每个节点进行对比,算法的复杂度就达到了O(n^3),n是数的节点数。所以,如果我们要展示1000个节点,我们就需要执行1亿次比较。。。这个就比较恐怖了。。。

优化后的diff算法

优化后的diff算法将O(n^3)复杂度转化为O(n),通过下面几个策略:

  1. 只比较同一层级,不跨级比较

5518628-d60043dbeddfce8b.png

  1. tag不相同,则直接删掉重建,不再深度比较

Screen Shot 2021-06-16 at 3.22.58 PM.png

  1. tag和key,两者都相同,则认为是相同节点,不再深度比较。

VNode 和 diff算法相结合

vue在官方文档中提到与react的渲染性能对比中,因为其使用了snabbdom而有更优异的性能。

JavaScript 开销直接与求算必要 DOM 操作的机制相关。尽管 Vue 和 React 都使用了 Virtual Dom 实现这一点,但 Vue 的 Virtual Dom 实现(复刻自 snabbdom)是更加轻量化的,因此也就比 React 的实现更高效。

下面咱们就通过snabbdom源码来看看吧~

VNode

在文章一开始,我们就用VNode来模拟DOM,那什么是VNode呢?

Snabbdom 的 Virtual Node 则是纯数据对象,通过 vnode 模块来创建,对象属性包括:

  • sel

  • data

  • children

  • text

  • elm

  • key 可以看到 Virtual Node 用于创建真实节点的数据包括:

  • 元素类型

  • 元素属性

  • 元素的子节点

//VNode函数,用于将输入转化成VNode
    /**
     *
     * @param sel    选择器
     * @param data    绑定的数据
     * @param children    子节点数组
     * @param text    当前text节点内容
     * @param elm    对真实dom element的引用
     * @returns {{sel: *, data: *, children: *, text: *, elm: *, key: undefined}}
     */
function vnode(sel, data, children, text, elm) {
     var key = data === undefined ? undefined : data.key;
      return { sel: sel, data: data, children: children,
          text: text, elm: elm, key: key };
}

但是呢,Snabbdom并没有把vnode直接暴露给我们用,而是用了h包装起,h的主要功能是处理参数:

h(sel,[data],[children],[text]) => vnode

从Snabbdom的源码可以看出,其实就是这几种函数:

export function h(sel: string): VNode; 
export function h(sel: string, data: VNodeData): VNode; 
export function h(sel: string, text: string): VNode; 
export function h(sel: string, children: Array<VNode | undefined | null>): VNode; 
export function h(sel: string, data: VNodeData, text: string): VNode; 
export function h(sel: string, data: VNodeData, children: Array<VNode | undefined | null>): VNode; 

patch

创建vnode之后,接下来就是调用patch()渲染成真实dom。

patch是snabbdom的init函数返回的。snabbdom.init传入modules数组,module用来扩展snabbdom创建复杂dom的能力。

上patch源码

return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node;
    const insertedVnodeQueue: VNodeQueue = [];
    
    //执行callback pre hook(dom的生命周期)
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();

    //第一个参数不是vnode
    if (!isVnode(oldVnode)) {
      //创建一个空的vnode,关联到这个DOM元素
      oldVnode = emptyNodeAt(oldVnode);
    }

    //相同的vnode(key 和 sel 都相同)
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } 
    //不同的vnode,直接删除重建
    else { 
      elm = oldVnode.elm!;
      parent = api.parentNode(elm) as Node;

      //重建
      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }

    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]);
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    return vnode;
  };

先判断oldVnode和vnode是否是相同的,如果是才可以执行patchVnode,否则创建新的dom删除旧的dom。 判断是否相同的源码很简单,根据优化后的diff算法策略1和3,只在同一级比较key和tag:

function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
  const isSameKey = vnode1.key === vnode2.key;
  const isSameIs = vnode1.data?.is === vnode2.data?.is;
  const isSameSel = vnode1.sel === vnode2.sel;

  return isSameSel && isSameKey && isSameIs;
}

如果相同,则调用patchVnode进行对比。

graph TB
start[patch函数被调用] --> conditionA{oldVnode是虚拟节点还是DOM节点}
conditionA -- 是DOM节点 --> operationA[将oldVnode包装成虚拟节点]
operationA --> conditionB{判断oldVnode和newVnode是同一个节点}
conditionA -- 是虚拟节点 --> conditionB
conditionB -- 不是 --> operationB[暴力删除oldVnode,插入newVnode]
conditionB -- 是 --> operationC[调用patchVnode]

patchVnode

下面开始讲patchVnode

function patchVnode(
    oldVnode: VNode,
    vnode: VNode,
    insertedVnodeQueue: VNodeQueue
  ) {
    //执行prepatch hook,类似于生命周期的钩子
    const hook = vnode.data?.hook;
    hook?.prepatch?.(oldVnode, vnode);
    
    //设置vnode element,赋值成和旧的一样就可以
    const elm = (vnode.elm = oldVnode.elm)!;
    
    //old children
    const oldCh = oldVnode.children as VNode[];
    //new children
    const ch = vnode.children as VNode[];
    
    if (oldVnode === vnode) return;
    
    //hook相关
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i)
        cbs.update[i](oldVnode, vnode);
      vnode.data.hook?.update?.(oldVnode, vnode);
    }
    
    // vnode.text === undefined 意味着vnode.children !== undefined
    if (isUndef(vnode.text)) {
      //新旧都有children
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      } 
      //旧children没有,新children有
      else if (isDef(ch)) {
        if (isDef(oldVnode.text)) api.setTextContent(elm, "");
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      } 
      //旧children有,新children没有
      else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      } 
      //旧的text有值,新的没有
      else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, "");
      }
    } 
    // vnode.text !== undefined 意味着 vnode.children === undefined 
    else if (oldVnode.text !== vnode.text) {
      //移除旧的text
      if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      //设置新的text
      api.setTextContent(elm, vnode.text!);
    }
    hook?.postpatch?.(oldVnode, vnode);
  }
graph TB
condition1{oldVnode和newVnode是否是同一个对象} -- 是 --> op1[什么都不做]
condition1 -- 不是 --> condition2{newVnode有没有text属性}
condition2 -- 有 --> condition3{newVnode和oldVnode的text属性是否相同}
condition3 -- 一样 --> op1
condition3 -- 不一样 --> op2[更新text]
condition2 -- 没有 --> condition4{oldVnode有没有children}
condition4 -- 没有 --> op3[清空text添加children]
condition4 -- 有 --> op4[调用updateChildren]

updateChildren

patchVnode最关键最核心的是updateChildren().这个方法是实现diff算法的主要地方。

function updateChildren(
    parentElm: Node,
    oldCh: VNode[],
    newCh: VNode[],
    insertedVnodeQueue: VNodeQueue
  ) {
    let oldStartIdx = 0;
    let newStartIdx = 0;
    let oldEndIdx = oldCh.length - 1;
    let oldStartVnode = oldCh[0];
    let oldEndVnode = oldCh[oldEndIdx];
    let newEndIdx = newCh.length - 1;
    let newStartVnode = newCh[0];
    let newEndVnode = newCh[newEndIdx];
    let oldKeyToIdx: KeyToIndexMap | undefined;
    let idxInOld: number;
    let elmToMove: VNode;
    let before: any;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (oldStartVnode == null) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
      } else if (oldEndVnode == null) {
        oldEndVnode = oldCh[--oldEndIdx];
      } else if (newStartVnode == null) {
        newStartVnode = newCh[++newStartIdx];
      } else if (newEndVnode == null) {
        newEndVnode = newCh[--newEndIdx];
      }
      
      // 新旧 start vnode的对比
      else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        oldStartVnode = oldCh[++oldStartIdx];
        newStartVnode = newCh[++newStartIdx];
      } 
      
      //新旧 end vnode的对比
      else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        oldEndVnode = oldCh[--oldEndIdx];
        newEndVnode = newCh[--newEndIdx];
      } 
      
      //旧的start vnode和新的end vnode对比
      else if (sameVnode(oldStartVnode, newEndVnode)) {
        // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(
          parentElm,
          oldStartVnode.elm!,
          api.nextSibling(oldEndVnode.elm!)
        );
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      } 
      
      //旧的end vnode和新的start vnode对比
      else if (sameVnode(oldEndVnode, newStartVnode)) {
        // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      } 
      
      //其他情况
      else {
        if (oldKeyToIdx === undefined) {
          oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        }
        
        //拿新节点的key,能否对应上old children中某个节点的key
        idxInOld = oldKeyToIdx[newStartVnode.key as string];
        
        //没有对应上,需要创造新元素
        if (isUndef(idxInOld)) {
          // New element
          api.insertBefore(
            parentElm,
            createElm(newStartVnode, insertedVnodeQueue),
            oldStartVnode.elm!
          );
        } 
        //对应上了
        else {
          elmToMove = oldCh[idxInOld];
          //比较selector,不相等
          if (elmToMove.sel !== newStartVnode.sel) {
            //new element
            api.insertBefore(
              parentElm,
              createElm(newStartVnode, insertedVnodeQueue),
              oldStartVnode.elm!
            );
          } 
          //selector相等
          else {
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
            oldCh[idxInOld] = undefined as any;
            api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
          }
        }
        newStartVnode = newCh[++newStartIdx];
      }
    }
    //结束
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
      if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
        addVnodes(
          parentElm,
          before,
          newCh,
          newStartIdx,
          newEndIdx,
          insertedVnodeQueue
        );
      } else {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
      }
    }
  }

代码比较复杂,上图讲可能更容易理解。

调用updateChildren()时oldCh值和newCh值。 Screen Shot 2021-06-16 at 4.50.59 PM.png

定义oldStartIdx,oldEndIdx,newStartIdx, newEndIdx Screen Shot 2021-06-16 at 4.54.55 PM.png

先做4种情况的比较,

  1. oldVnode的start和newVnode的start,为true则patch之后就移动头指针 oldStartIdx++ newStartIdx++
  2. oldVnode的end和newVnode的end,为true则patch之后移动尾指针 oldEndIdx-- newEndIdx--
  3. oldVnode的start和newVnode的end,为true则newEndIdx 指向的节点移动到 oldStartIdx 之后 oldStartIdx++ newEndIdx--
  4. oldVnode的end和newVnode的start,为true则newStartIdx 指向的节点到 oldEndIdx 前面 oldEndIdx-- newStartIdx++

Screen Shot 2021-06-16 at 4.54.55 PM.png

当然了,不排除以上4种情况都不符合的时候,这个时候就需要遍历key了。如果找到了,就patchVNode(),如果没有找到,就会新增。

比较结束后,会做一个判断。

  1. newVnode中还有剩余。新节点中剩余的都 插入 旧节点oldEnd后面 或 oldStart之前
  2. oldVnode中还有剩余节点,直接删除。

整个流程图就是如下

graph TD
start[patch函数被调用] --> conditionA{oldVnode是虚拟节点还是DOM节点}
conditionA -- 是DOM节点 --> operationA[将oldVnode包装成虚拟节点]
operationA --> conditionB{判断oldVnode和newVnode是同一个节点}
conditionA -- 是虚拟节点 --> conditionB
conditionB -- 不是 --> operationB[暴力删除oldVnode,插入newVnode]
conditionB -- 是 --> condition1{oldVnode和newVnode是否是同一个对象}
condition1 -- 是 --> op1[什么都不做]
condition1 -- 不是 --> condition2{newVnode有没有text属性}
condition2 -- 有 --> condition3{newVnode和oldVnode的text属性是否相同}
condition3 -- 一样 --> op1
condition3 -- 不一样 --> op2[更新text]
condition2 -- 没有 --> condition4{oldVnode有没有children}
condition4 -- 没有 --> op3[清空text添加children]
condition4 -- 有 --> op4[diff算法]
op4 --> op5[几种情况的命中] --> condition5{是否还有剩余项}
condition5 -- 旧节点全部结束 --> 创建新元素插入
condition5 -- 新节点全部结束 --> 剩余旧节点全部删除