关于前端:深入浅出虚拟-DOM-和-Diff-算法及-Vue2-与-Vue3-中的区别

2次阅读

共计 15081 个字符,预计需要花费 38 分钟才能阅读完成。

因为 Diff 算法,计算的就是虚构 DOM 的差别,所以先铺垫一点点虚构 DOM,理解一下其构造,再来一层层揭开 Diff 算法的面纱,深入浅出,助你彻底弄懂 Diff 算法原理

意识虚构 DOM

虚构 DOM 简略说就是 用 JS 对象来模仿 DOM 构造

那它是怎么用 JS 对象模仿 DOM 构造的呢?看个例子

<template>
    <div id="app" class="container">
        <h1> 沐华 </h1>
    </div>
</template>

下面的模板转在虚构 DOM 就是上面这样的

{
  'div',
  props:{id:'app', class:'container'},
  children: [{ tag: 'h1', children:'沐华'}
  ]
}

这样的 DOM 构造就称之为 虚构 DOM (Virtual Node),简称 vnode

它的表达方式就是把每一个标签都转为一个对象,这个对象能够有三个属性:tagpropschildren

  • tag:必选。就是标签。也能够是组件,或者函数
  • props:非必选。就是这个标签上的属性和办法
  • children:非必选。就是这个标签的内容或者子节点,如果是文本节点就是字符串,如果有子节点就是数组。换句话说 如果判断 children 是字符串的话,就示意肯定是文本节点,这个节点必定没有子元素

为什么要应用虚构 DOM 呢? 看个图

如图能够看出原生 DOM 有十分多的属性和事件,就算是创立一个空 div 也要付出不小的代价。而应用虚构 DOM 来晋升性能的点在于 DOM 发生变化的时候,通过 diff 算法和数据扭转前的 DOM 比照,计算出须要更改的 DOM,而后只对变动的 DOM 进行操作,而不是更新整个视图

在 Vue 中是怎么把 DOM 转成下面这样的虚构 DOM 的呢,有趣味的能够关注我另一篇文章具体理解一下 Vue 中的模板编译过程和原理

在 Vue 里虚构 DOM 的数据更新机制采纳的是异步更新队列,就是把变更后的数据变装入一个数据更新的异步队列,就是 patch,用它来做新老 vnode 比照

意识 Diff 算法

Diff 算法,在 Vue 外面就是叫做 patch,它的外围就是参考 Snabbdom,通过新旧虚构 DOM 比照(即 patch 过程),找出最小变动的中央转为进行 DOM 操作

扩大
在 Vue1 里是没有 patch 的,每个依赖都有独自的 Watcher 负责更新,当我的项目规模变大的时候性能就跟不上了,所以在 Vue2 里为了晋升性能,改为每个组件只有一个 Watcher,那咱们须要更新的时候,怎么能力准确找到组件里发生变化的地位呢?所以 patch 它来了

那么它是在什么时候执行的呢?

在页面 首次渲染 的时候会调用一次 patch 并创立新的 vnode,不会进行更深层次的比拟

而后是在 组件中数据发生变化时,会触发 setter 而后通过 Notify 告诉 Watcher,对应的 Watcher 会告诉更新并执行更新函数,它会执行 render 函数获取新的虚构 DOM,而后执行 patch 比照上次渲染后果的老的虚构 DOM,并计算出最小的变动,而后再去依据这个最小的变动去更新实在的 DOM,也就是视图

那么它是怎么计算的? 先看个图

比方有上图这样的 DOM 构造,是怎么计算出变动?简略说就是

  • 遍历老的虚构 DOM
  • 遍历新的虚构 DOM
  • 而后依据变动,比方下面的扭转和新增,再从新排序

可是这样会有很大问题,如果有 1000 个节点,就须要计算 1000³ 次,也就是 10 亿次,这样是无奈让人承受的,所以 Vue 或者 React 里应用 Diff 算法的时候都遵循深度优先,同层比拟的策略做了一些优化,来计算出 最小变动

Diff 算法的优化

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

如图,Diff 过程只会把同色彩框起来的同一层级的 DOM 进行比拟,这样来简化比拟次数,这是第一个方面

2. 比拟标签名

如果同一层级的比拟标签名不同,就间接移除老的虚构 DOM 对应的节点,不持续按这个树状构造做深度比拟,这是简化比拟次数的第二个方面

3. 比拟 key

