资讯专栏INFORMATION COLUMN

virtual dom及diff算法

xuexiangjys / 2470人阅读

摘要:原文地址此篇结合我最近造的小轮子进行分析,其中的算法主要参考库。如果其中一方没有子节点,那么进行删除或添加。判断是否一方已遍历完,对真实进行相应的删减或添加。

原文地址:https://github.com/HolyZheng/...
此篇结合我最近造的小轮子hoz进行分析,其中的virtual dom算法主要参考snabbdom库。
virtual dom 什么是virtual dom

virtual dom的本质是一个用来映射真实dom的JavaScript对象,比如

// hoz库中的 src/vdom/vnode.js

class VNode {
  constructor (sel, tagName, id, className, el, children, data, text, key) {
    this.tagName = tagName // DIV
    this.sel = sel // div#id.class
    this.id = id // id
    this.className = className // []
    this.children = children // []
    this.el = el // node
    this.data = data // {}
    this.key = key
    this.text = text
  }
}

通过一个vnode对象去对应一个dom元素,vnode的属性对应反映dom元素的属性。然后我们可以定义一个toVnode方法,把一个dom tree转为virtual dom tree。

// hoz库中 src/vdom/toVnode.js

import VNode from "./vnode"
import * as utils from "./is"

function toVnode (node, utilsTool) {
  const api = (utilsTool === undefined ? utils : utilsTool) // 自定义的一些工具
  let text

// 判断是否为node节点,不是抛出错误
  if (!node) {
    throw new Error("Please make sure you have pass the argument "node" in to function toVnode")
  }

// 如果是element类型节点
  if (api.isElement(node)) {

   // 收集该节点各属性信息,这里实现方式比较多,只要获取到需要的足够的信息就行了
  // 这里获取了id,类名cn,类名数组ca,类字符串如 .classA.classB,sel 等等
    const id = node.id ? node.id : ""
    const cn = node.getAttribute("class")
    const ca = cn ? cn.split(" ") : null // classArray
    const c = cn ? "." + cn.split(" ").join(".") : "" // .classA.classB
    const sel = node.tagName.toLowerCase() + "#" + id + c
    const attrs = {}
    const elmAttrs = node.attributes
    const elmChildren = node.childNodes
    const children = elmChildren.length ? [] : null
    const tag = node.tagName
    let i, n
  
    for (i = 0, n = elmAttrs.length; i < n; i++) {
      if (elmAttrs[i].nodeName !== "class" && elmAttrs[i].nodeName !== "id") {
        // id 和 class例外处理
        attrs[elmAttrs[i].nodeName] = elmAttrs[i].nodeValue
      }
    }

// 如果给节点指定了key属性,则获取key的值
    const key = attrs.key ? attrs.key : undefined

// 如果有子节点,对子节点递归调用toVnode方法
    for (i = 0, n = elmChildren.length; i < n; i++) {
      children.push(toVnode(elmChildren[i], api))
    }
    return new VNode(sel, tag, id, ca, node, children, attrs, undefined, key)

// 文本节点
  } else if (api.isText(node)) {
    text = node.textContent
    return new VNode(undefined, undefined, undefined, undefined, node, undefined, undefined, text, undefined)

// 注释节点
  } else if (api.isComment(node)) {
    text = node.textContent
    return new VNode("commont", undefined, undefined, undefined, node, undefined, undefined, text, undefined)
  } else {
    return new VNode("", undefined, undefined, undefined, node, undefined, undefined, undefined, undefined)
  }
}

export default toVnode

toVnode方法说白了就是,判断当前节点类型,创建对应类型的vnode,然后如果有子节点的话,对子节点递归调用toVnode方法,将子节点也转为vnode,执行结束后,我们就可以得到一棵以传入节点为根节点的virtual dom tree了。

到这里我们已经有了一个映射真实dom的virtual dom类(vnode),以及一个将dom转为virtual dom方法(toVnode)

diff算法

接下来到了关键的diff算法,diff算法就是用来对比新旧两个vnode,计算出最小的修改单位,去操作更新真实的dom。下面是一张解释diff流程的经典图,我相信你不是第一次看到

