Vue2.0 Diff算法

300 阅读8分钟

一、前言

1、什么是虚拟dom?

虚拟dom就是真实DOM以对象的形式模拟成树形的一种结构

我们看一下真实DOM与虚拟DOM长啥什么样?

真实Dom

<ul id="list">
    <li class="item">11</li>
    <li class="item">22</li>
    <li class="item">33</li>
</ul>

对应的虚拟DOM

var oldVDOM = {
  tagName: 'ul',
  props: {
    id: 'list'
  },
  children: [
    { tagName: 'li', props: { class: 'item' }, children: ['11'] },
    { tagName: 'li', props: { class: 'item' }, children: ['22'] },
    { tagName: 'li', props: { class: 'item' }, children: ['33'] },
  ]
}

2、当数据发生变化时,vue是怎么更新节点的?

要知道渲染真实DOM的开销是很大的,比如有时候我们修改了某个数据,如果直接渲染到真实dom上会引起整个dom树的重绘和重排,有没有可能我们只更新我们修改的那一小块dom而不要更新整个dom呢?diff算法能够帮助我们。

首先我们会根据真实的DOM生成一个virtual DOM,然后当virtual DOM的某个节点数据发生改变后,会触发setter,然后通过Dep.notify去通知所有的订阅者watcher,然后订阅者就会去更新组件,然后生成新的virtual DOM,最后通过调用patch方法,通过对比新旧virtual DOM,边对比边给真实DOM打补丁,更新视图。

二、diff算法

新旧虚拟DOM对比的过程,就是通过diff算法来实现的,diff算法比较只会在同一层级进行,不会跨层级的。如下图所示:

url

diff流程图

当数据发生改变时,set方法会让调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。 url

二、具体分析

patch方法的作用就是判断同层的虚拟节点是否为同一种类型的节点,如果是就继续进行更深层次的比较,否则就直接将整个节点替换成新的虚拟dom对应的真是节点

patch

function patch(oldVnode, newVnode) {
  // 比较是否为一个类型的节点
  if (sameVnode(oldVnode, newVnode)) {
    // 是:继续进行深层比较
    patchVnode(oldVnode, newVnode)
  } else {
    // 否
    const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
    const parentEle = api.parentNode(oldEl) // 获取父节点
    createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
      // 设置null,释放内存
      oldVnode = null
    }
  }

  return newVnode
}

那判断是否为同一种类型节点的标准是啥呢?我们接下来看一下sameVnode方法

sameVnode

function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否一样
    oldVnode.tagName === newVnode.tagName && // 标签名是否一样
    oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
    sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是相同的
  )
}

patchVnode

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el // 获取真实dom,el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return    // 新旧虚拟节点指向同一个对象,直接返回,说明可以复用
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)  // 都有文本节点且文本不同,更新el为vnode对应的文本
    }else {
        updateEle(el, vnode, oldVnode)
    	if (oldCh && ch && oldCh !== ch) { // 都有子节点
            updateChildren(el, oldCh, ch)
    	}else if (ch){ // 只有Vnode有子节点,el新增Vnode子节点
            createEle(vnode) //create el's children dom
    	}else if (oldCh){ 只有oldVnode有子节点,删除el对应的子节点
            api.removeChildren(el)
    	}
    }
}

代码分析:

  • 找到oldVnode对应的真是dom,命名为el
  • 判断Vnode与oldVnode是否指向同一个对象,如果是,就直接返回
  • 如果他们有文本节点并且文本不相同,那么将el的文本节点设置为Vnode的文本节点
  • 如果oldVnode有子节点,而Vnode没有,则删除el的子节点
  • 如果oldVnode没有子节点,而Vnode有,则新增el的子节点
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

其他几个点都很好理解,我们详细来讲一下updateChildren

updateChildren

// src/vdom/patch.js