如果标签名雷同,key 也雷同,就会认为是雷同节点,也不持续按这个树状构造做深度比拟,比方咱们写 v-for 的时候会比拟 key,不写 key 就会报错,这也就是因为 Diff 算法须要比拟 key

面试中有一道特地常见的题,就是让你说一下 key 的作用,实际上考查的就是大家对虚构 DOM 和 patch 细节的把握水平,可能反馈出咱们面试者的了解档次,所以这里扩大一下 key

key 的作用

比方有一个列表,咱们须要在两头插入一个元素,会产生什么变动呢?先看个图

如图的 li1li2 不会从新渲染,这个没有争议的。而 li3、li4、li5 都会从新渲染

因为在不应用 key 或者列表的 index 作为 key 的时候,每个元素对应的地位关系都是 index,上图中的后果间接导致咱们插入的元素到前面的全副元素,对应的地位关系都产生了变更,所以全副都会执行更新操作,这可不是咱们想要的,咱们心愿的是渲染增加的那一个元素,其余四个元素不做任何变更,也就不要从新渲染

而在应用惟一 key 的状况下,每个元素对应的地位关系就是 key,来看一下应用惟一 key 值的状况下

这样如图中的 li3li4 就不会从新渲染,因为元素内容没产生扭转,对应的地位关系也没有产生扭转。

这也是为什么 v-for 必须要写 key,而且不倡议开发中应用数组的 index 作为 key 的起因

总结一下:

  • key 的作用次要是为了更高效的更新虚构 DOM,因为它能够十分准确的找到雷同节点,因而 patch 过程会十分高效
  • Vue 在 patch 过程中会判断两个节点是不是雷同节点时,key 是一个必要条件。比方渲染列表时,如果不写 key,Vue 在比拟的时候,就可能会导致频繁更新元素,使整个 patch 过程比拟低效,影响性能
  • 应该防止应用数组下标作为 key,因为 key 值不是惟一的话可能会导致下面图中示意的 bug,使 Vue 无奈辨别它他,还有比方在应用雷同标签元素过渡切换的时候,就会导致只替换其外部属性而不会触发过渡成果
  • 从源码里能够晓得,Vue 判断两个节点是否雷同时次要判断两者的元素类型和 key 等,如果不设置 key,就可能永远认为这两个是雷同节点,只能去做更新操作,就造成大量不必要的 DOM 更新操作,显著是不可取的

有趣味的能够去看一下源码:src\core\vdom\patch.js -35 行 sameVnode(),上面也有具体介绍

Diff 算法外围原理——源码

下面说了 Diff 算法,在 Vue 外面就是 patch,铺垫了这么多,上面进入源码里看一下这个神乎其神的 patch 干了啥?

patch

源码地址:src/core/vdom/patch.js -700 行

其实 patch 就是一个函数,咱们先介绍一下源码里的外围流程,再来看一下 patch 的源码,源码里每一行也有正文

它能够接管四个参数,次要还是前两个

  • oldVnode:老的虚构 DOM 节点
  • vnode:新的虚构 DOM 节点
  • hydrating:是不是要和实在 DOM 混合,服务端渲染的话会用到,这里不过多阐明
  • removeOnly:transition-group 会用到,这里不过多阐明

次要流程是这样的:

  • vnode 不存在,oldVnode 存在,就删掉 oldVnode
  • vnode 存在,oldVnode 不存在,就创立 vnode
  • 两个都存在的话,通过 sameVnode 函数 (前面有详解) 比照是不是同一节点

    • 如果是同一节点的话,通过 patchVnode 进行后续比照节点文本变动或子节点变动
    • 如果不是同一节点,就把 vnode 挂载到 oldVnode 的父元素下

      • 如果组件的根节点被替换,就遍历更新父节点,而后删掉旧的节点
      • 如果是服务端渲染就用 hydrating 把 oldVnode 和实在 DOM 混合

上面看残缺的 patch 函数源码,阐明我都写在正文里了