diff会算法会在同一层比较新旧两个vnode,从而将算法复杂度降低到O(n)。hoz库中diff算法的实现在patch.js文件中,这里面有几个关键的函数

patch
/**
 * 比较新老节点,相同则进行patchVnode,不同的话在真实dom上
 * 用新的节点,取代旧的节点。
 */

export default function patch (oldVnode, vnode) {
  if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
  } else {
    const oldElement = oldVnode.el
    let parentElement = api.parentNode(oldElement)
    createElm(vnode)
    if (parentElement !== null) {
      api.insertBefore(parentElement, vnode.el, api.nextSibling(oldElement))
      api.removeChild(parentElement, oldVnode.el)
    }
  }
  return vnode
}

第一个关键函数就是我们diff算法的入口函数patch方法,该方法负责,判断oldVnode,和vnode是否为同一节点,如果是,调用patchVnode方法,进一步比较处理,不是的话,用新节点代替就节点。

ps:通过sameVnode进行判断

function sameVnode (oldVnode, vnode) {
  return vnode.key === oldVnode.key && vnode.tagName === oldVnode.tagName
  // 这里可按自己需要进行比较
}
patchVnode

上面提到,如果是同一节点的话,就会进入到patchVnode方法中

function patchVnode (oldVnode, vnode) {
  const element = vnode.el = oldVnode.el
  let oldCh = oldVnode.children
  let ch = vnode.children
  // 如果相同,不需要比较
  if (oldVnode === vnode) {
    console.log("oldVnode === vnode")
    return
  }
  if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
    api.setTextContent(element, vnode.text)
  } else {
    // 更新element的className, id, 和其他属性
    updateElm(element, vnode)
    if (oldCh && ch && oldCh !== ch) {
      updateChildren(element, oldCh, ch)
    } else if (ch) {
      // 新vnode有子节点,oldvnode没有子节点,给element添加子节点
      addVnodes(element, null, ch, 0, ch.length - 1)
    } else if (oldCh) {
      // 新vnode没有子节点,oldvnode有子节点,给element删除子节点
      removeVnodes(element, oldCh, 0, oldCh.length - 1)
    }
  }
}

patchVnode方法的任务就是

比较oldVnode与vnode,如果两者完全相同,没有变化的话,直接return。不同的话,用新的vnode去更新oldVnode。

比较它们的子节点,如果都由子节点,调用updateChildren去比较它们的子节点。

如果其中一方没有子节点,那么进行dom删除或添加。

添加节点:通过addVnode,遍历vnode子节点,调用createElm创建真实dom,插入到真实dom中。

删除节点:通过removeVnode,遍历oldvnode,并从真实dom中删除。

updateChildren

刚刚说到,如果它们都有子节点,那么会调用updateChildren去继续比较它们的子节点,updateChildren是这里的难点,可以说,最复杂的操作就是这一步,代码

function updateChildren (parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let newEndIdx = newCh.length - 1
  let oldStartVnode = oldCh[0]
  let newStartVnode = newCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx
  let idxInOld
  let emlToMove
  let before
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 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 {
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIndex(oldCh, oldStartIdx, oldEndIdx)
      }
      idxInOld = oldKeyToIdx[newStartVnode.key]
      // idx 不存在,说明该节点是新增的,原来没有的,故创建一个新节点,并插入
      if (!idxInOld) {
        api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.el)
        newStartVnode = newCh[++newStartIdx]
      } else {
        emlToMove = oldCh[idxInOld]
        // sel 不同的话,证明也是一个原来没有的节点,所以创建并插入
        if (emlToMove.sel !== newStartVnode.sel) {
          api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.el)
        } else {
          patchVnode(emlToMove, newStartVnode)
          oldCh[idxInOld] = null
          api.insertBefore(parentElm, emlToMove.el, oldStartVnode.el)
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
  }
  // 这是oldVnode已经遍历完,vnode还没有,说明vnode比oldVnode节点多,所以将多出的节点创建并插入
  if (oldStartIdx > oldEndIdx) {
    before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
    addVnodes(parentElm, before, newCh, newStartIdx, oldEndIdx)
  } else {
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
  }
}

