Vue系列:虚拟DOM和diff算法

714 阅读11分钟

虚拟DOM

DOM操作非常耗费性能,在操作DOM时,会出现DOM的回流(Reflow:元素的大小或者位置发生了变化) 和重绘(Repaint:元素样式的改变),重新渲染DOM,可以看看我以前的文章----浏览器渲染过程

现在的框架Vue和react很少直接操作DOM,因为Vue和React是数据驱动视图,我们只会对数据进行增删改的处理,那么框架是如何控制DOM操作的呢?

react和vue使用虚拟DOM(vdom)的来解决这个问题,主要的原理是:用JS模拟DOM结构,把DOM的计算转移为js的计算,使用diff算法计算出最小的变更,然后根据变更操作DOM,减小计算量。

例如:使用js对象表示html的树形结构:

image。png

下面我们通过snabbdom这个vdom库的源码来学习vdom和diff算法,Vue也是参考它实现的vdom和diff

首先看一下官方的例子

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

有两个关键函数:

  • h 函数返回一个vnode,他是是使用js对象表示的虚拟DOM结构。接受 sel (选择器), data (对DOM的js描述), children (这个虚拟DOM的子vnode元素)
  • patch 函数的作用一是用来将vnode渲染为真实DOM挂载到页面,二是使用diff算法对比两个vnode的不同,然后对DOM进行重新渲染。

返回的vNode结构如下: image。png

总结:

  • DOM操作非常耗费性能,因为回流重绘
  • Vue和React是数据驱动视图,使用JS模拟DOM结构(vnode),把DOM的计算转移为js的计算,使用diff算法比较新旧vnode,计算出最小的变更,然后根据变更更新DOM,减小计算量。
  • snabbdom库中的 h 函数生成一个vnode, patch 函数进行DOM渲染和使用diff算法更新DOM

diff算法

概述

比较两个新旧vNode的diff的过程主要是在 patch 函数中进行, image。png

如果正常情况下两棵树之间作对比,那么第一,遍历tree1 ;第二,遍历tree2 ;第三,排序 ,一共要遍历三次,所以树diff的时间复杂度O(n^3)。假设有1000个DOM节点,要计算1亿次,算法不可用

框架中的diff算法优化如下:

  • 只比较同一层级,不跨级比较
  • tag不相同,则直接删掉重建,不再深度比较(有可能tag不相同但是tag下面的子元素还是相同的,但是我们不管了,只要tag不相同就删掉,因为深度比较复杂度太高)
  • tag和key ,两者都相同,则认为是相同节点,不再深度比较 优化时间复杂度到O(n)

image。png

image。png

接下来解读源码的核心的函数,来了解一下diff的大致流程

生成vnode

h.ts 文件里的 h 函数用来生成vNode,我们来看一下这个源码

//h.ts
...

export function h (sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}, children: any, text: any, i: number;
 ...//细节忽略不用细看
  // 返回 vnode
  return vnode(sel, data, children, text, undefined);
};
export default h;

然后看看 vnode 函数:

export function vnode (sel: string | undefined,
  data: any | undefined,
  children: Array<VNode | string> | undefined,
  text: string | undefined,
  elm: Element | Text | undefined): VNode {
  let key = data === undefined ? undefined : data.key;
  //返回一个js对象结构的虚拟DOM
  return { sel, data, children, text, elm, key };
}
  • 返回一个js对象结构的虚拟DOM(vNode)。
  • childrentext 是不能共存的,要么里面是纯text文本,要么是子元素
  • elm 就是vnode对应的那个DOM元素
  • key 就相当于 v-for 里面的 key ,是我们在使用 v-for 的时候需要自己手动加上

patch 函数

patch 函数是 init 返回的,具体源码在snabbdom.ts里

解析如下:

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node;
  const insertedVnodeQueue: VNodeQueue = [];
  ...
  // 第一个参数不是 vnode
  if (!isVnode(oldVnode)) {
    // 创建一个空的 vnode ,关联到这个 DOM 元素
    oldVnode = emptyNodeAt(oldVnode);
  }

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

    // 重建
    createElm(vnode, insertedVnodeQueue);

    if (parent !== null) {
      api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
      removeVnodes(parent, [oldVnode], 0, 0);
    }
  }
...//其余代码先不看
  return vnode;
};
  • 如果是相同的 vnode(判断方法是 keysel 都相等),执行 patchvNode 函数,继续进行对比
  • 不同的 vnode ,直接删掉重建

sameVnode 的函数如下:

function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
  // key 和 sel 都相等
  // undefined === undefined // true
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