// 两个判断函数
function isUndef (v: any): boolean %checks {return v === undefined || v === null}
function isDef (v: any): boolean %checks {return v !== undefined && v !== null}
return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // 如果新的 vnode 不存在,然而 oldVnode 存在
    if (isUndef(vnode)) {
      // 如果 oldVnode 存在,调用 oldVnode 的组件卸载钩子 destroy
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []
    
    // 如果 oldVnode 不存在的话,新的 vnode 是必定存在的,比方首次渲染的时候
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      // 就创立新的 vnode
      createElm(vnode, insertedVnodeQueue)
    } else {
      // 剩下的都是新的 vnode 和 oldVnode 都存在的话
      
      // 是不是元素节点
      const isRealElement = isDef(oldVnode.nodeType)
      // 是元素节点 && 通过 sameVnode 比照是不是同一个节点 (函数前面有详解)
      if (!isRealElement && sameVnode(oldVnode, vnode)) {// 如果是 就用 patchVnode 进行后续比照 (函数前面有详解)
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
      } else {
        // 如果不是同一元素节点的话
        if (isRealElement) {
          // const SSR_ATTR = 'data-server-rendered'
          // 如果是元素节点 并且有 'data-server-rendered' 这个属性
          if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
            // 就是服务端渲染的,删掉这个属性
            oldVnode.removeAttribute(SSR_ATTR)
            hydrating = true
          }
          // 这个判断里是服务端渲染的解决逻辑,就是混合
          if (isTrue(hydrating)) {if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {invokeInsertHook(vnode, insertedVnodeQueue, true)
              return oldVnode
            } else if (process.env.NODE_ENV !== 'production') {warn('这是一段很长的正告信息')
            }
          }
          // function emptyNodeAt (elm) {//    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
          //  }
          // 如果不是服务端渲染的,或者混合失败,就创立一个空的正文节点替换 oldVnode
          oldVnode = emptyNodeAt(oldVnode)
        }
        
        // 拿到 oldVnode 的父节点
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
        
        // 依据新的 vnode 创立一个 DOM 节点,挂载到父节点上
        createElm(
          vnode,
          insertedVnodeQueue,
          oldElm._leaveCb ? null : parentElm,
          nodeOps.nextSibling(oldElm)
        )
        
        // 如果新的 vnode 的根节点存在,就是说根节点被批改了,就须要遍历更新父节点
        if (isDef(vnode.parent)) {
          let ancestor = vnode.parent
          const patchable = isPatchable(vnode)
          // 递归更新父节点下的元素
          while (ancestor) {
            // 卸载老根节点下的全副组件
            for (let i = 0; i < cbs.destroy.length; ++i) {cbs.destroy[i](ancestor)
            }
            // 替换现有元素
            ancestor.elm = vnode.elm
            if (patchable) {for (let i = 0; i < cbs.create.length; ++i) {cbs.create[i](emptyNode, ancestor)
              }
              const insert = ancestor.data.hook.insert
              if (insert.merged) {for (let i = 1; i < insert.fns.length; i++) {insert.fns[i]()}
              }
            } else {registerRef(ancestor)
            }
            // 更新父节点
            ancestor = ancestor.parent
          }
        }
        // 如果旧节点还存在,就删掉旧节点
        if (isDef(parentElm)) {removeVnodes([oldVnode], 0, 0)
        } else if (isDef(oldVnode.tag)) {
          // 否则间接卸载 oldVnode
          invokeDestroyHook(oldVnode)
        }
      }
    }
    // 返回更新后的节点
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }

sameVnode

源码地址:src/core/vdom/patch.js -35 行

这个是用来判断是不是同一节点的函数