下面图反映了updateChildren的运作方式

将oldStartIdx,oldEndIdx,newStartIdx,newEndIdx分别指向同层新旧vnode的首尾,然后随着比较而逐渐往中间移动,对4个节点进行比较,会有4种比较结果,(oldStartIdx和newStartIdx或newEndIdx指向同一节点,oldEndIdx和newStartIdx或newEndIdx指向同一节点这四种),根据4个节点的的4种比较结果做相应的操作,比如当出现oldStartIdx和newStartIdx指向同一节点这个结果时,表示这个节点不需要进行移位操作,直接调用patchVnode进行进一步比较,又比如出现oldStartIdx和newEndIdx指向同一节点这一结果,那么表示oldStartIdx指向的节点需要移动到oldEndIdx后面去,然后再对他们调用patchVnode进行进一步比较。

如果4种结果都不是,调用createKeyToOldIndex 方法,创建一个key与oldIndex之间的映射的map,查看newStartVnode.key是否有对应的oldVnode,如果有,说明oldvnode移动到了oldStartVnode前面,然后将对应的oldVnode插入到oldStartVnode前面,如果没有,说明在当前newStartVnode是新创建得节点,在oldStartVnode前面插入该节点。

判断是否一方已遍历完,对真实dom进行相应的删减或添加。

这就是diff得大致流程了。

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/95821.html

相关文章

  • react diff算法

    摘要:算法的本质是对传统遍历算法的优化策略用三大策略将复杂度转化为复杂度策略一中节点跨层级的移动操作特别少,可以忽略不计。当节点处于同一层级时,提供三种节点操作删除插入移动。在旧的节点中的,它的,不满足的条件,因此不做移动操作。 一、react diff算法 diff算法的作用 计算出Virtual DOM中真正变化的部分,并只针对该部分进行原生DOM操作,而非重新渲染整个页面。 传统di...

    imccl 评论0 收藏0
  • react基本原理性能优化

    摘要:对同一层级的子节点进行处理时,会根据进行简要的复用。二性能优化方案由于中性能主要耗费在于阶段的算法,因此性能优化也主要针对算法。此时最常用的优化方案即为方法。或者直接使用,原理一致。 一、从React原理谈起 react是什么? showImg(https://segmentfault.com/img/bVbcYvf?w=1140&h=384); react是用于构建用户界面的JS框架...

    VincentFF 评论0 收藏0
  • react基本原理性能优化

    摘要:对同一层级的子节点进行处理时,会根据进行简要的复用。或者直接使用,原理一致。 一、从React原理谈起 react是什么? showImg(https://segmentfault.com/img/bVbcYvf?w=1140&h=384); react是用于构建用户界面的JS框架。因此react只负责解决view层的渲染。 react做了什么? Virtual Dom模型 生命周期...

    pinecone 评论0 收藏0
  • React && VUE Virtual DomDiff算法统一之路 snabbd

    摘要:毫无疑问的是算法的复杂度与效率是决定能够带来性能提升效果的关键因素。速度略有损失,但可读性大大提高。因此目前的主流算法趋向一致,在主要思路上,与的方式基本相同。在里面实现了的算法与支持。是唯一添加的方法所以只发生在中。 VirtualDOM是react在组件化开发场景下,针对DOM重排重绘性能瓶颈作出的重要优化方案,而他最具价值的核心功能是如何识别并保存新旧节点数据结构之间差异的方法,...

    shixinzhang 评论0 收藏0
  • React diff算法

    摘要:传统算法通过循环递归对节点进行依次对比,效率低下,算法复杂度达到,其中是树中节点的总数。对于同一层级的一组子节点,它们可以通过唯一进行区分。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。 https://zhuanlan.zhihu.com/p/... React diff 会帮助我们计算出 Virtual DOM 中真正变化的部分,并只针对该部分进行实...

    caozhijian 评论0 收藏0

发表评论

0条评论

xuexiangjys

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<