// 判断两个vnode的标签和key是否相同 如果相同 就可以认为是同一节点就地复用
function isSameVnode(oldVnode, newVnode) {
  return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key;
}
// diff算法核心 采用双指针的方式 对比新老vnode的儿子节点
function updateChildren(parent, oldCh, newCh) {
  let oldStartIndex = 0; //老儿子的起始下标
  let oldStartVnode = oldCh[0]; //老儿子的第一个节点
  let oldEndIndex = oldCh.length - 1; //老儿子的结束下标
  let oldEndVnode = oldCh[oldEndIndex]; //老儿子的起结束节点

  let newStartIndex = 0; //同上  新儿子的
  let newStartVnode = newCh[0];
  let newEndIndex = newCh.length - 1;
  let newEndVnode = newCh[newEndIndex];

  // 根据key来创建老的儿子的index映射表  类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
  function makeIndexByKey(children) {
    let map = {};
    children.forEach((item, index) => {
      map[item.key] = index;
    });
    return map;
  }
  // 生成的映射表
  let map = makeIndexByKey(oldCh);

  // 只有当新老儿子的双指标的起始位置不大于结束位置的时候  才能循环 一方停止了就需要结束循环
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 因为暴力对比过程把移动的vnode置为 undefined 如果不存在vnode节点 直接跳过
    if (!oldStartVnode) {
      oldStartVnode = oldCh[++oldStartIndex];
    } else if (!oldEndVnode) {
      oldEndVnode = oldCh[--oldEndIndex];
    } else if (isSameVnode(oldStartVnode, newStartVnode)) {
      // 头和头对比 依次向后追加
      patch(oldStartVnode, newStartVnode); //递归比较儿子以及他们的子节点
      oldStartVnode = oldCh[++oldStartIndex];
      newStartVnode = newCh[++newStartIndex];
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      //尾和尾对比 依次向前追加
      patch(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 老的头和新的尾相同 把老的头部移动到尾部
      patch(oldStartVnode, newEndVnode);
      parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); //insertBefore可以移动或者插入真实dom
      oldStartVnode = oldCh[++oldStartIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 老的尾和新的头相同 把老的尾部移动到头部
      patch(oldEndVnode, newStartVnode);
      parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
      oldEndVnode = oldCh[--oldEndIndex];
      newStartVnode = newCh[++newStartIndex];
    } else {
      // 上述四种情况都不满足 那么需要暴力对比
      // 根据老的子节点的key和index的映射表 从新的开始子节点进行查找 如果可以找到就进行移动操作 如果找不到则直接进行插入
      let moveIndex = map[newStartVnode.key];
      if (!moveIndex) {
        // 老的节点找不到  直接插入
        parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
        newStartVnode = newCh[++newStartIdx]
      } else {
        let moveVnode = oldCh[moveIndex]; //找得到就拿到老的节点
        oldCh[moveIndex] = undefined; //这个是占位操作 避免数组塌陷  防止老节点移动走了之后破坏了初始的映射表位置
        parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到最前面
        patch(moveVnode, newStartVnode);
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  // 如果老节点循环完毕了 但是新节点还有  证明  新节点需要被添加到头部或者尾部
  if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 这是一个优化写法 insertBefore的第一个参数是null等同于appendChild作用
      const ele =
        newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].el;
      parent.insertBefore(createElm(newCh[i]), ele);
    }
  }
  // 如果新节点循环完毕 老节点还有  证明老的节点需要直接被删除
  if (oldStartIndex <= oldEndIndex) {
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      let child = oldCh[i];
      if (child != undefined) {
        parent.removeChild(child.el);
      }
    }
  }
}

函数功能分析:

  1. 首先将接收Vnode的子节点newCh,以及oldVnode的子节点oldCh
  2. 给newCh与oldVnode首尾分别新增两个变量newStartIdx、newEndIdx、oldStartIdx,oldEndIdx,进行两两比较,一共有4中方式,如果都没有匹配上,如果设置了key,就会用key进行比较,比较过程中,变量会往中间靠拢。
  3. 如果oldStartIdx > oldEndIdx,说明oldVnode提早结束遍历,Vnode剩余的节点就要插入到dom中 如果newStartIdx > newEndIdx,Vnode提早结束遍历,oldVnode有剩余,将oldVnod对应的剩余节点在对应的dom中删除

补充说明:如果前四种都没你匹配上,就使用key来进行比较。具体比较方式如下:

  1. 首先取出oldCh的oldStartIdx至oldEndIdx之间对应的节点,保存成对象oldKeyToIdx,属性为节点的key,属性值为节点的下标i
  2. 取出newStartIdx对应的key,去oldKeyToIdx中匹配
    • 没有匹配上,说明说是新增的节点,将节点插入到真实的dom中
    • 匹配上了,判断是否是同类型节点?
      • 是同类型节点,继续进行深层次比较(继续进入子节点比较),然后将当前的oldCh对应的子节点设置为undefined(oldCh[idxInOld] = undefined)
      • 不是同类型节点,说明只是key相同,就会将newStartIdx对应的节点作为新节点插入