这个函数不长,间接看源码吧

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key 是不是一样
    a.asyncFactory === b.asyncFactory && ( // 是不是异步组件
      (
        a.tag === b.tag && // 标签是不是一样
        a.isComment === b.isComment && // 是不是正文节点
        isDef(a.data) === isDef(b.data) && // 内容数据是不是一样
        sameInputType(a, b) // 判断 input 的 type 是不是一样
      ) || (isTrue(a.isAsyncPlaceholder) && // 判断辨别异步组件的占位符否存在
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

patchVnode

源码地址:src/core/vdom/patch.js -501 行

这个是在新的 vnode 和 oldVnode 是同一节点的状况下,才会执行的函数,次要是比照节点文本变动或子节点变动

还是先介绍一下次要流程,再看源码吧,流程是这样的:

  • 如果 oldVnode 和 vnode 的援用地址是一样的,就示意节点没有变动,间接返回
  • 如果 oldVnode 的 isAsyncPlaceholder 存在,就跳过异步组件的查看,间接返回
  • 如果 oldVnode 和 vnode 都是动态节点,并且有一样的 key,并且 vnode 是克隆节点或者 v-once 指令管制的节点时,把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上,而后返回
  • 如果 vnode 不是文本节点也不是正文的状况下

    • 如果 vnode 和 oldVnode 都有子节点,而且子节点不一样的话,就调用 updateChildren 更新子节点
    • 如果只有 vnode 有子节点,就调用 addVnodes 创立子节点
    • 如果只有 oldVnode 有子节点,就调用 removeVnodes 删除该子节点
    • 如果 vnode 文本为 undefined,就删掉 vnode.elm 文本
  • 如果 vnode 是文本节点然而和 oldVnode 文本内容不一样,就更新文本
  function patchVnode (
    oldVnode, // 老的虚构 DOM 节点
    vnode, // 新的虚构 DOM 节点
    insertedVnodeQueue, // 插入节点的队列
    ownerArray, // 节点数组
    index, // 以后节点的下标
    removeOnly // 只有在
  ) {
    // 新老节点援用地址是一样的,间接返回
    // 比方 props 没有扭转的时候,子组件就不做渲染,间接复用
    if (oldVnode === vnode) return
    
    // 新的 vnode 实在的 DOM 元素
    if (isDef(vnode.elm) && isDef(ownerArray)) {
      // clone reused vnode
      vnode = ownerArray[index] = cloneVNode(vnode)
    }

    const elm = vnode.elm = oldVnode.elm
    // 如果以后节点是正文或 v-if 的,或者是异步函数,就跳过查看异步组件
    if (isTrue(oldVnode.isAsyncPlaceholder)) {if (isDef(vnode.asyncFactory.resolved)) {hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
      } else {vnode.isAsyncPlaceholder = true}
      return
    }
    // 以后节点是动态节点的时候,key 也一样,或者有 v-once 的时候,就间接赋值返回
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      vnode.key === oldVnode.key &&
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      vnode.componentInstance = oldVnode.componentInstance
      return
    }
    // hook 相干的不必管
    let i
    const data = vnode.data
    if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {i(oldVnode, vnode)
    }
    // 获取子元素列表
    const oldCh = oldVnode.children
    const ch = vnode.children
    
    if (isDef(data) && isPatchable(vnode)) {
      // 遍历调用 update 更新 oldVnode 所有属性,比方 class,style,attrs,domProps,events...
      // 这里的 update 钩子函数是 vnode 自身的钩子函数
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
      // 这里的 update 钩子函数是咱们传过来的函数
      if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
    }
    // 如果新节点不是文本节点,也就是说有子节点
    if (isUndef(vnode.text)) {
      // 如果新老节点都有子节点
      if (isDef(oldCh) && isDef(ch)) {
        // 如果新老节点的子节点不一样,就执行 updateChildren 函数,比照子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        // 如果新节点有子节点的话,就是说老节点没有子节点
        
        // 如果老节点文本节点,就是说没有子节点,就清空
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // 增加子节点
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 如果新节点没有子节点,老节点有子节点,就删除
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        // 如果老节点是文本节点,就清空
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      // 新老节点都是文本节点,且文本不一样,就更新文本
      nodeOps.setTextContent(elm, vnode.text)
    }
    if (isDef(data)) {
      // 执行 postpatch 钩子
      if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
    }
  }

updateChildren

源码地址:src/core/vdom/patch.js -404 行

这个是新的 vnode 和 oldVnode 都有子节点,且子节点不一样的时候进行比照子节点的函数

这里很要害,很要害!

比方当初有两个子节点列表比照,比照次要流程如下

循环遍历两个列表,循环进行条件是:其中一个列表的开始指针 startIdx 和 完结指针 endIdx 重合

循环内容是:{

  • 新的头和老的头比照
  • 新的尾和老的尾比照
  • 新的头和老的尾比照
  • 新的尾和老的头比照。这四种对比方图

以上四种只有有一种判断相等,就调用 patchVnode 比照节点文本变动或子节点变动,而后挪动比照的下标,持续下一轮循环比照

如果以上四种状况都没有命中,就一直拿新的开始节点的 key 去老的 children 里找

  • 如果没找到,就创立一个新的节点
  • 如果找到了,再比照标签是不是同一个节点

    • 如果是同一个节点,就调用 patchVnode 进行后续比照,而后把这个节点插入到老的开始后面,并且挪动新的开始下标,持续下一轮循环比照
    • 如果不是雷同节点,就创立一个新的节点

    }

  • 如果老的 vnode 先遍历完,就增加新的 vnode 没有遍历的节点
  • 如果新的 vnode 先遍历完,就删除老的 vnode 没有遍历的节点

为什么会有头对尾,尾对头的操作?

因为能够疾速检测出 reverse 操作,放慢 Diff 效率

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0 // 老 vnode 遍历的下标
    let newStartIdx = 0 // 新 vnode 遍历的下标
    let oldEndIdx = oldCh.length - 1 // 老 vnode 列表长度
    let oldStartVnode = oldCh[0] // 老 vnode 列表第一个子元素
    let oldEndVnode = oldCh[oldEndIdx] // 老 vnode 列表最初一个子元素
    let newEndIdx = newCh.length - 1 // 新 vnode 列表长度
    let newStartVnode = newCh[0] // 新 vnode 列表第一个子元素
    let newEndVnode = newCh[newEndIdx] // 新 vnode 列表最初一个子元素
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly
    
    // 循环,规定是开始指针向右挪动,完结指针向左挪动挪动
    // 当开始和完结的指针重合的时候就完结循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx]
        
        // 老开始和新开始比照
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        // 是同一节点 递归调用 持续比照这两个节点的内容和子节点
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // 而后把指针后移一位,从前往后顺次比照
        // 比方第一次比照两个列表的[0],而后比[1]...,前面同理
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 老完结和新完结比照
      } else if (sameVnode(oldEndVnode, newEndVnode)) {patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 而后把指针前移一位,从后往前比
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老开始和新完结比照
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        // 老的列表从前往后取值,新的列表从后往前取值,而后比照
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
        
        // 老完结和新开始比照
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        // 老的列表从后往前取值,新的列表从前往后取值,而后比照
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
        
        // 以上四种状况都没有命中的状况
      } else {if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 拿到新开始的 key,在老的 children 里去找有没有某个节点有这个 key
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
          
        // 新的 children 里有,可是没有在老的 children 里找到对应的元素
        if (isUndef(idxInOld)) {
          /// 就创立新的元素
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          // 在老的 children 里找到了对应的元素
          vnodeToMove = oldCh[idxInOld]
          // 判断标签如果是一样的
          if (sameVnode(vnodeToMove, newStartVnode)) {
            // 就把两个雷同的节点做一个更新
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // 如果标签是不一样的,就创立新的元素
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // oldStartIdx > oldEndIdx 阐明老的 vnode 先遍历完
    if (oldStartIdx > oldEndIdx) {
      // 就增加从 newStartIdx 到 newEndIdx 之间的节点
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    
    // 否则就阐明新的 vnode 先遍历完
    } else if (newStartIdx > newEndIdx) {
      // 就删除掉老的 vnode 里没有遍历的节点
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

至此,整个 Diff 流程的外围逻辑源码到这就完结了,再来看一下 Vue 3 里做了哪些扭转吧

Vue3 的优化

本文源码版本是 Vue2 的,在 Vue3 里整个重写了 Diff 算法这一块货色,所以源码的话能够说根本是齐全 不一样 的,然而要做的事还是一样的

对于 Vue3 的 Diff 残缺源码解析还在撰稿中,过几天就公布了,这里先介绍一下相比 Vue2 优化的局部,尤大颁布的数据就是 update 性能晋升了 1.3~2 倍ssr 性能晋升了 2~3 倍,来看看都有哪些优化

  • 事件缓存:将事件缓存,能够了解为变成动态的了
  • 增加动态标记:Vue2 是全量 Diff,Vue3 是动态标记 + 非全量 Diff
  • 动态晋升:创立动态节点时保留,后续间接复用
  • 应用最长递增子序列优化了比照流程:Vue2 里在 updateChildren() 函数里比照变更,在 Vue3 里这一块的逻辑次要在 patchKeyedChildren() 函数里,具体看上面

事件缓存

比方这样一个有点击事件的按钮

<button @click="handleClick"> 按钮 </button>

来看下在 Vue3 被编译后的后果

export function render(_ctx, _cache, $props, $setup, $data, $options) {return (_openBlock(), _createElementBlock("button", {onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
  }, "按钮"))
}

留神看,onClick 会先读取缓存,如果缓存没有的话,就把传入的事件存到缓存里,都能够了解为变成动态节点了,优良吧,而在 Vue2 中就没有缓存,就是动静的

动态标记

看一下动态标记是啥?

源码地址:packages/shared/src/patchFlags.ts

export const enum PatchFlags {
  TEXT = 1 ,  // 动静文本节点
  CLASS = 1 << 1,  // 2   动静 class
  STYLE = 1 << 2,  // 4   动静 style
  PROPS = 1 << 3,  // 8   除去 class/style 以外的动静属性
  FULL_PROPS = 1 << 4,       // 16  有动静 key 属性的节点,当 key 扭转时,需进行残缺的 diff 比拟
  HYDRATE_EVENTS = 1 << 5,   // 32  有监听事件的节点
  STABLE_FRAGMENT = 1 << 6,  // 64  一个不会扭转子节点程序的 fragment (一个组件内多个根元素就会用 fragment 包裹)
  KEYED_FRAGMENT = 1 << 7,   // 128 带有 key 属性的 fragment 或局部子节点有 key
  UNKEYEN_FRAGMENT = 1 << 8, // 256  子节点没有 key 的 fragment
  NEED_PATCH = 1 << 9,       // 512  一个节点只会进行非 props 比拟
  DYNAMIC_SLOTS = 1 << 10,   // 1024   动静 slot
  HOISTED = -1,  // 动态节点 
  BAIL = -2      // 示意 Diff 过程中不须要优化
}

先理解一下动态标记有什么用?看个图

在什么中央用到的呢?比方上面这样的代码

<div id="app">
    <div> 沐华 </div>
    <p>{{age}}</p>
</div>

在 Vue2 中编译的后果是,有趣味的能够自行装置 vue-template-compiler 自行测试

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [_c('div',[_v("沐华")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

在 Vue3 中编译的后果是这样的,有趣味的能够点击这里自行测试

const _hoisted_1 = {id: "app"}
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}

看到下面编译后果中的 -11 了吗,这就是动态标记,这是在 Vue2 中没有的,patch 过程中就会判断这个标记来 Diff 优化流程,跳过一些动态节点比照

动态晋升

其实还是拿下面 Vue2 和 Vue3 动态标记的例子,在 Vue2 里每当触发更新的时候,不论元素是否参加更新,每次都会全副从新创立,就是上面这一堆

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [_c('div',[_v("沐华")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

而在 Vue3 中会把这个不参加更新的元素保存起来,只创立一次,之后在每次渲染的时候不停地复用,比方下面例子中的这个,动态的创立一次保存起来

const _hoisted_1 = {id: "app"}
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

而后每次更新 age 的时候,就只创立这个动静的内容,复用下面保留的动态内容

export function render(_ctx, _cache, $props, $setup, $data, $options) {return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}

patchKeyedChildren

在 Vue2 里 updateChildren 会进行

  • 头和头比
  • 尾和尾比
  • 头和尾比
  • 尾和头比
  • 都没有命中的比照

在 Vue3 里 patchKeyedChildren

  • 头和头比
  • 尾和尾比
  • 基于最长递增子序列进行挪动 / 增加 / 删除

看个例子,比方

  • 老的 children:[a, b, c, d, e, f, g]
  • 新的 children:[a, b, f, c, d, e, h, g]
  1. 先进行头和头比,发现不同就完结循环,失去 [a, b]
  2. 再进行尾和尾比,发现不同就完结循环,失去 [g]
  3. 再保留没有比拟过的节点 [f, c, d, e, h],并通过 newIndexToOldIndexMap 拿到在数组里对应的下标,生成数组 [5, 2, 3, 4, -1]-1 是老数组里没有的就阐明是新增
  4. 而后再拿取出数组里的最长递增子序列,也就是 [2, 3, 4] 对应的节点 [c, d, e]
  5. 而后只须要把其余残余的节点,基于 [c, d, e] 的地位进行挪动 / 新增 / 删除就能够了

应用最长递增子序列能够最大水平的缩小 DOM 的挪动,达到起码的 DOM 操作,有趣味的话去 leet-code 第 300 题(最长递增子序列) 体验下

往期精彩

  • Vue3 的 7 种和 Vue2 的 12 种组件通信,值得珍藏
  • 最新的 Vue3.2 都更新了些什么理解一下
  • JavaScript 进阶知识点
  • 22 个高频 JavaScript 手写代码理解一下
  • 前端异样监控和容灾

结语

如果本文对你有一丁点帮忙,点个赞反对一下下吧,感激感激

本文首发掘金:https://juejin.cn/post/701059…

已受权稀土掘金技术社区公众号独家应用,请勿转载,感激

正文完
 0