Vue的diff算法核心原理

写在最前:本文转发掘金
类似文章

什么是虚拟DOM

讲 Diff 算法钱,先了解下虚拟DOM,有利于对算法的理解。
虚拟DOM是一个用来表示真实DOM的对象,以下是真实DOM:

<ul id="list">
  <li class="item">哈哈</li>
  <li class="item">呵呵</li>
  <li class="item">嘿嘿</li>
</ul>

对应的虚拟DOM为:

let oldVDOM = {
  tagName: 'ul',   // 标签名
  props: {  //标签属性
    id: 'list'
  },
  children: [
    {
      tagName: 'li', props: {class: 'itme'}, children: ['哈哈']
    },
    {
      tagName: 'li', props: {class: 'itme'}, children: ['呵呵']
    },
    {
      tagName: 'li', props: {class: 'itme'}, children: ['嘿嘿']
    }
  ]
}

这时候,我修改一个 li标签 的文本

<ul id="list">
    <li class="item">哈哈</li>
    <li class="item">呵呵</li>
    <li class="item">林三心哈哈哈哈哈</li> // 修改
</ul>

生成的新虚拟DOM为:

let newVDOM = { // 新虚拟DOM
        tagName: 'ul', // 标签名
        props: { // 标签属性
            id: 'list'
        },
        children: [ // 标签子节点
            {
                tagName: 'li', props: { class: 'item' }, children: ['哈哈']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['呵呵']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['林三心哈哈哈哈哈']
            },
        ]
    }

这就是咱们平常说的新旧两个虚拟DOM,这个时候的新虚拟DOM是数据的最新状态,那么我们直接拿新虚拟DOM去渲染成真实DOM的话,效率真的会比直接操作真实DOM高吗?那肯定是不会的,看下图:


watermark1.jpg

由上图,一看便知,肯定是第2种方式比较快,因为第1种方式中间还夹着一个虚拟DOM的步骤,所以虚拟DOM比真实DOM快这句话其实是错的,或者说是不严谨的。虚拟DOM算法操作真实DOM,性能高于直接操作真实DOM,虚拟DOM和虚拟DOM算法是两种概念。虚拟DOM算法 = 虚拟DOM + Diff算法。

什么是 Diff 算法

watermark2.jpg

上图中,其实只有li标签修改了文本,所以没必要更新所有节点,只更新这个li标签就行,Diff算法就是查出这个li标签的算法。
总结:Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其它数据没发生变化的节点,实现精准地更新真实DOM,进而提高效率。

  • 使用虚拟DOM算法的损耗计算: 总损耗 = 虚拟DOM增删改 + (与Diff算法效率有关)真实DOM差异增删改+ (较少的节点)排版与重绘
  • 直接操作真实DOM的损耗计算: 总损耗 = 真实的DOM完全增删改 + (可能较多的节点)排版与重绘

Diff 算法的原理

Diff 同层对比

新旧虚拟DOM对比的时候,Diff算法比较只会在同层进行,不会跨层比较。所以Diff算法是:深度优先算法。时间复杂度:O(n)


watermark3.jpg

Diff 对比流程

当数据改变时,会触发 setter,并且通过Dep.notify 去通知所有订阅者Watcher,订阅者们会调用pathc方法,给真实DOM打补丁,更新相应的视图。
newVnode 和 oldVnode: 同层的新旧虚拟节点

watermark4.jpg

patch 方法

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

  • 是:继续执行 patchVnode方法进行深层对比
  • 否: 没必要对比,直接整个节点替换成新虚拟节点

来看看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 方法

patch关键的一步就是 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 方法

这个函数做了一下事情

  • 找到对应的真实DOM,成为 el
  • 判断newVnodeoldVnode 是否指向同一个对象,如果是,那么直接 return
  • 如果他们都有文本节点并且不相等,那么将el 的文本节点设置为newVnode的文本节点
  • 如果oldVnode 有子节点而 newVnode没有,则删除el的子节点
  • 如果oldVnode 没有子节点而 newVnode 有,则将newVnode的子节点插入真实DOM的el
  • 如果两者都有子节点,则执行updateChildren 函数比较子节点,这一步很重要
function patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el;   // 获取真实的对象
  // 获取新旧虚拟节点的子节点数组
  const oldCh = oldVnode.children, newCh = newVnode.children;
  // 如果新旧虚拟节点是同一个对象则终止
  if (oldVnode === newVnode) return
  // 如果新旧虚拟节点是文本节点,且文本不一样
  if(oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text){
    // 则直接将真实DOM中文本更新为新虚拟节点的文本
    api.setTextContent(el, newVnode.text)
  } else {
    // 反之,非文本节点
    if(oldCh && newCh && oldCh!== newCh){
      // 新旧虚拟节点都有子节点,且子节点不同
      // 对比子节点,并更新
      updateChildren(el, oldCh, newCh)
    } else if(newCh){
      // 新虚拟节点有子节点,旧虚拟节点没有
      // 创建新虚拟节点的子节点,并更新到真实DOM上
      createEle(newVnode)
    } else if(oldCh){
      // 旧虚拟节点有子节点,新虚拟节点没有
      // 直接删除真实DOM里对应的子节点
      api.removeChild(el)
    }
  }
}

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