接下来看一下具体实例

实例1

image.png 接下来分析一下比较过程

image.png 第1步

  oldS=a oldE=c
  newS=b newE=a

比较结果:oldS与newE相等,所以要将节点a移到最后,然后oldS++,newE--

image.png 第2步

  oldS=b oldE=c
  newS=b newE=e

比较结果:oldS与newS相等,所以要将节点b移到第1位,由于此时b就是第一位,就不移动了,然后oldS++,newS++

image.png 第3步

  oldS=c oldE=c
  newS=c newE=e

比较结果:oldS、oldE与newS相等,所以要将节点c移到第2位,此时c已经是第2位,就不移动了,然后oldS++,oldE--,newS++

image.png 由于此时oldS > oldE,oldVnode提早遍历完,因此需要将Vnode剩下的e插入到第3位 因此最新的dom为:

image.png

我们再来看一个收尾变量两两交叉匹配不上的情况

实例2

image.png 接下来分析一下比较过程

image.png

第1步

  oldS=a oldE=c
  newS=b newE=e

比较结果:两两交叉4种对比都没有匹配上,所以将newS=b与oldS=a至oldE=c中的a b c进行匹配,结果与b匹配上了。然后oldVnode的b设置为undefined(简写und),因此dom中b需要移到第一位。最后newS++

image.png

第2步

  oldS=a oldE=c
  newS=a newE=e

比较结果:oldS与newS相同,所以要将a移到第2位,因为此时dom中a就在第2位,因此就不移动。最后oldS++,newS++

image.png 第3步

  oldS=und oldE=c
  newS=c newE=e

比较结果:oldE与newS相同,所以要将c移到最后,此时dom中c就是在最后,因此不移动。最后oldE--,newS++

image.png 第4步

  oldS=und oldE=und
  newS=e newE=e

比较结果:由于此时newS与newE中的e没被匹配上,因此将e作为新节点插入到dom中,然后newS++,newE--

image.png

由于此时newS > newE,Vnode先遍历完,而oldVnode中还剩undefined节点,将它在dom中删除,由于dom并没有undefined,因此就不用删除啦!因此最终的dom排序,如下所示:

image.png

三、用index做为key

平时使用v-for时,为啥不建议使用index作为key呢?看一下下面这个例子, 往li中插入一个新值new,结果导致四个节点都被更新了

<ul>                        <ul>
    <li key="0">a</li>        <li key="0">new</li>
    <li key="1">b</li>        <li key="1">a</li>
    <li key="2">c</li>        <li key="2">b</li>
                              <li key="3">c</li>
</ul>                       </ul>

执行流程分析:

  1. 首先执行patch时,会比较Vnode与oldVnode的首部节点ul,他们是同种类型节点,就会调用pathVnode方法进一步的比较
  2. 他们都有子节点,并且是不同的,所以会进入updateChildren方法中执行
  3. 因为新旧节点li中key=0, 1, 2值相同,他们是属于同种类型节点,因此会进入patchVnode中,更新本文节点(a->new、b->a、c->b),而key=3的li节点由于在旧的虚拟dom找不到,会被当作新的节点插入到真实的dom中

那如果li中使用唯一的id,情况又有何不同呢?请看下面这个例子

<ul>                        <ul>
                                <li key="104">new</li>
    <li key="101">a</li>        <li key="101">a</li>
    <li key="102">b</li>        <li key="102">b</li>
    <li key="103">c</li>        <li key="103">c</li>
</ul>                       </ul>

执行流程分析:

  1. 首先执行patch时,会比较Vnode与oldVnode的首部节点ul,他们是同种类型节点,就会调用pathVnode方法进一步的比较
  2. 他们都有子节点,并且是不同的,所以会进入updateChildren方法中执行
  3. 使用收尾变量两两交叉四种匹配法
a b c
  \ \ \
new a b c
1次,c匹配上,位置也一样,复用
第2次,b匹配上,位置也一样,复用
第3次,a匹配上,位置也一样,复用
最后新的虚拟dom中剩余new节点,作为新节点,插入到dom中

小结:我们在使用v-for时,一定要使用唯一的标识作为key的值,这样能节约性能

四、参考