可见,sameVnode原理如下: 如果都不传,都没有 key (不是 v-for ),那就比较两个vNode的 sel 选择器来判断是不是同一个 vnode 了,如果有 key ,就一起比较 keysel

总结:

  • 第一次执行 patchpatch(container, vnode); ,创建一个空的vnode,关联传进来的dom,让传入的两个参数都变为vnode,然后执行下面的逻辑
  • 如果是相同的vnode,tag(sel)和key两者都相同,则认为是相同节点。执行 patchVnode 方法。
  • 如果是不同的vnode,tag(sel)不相同,则直接删掉重建,不再深度比较

patchVnode 函数

上面的patch函数中,如果两个vnode相同,就执行 patchVnode 方法,对比vnode。

patchVnode具体过程如下

function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {

  // 设置 vnode.elem,新vnode可能没有elm,所以把旧的赋给他,因为要知道需要更新哪个真实的dom元素
  const elm = vnode.elm = oldVnode.elm!;

  // 旧 children
  let oldCh = oldVnode.children as VNode[];
  // 新 children
  let ch = vnode.children as VNode[];

  if (oldVnode === vnode) return;


  // vnode.text === undefined (vnode.children 一般有值)
  if (isUndef(vnode.text)) {
    // 新旧都有 children
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
    // 新 children 有,旧 children 无 (旧 text 有)
    } else if (isDef(ch)) {
      // 清空 text
      if (isDef(oldVnode.text)) api.setTextContent(elm, '');
      // 添加 children
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    // 旧 child 有,新 child 无
    } else if (isDef(oldCh)) {
      // 移除 children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    // 旧 text 有
    } else if (isDef(oldVnode.text)) {
      api.setTextContent(elm, '');
    }

  // else : vnode.text !== undefined (vnode.children 无值)
  } else if (oldVnode.text !== vnode.text) {
    // 移除旧 children
    if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1);
    }
    // 设置新 text
    api.setTextContent(elm, vnode.text!);
  }
}

总结:

  • 如果新的vnode有 children ,没有 textvnode.text === undefinedtextchildren 不能同时存在)):
    • 如果新旧vnode都有 children ,调用 updateChildren() 方法,再继续进行更新
    • 如果新 children 有,旧 children 无,就调用 addVnodes() 方法,把新的 children 添加到 elm 上,
    • 如果新 children 无,旧 children 有,就调用 removeVnodes() 方法移除旧的vnode的 children
    • 最后还剩一种情况,新旧都没 children ,旧的vnode有 text ,新的vnode没有 text ,那么就把 elmtext 设置为空
  • 如果新的vnode没有 children 只有 text ,( vnode.text !== undefinedvnode.children 无值)),而且新旧 text 还不一样 ( oldVnode.text !== vnode.text ) ,就移除旧的vnode的 children ,替换成新vnode的 text

其中只有新旧vnode都有 children ,的情况下,需要调用 updateChildren() 方法, update 方法比较复杂,其余的情况都是简单的调用DOM 的api新增,移除DOM元素。下面说说 updateChildren() 方法

updateChildren 函数

updateChildren 比较复杂,可以只去理解流程。 传入元素elm,旧的children,新的chlidren

image。png 上图字幕代表vnode的 key 和tag(sel)的组合,用来区分是不是同一个vnode。

image。png 过程如上图所示

原理:

  • 针对新旧 ch 我们定义四个index, oldStartIdxoldEndIdxnewStartIdxnewEndIdx ,然后进行一个循环,在循环过程中,idx会一边累加或者一边累减,startIdx会累加,endIdx会累减,在这个过程中,指针会慢慢地往中间去移动,当指针重合的时候,说明遍历结束了,循环结束。

在每一轮循环过程中的具体的对比过程是:

  • 如果出现下面四种情况中的一种相同的情况:开始和开始节点去对比,结束和结束节点对比,结束和开始节点对比,那么就执行 patchVnode() 函数,进行递归比较,并且指针累加或者累减,往中间移动。 进行下一轮循环的时候,指针就指到下一个了children
  • 如果都没有上面的四种情况,首先会拿新节点 key ,能否对应上 oldCh 中的某个节点的 key
    • 如果没有对应上,说明这个节点是新的,找个地方插入进去新的就好。
    • 如果对应上了,还要判断sel是否相等,如果sel不相等,那还是没对应上,说明节点是新的,那也找地方插入新的。
    • 如果sel相等,key相等,那么继续对这两个相同的节点执行 patchVnode 方法,递归比较。