updateChildren 方法
这是patchVnode 里重要的一个方法,新旧虚拟节点的子节点对比,就发生在updateChildren方法中。
这是个首尾指针法,新的子节点集合和旧的子节点集合,各有首尾两个指针,举个例子:

<ul>
  <li>a</li>
  <li>b</li>
  <li>c</li>
</ul>
<!--修改后的数据-->
<ul>
    <li>b</li>
    <li>c</li>
    <li>e</li>
    <li>a</li>
</ul>

那么新旧两个子节点集合以及其首尾指针为:


watermark5.jpg

然后会进行互相比较,总共有五种比较情况:

    1. oldS 和 newS 使用 sameVnode方法 进行比较, sameVnode(oldS, newS)
    1. oldS 和 newE 使用 sameVnode方法 进行比较, sameVnode(oldS, newE)
    1. oldE 和 newS 使用 sameVnode方法 进行比较, sameVnode(oldE, newS)
    1. oldE 和 newE 使用 sameVnode方法 进行比较, sameVnode(oldE, newE)
    1. 如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnodekey 去找出旧节点中可以复用的位置。
      watermark6.jpg

      接下来就以上面代码为例,分析一下比较的过程
      注意,最终的渲染结果都要以newVDOM为准,这也解释了为什么之后的节点移动需要移动到 newVDOM 所对应的位置
      watermark7.jpg
  • 第一步
oldS = a, oldE = c
newS = b, newE = a

比较结果: oldS 和 newE 相等,需要把 节点a 移动到 newE 所对用的位置,也就是末尾,同时oldS++newE--

watermark8.jpg

  • 第二步
oldS = b, oldE = c
newS = b, newE = e

比较结果: oldS 和 newS 相等,需要把节点b移动到newS所对应的位置,同时oldS++,newS++

watermark9.jpg

  • 第三步
oldS = c, oldE = c
newS = c, newE = e

比较结果:oldS、oldE 和 newS相等,需要把节点c移动到newS所对应的位置,同时oldS++,newS++

watermark10.jpg

  • 第四步
    oldS>oldE,则oldCh先遍历完成了,而newCh还未遍历完,所以需要将多出来的节点,插入到真实DOM上对应的位置上
    watermark11.jpg

附上updateChildren 核心代码

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0, 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
  let idxInOld
  let elmToMove
  let before
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx]
    } 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)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode)
      api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 使用key时的比较
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
      }
      idxInOld = oldKeyToIdx[newStartVnode.key]
      if (!idxInOld) {
        api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
        newStartVnode = newCh[++newStartIdx]
      }
      else {
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
        } else {
          patchVnode(elmToMove, newStartVnode)
          oldCh[idxInOld] = null
          api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  if (oldStartIdx > oldEndIdx) {
    before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
    addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

用index做key

平常v-for循环渲染的时候,为什么不建议用index作为循环项的key呢?
我们举个例子,左边初始数据,然后我在数据前插入一个新数据,变成右边的列表

<ul>                      <ul>
    <li key="0">a</li>        <li key="0">林三心</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>

按理说,最理想的结果是:只插入一个li标签新节点,其它都不动,确保操作DOM效率最高。但是我们这里用了index来当key的话,过程是所有li标签都更新了,为啥会是这样呢?我们通过图来解释一下。
按理说,a,b,c三个li标签都是复用之前的,因为它们三个都没有改变,改变的只是前面新增了一个林三心

按照之前介绍的diff算法,会进行旧首节点和新首节点 samgNode 对比,这一步命中了逻辑,因为现在新旧两次首部节点的key都是0,同理,key为1和2的也命中了逻辑,导致相同key的节点会去进行patchVnode更新文本,知道最后原本就有的c节点,变成了新增节点。

watermark12.jpg

这就是为什么需要用id来做key的原因了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354

推荐阅读更多精彩内容

  • keep-alive 组件有什么作用? keep-alive 是 vue 的内置组件,一般情况下,组件进行切换的时...
    喜喜喜喜喜阅读 736评论 0 1
  • Diff算法的作用是用来计算出 Virtual DOM 中被改变的部分,然后针对该部分进行原生DOM操作,而不用重...
    古月丶阅读 50,055评论 1 42
  • 1. 当数据发生变化时,vue是怎么更新节点的? 要知道渲染真实DOM的开销是很大的,比如有时候我们修改了某个数据...
    Marting424阅读 474评论 0 0
  • 前言 我的目标是写一个非常详细的关于diff的干货,所以本文有点长。也会用到大量的图片以及代码举例,目的让看这篇文...
    flyinskybiu阅读 437评论 0 0
  • vue是现在主流前端框架之一,采用了很多高级特性,如虚拟DOM,那么它是如何批量更新的,我们一起来了解下。 数据变...
    老鼠AI大米_Java全栈阅读 905评论 0 4