function updateChildren (parentElm: Node,
  oldCh: VNode[],
  newCh: VNode[],
  insertedVnodeQueue: VNodeQueue) {
  let oldStartIdx = 0, newStartIdx = 0let oldEndIdx = oldCh。length - 1let oldStartVnode = oldCh[0];
  let oldEndVnode = oldCh[oldEndIdx];
  let newEndIdx = newCh。length - 1let newStartVnode = newCh[0];
  let newEndVnode = newCh[newEndIdx];
  let oldKeyToIdx: KeyToIndexMap | undefinedlet idxInOld: numberlet elmToMove: VNodelet before: anywhile (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];

    // 开始和开始对比
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    
    // 结束和结束对比
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];

    // 开始和结束对比
    } 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];

    // 结束和开始对比
    } 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 ,能否对应上 oldCh 中的某个节点的 key
      idxInOld = oldKeyToIdx[newStartVnode。key as string];

      // 没对应上
      if (isUndef(idxInOld)) { // New element
        api。insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode。elm!);
        newStartVnode = newCh[++newStartIdx];
      
      // 对应上了
      } else {
        // 对应上 key 的节点
        elmToMove = oldCh[idxInOld];

        // sel 是否相等(sameVnode 的条件)
        if (elmToMove。sel !== newStartVnode。sel) {
          // New element
          api。insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode。elm!);
        
        // sel 相等,key 相等
        } 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] == nullnull : newCh[newEndIdx + 1]。elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    } else {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }
}

diff总结

  • patchVnode
  • addVnodes removeVnodes
  • updateChildren ( key的重要性)

为什么v-for要使用key

从上面 updateChildren 函数可以看出,key是一个比较新旧两个vNode是否相等的关键条件

根据源码可以知道: image。png

  • 如果不使用key,diff发现没有key能对应上,会认为所有的节点都更新了,算法会销毁所有vnode,重新渲染新的元素。

  • 如果检测出来新节点中的key对应旧节点中的某个key,比如进行进行互换位置的操作,就没有必要销毁重新渲染,仅仅互换位置。可提高性能

如果key使用随机数是没有作用的,因为一更新随机数就变成新的了,所有的key都对应不上,所以就和没有写一样,就会重新渲染

如果key使用数组的index,如果原来的元素从1的位置换到0,那么diff之后,会出现问题,算法会误认为两个元素没有变换,然后就不更新

总结DOMdiff算法的过程

vue的虚拟DOM和diff算法是参考snabbdom这个库来实现的,那么我就通过这个库来说明一下

库里有一个关键函数patch函数,入两个新旧虚拟DOM,用diff算法对比两个vnode的不同,然后对DOM进行重新渲染。

为了防止时间复杂度变为n^3,框架中的diff算法优化如下:

  • 只比较同一层级,不跨级比较
  • tag不相同,则直接删掉重建,不再深度比较

优化时间复杂度到O(n)

第一步patch函数的逻辑

这个方法作用就是,对比当前同层的虚拟节点是否为同一种类型的标签

判断的关键为sameVnode()方法,key和标签名是都相同

  • 如果是相同的 vnode(判断方法是 key 和 sel 都相等),执行 patchvNode 函数,继续进行对比
  • 不同的 vnode ,直接删掉重建,换成新的虚拟节点

第二步patchVnode 函数

具体逻辑是判断逻辑是

  1. 如果新旧的的vnode都有children,那么就要使用updateChildren()方法进行更复杂的比较
  2. 剩下的情况就是新对旧的覆盖,使用api来替换不同的DOM,或文本节点。如oldVnode有子节点newVnode没有,用removeVnodes()。odVnode没子节点newVnode有,用addVnodes(),只有文本节点就进行直接的替换

第三步updateChildren 函数

updateChildren 函数传入旧的children,新的chlidren,行新旧虚拟节点的子节点对比。

image.png 原理:

使用首尾指针法,一共标注四个指针,分别是新旧子节点的开始和结束。然后循环进行比较。在循环过程中,startIdx会累加,endIdx会累减,指针会慢慢地往中间去移动,当指针重合的时候,说明遍历结束了,循环结束。

在每一轮循环过程中的具体的对比过程是:

  • 使用sameVnode()方法进行四种情况的对比:开始和开始节点去对比,结束和结束节点对比,结束和开始节点对比,如果相同,说明对应上了,那么就执行 patchVnode() 函数,继续进行递归地替换或者对比,用新节点的信息覆盖旧节点的信息。进行下一轮循环的时候,指针就指到下一个了children

  • 如果都没有上面的四种情况,首先会拿新节点 key ,能否对应上 oldCh 中的某个节点的 key

    • 如果没有对应上,说明这个节点是新的,找个地方插入进去新的就好。
    • 如果对应上了,就这两个相同的节点执行 patchVnode 方法,递归比较。