【无标题】

文章详细介绍了为何需要diff算法,以及在Vue框架中,如何利用虚拟DOM来优化性能。diff算法的核心是通过比较新老虚拟DOM节点,找出最小更新策略,减少真实DOM的操作,从而提升应用性能。文中还提供了具体的diff算法实现步骤,包括属性更新和子节点的比对更新。
摘要由CSDN通过智能技术生成

为什么需要 diff 算法?

再说 diff 算法之前,需要了解一下什么是虚拟 DOM

真实 DOM 与 虚拟 DOM

我们都知道,网页是由一个个 dom 组成的,其中,这种 dom 就是真实 dom ,也就是我们常说的标签元素。那么既然有真实 dom,就也存在与之相对的虚拟 dom,为什么需要虚拟 dom 呢?

在使用 vue 的时候,如果每一次有值更改,如果都需要手动的去获取 dom 并且更新 dom,所带来的代价着实过大,及其损耗性能,在这种情况下,如果能用一种 js 对象去模仿真实的 dom 对象,每一次有值更改的时候,我只要去查看,去比较,哪些 js 对象(也就是虚拟 dom )的值变化了,我们只需要去更新变化的 dom ,而未变更的部分则依然保留原来的 dom,这样一来,是不是就极大的优化了性能?

虚拟 dom 的结构大致如下,其实就是一个模仿真实 dom 的 js 对象,拥有 tag( 元素标签 ) ,children( 子节点 ) , data( 元素属性 ) 等属性,当 tag 为 undefined 时,代表是文本节点,其不存在 children 属性。

虚拟 dom 的大致结构

因此,diff 算法的核心就是引入了虚拟 dom 的概念,它的作用是减少性能的损耗。

diff 算法比对元素

在 vue 框架中,vm._render 的作用是根据模板生成虚拟 dom,而 vm._update(vm._render),其作用就是根据生成的虚拟 dom,生成真实 dom 并替换原有的 dom,因此我们可以用一个 patch(oldVnode,newVndoe) 函数对新老节点进行比对,newVnode 就是我新变化的虚拟 dom,oldVnode可以传入的是虚拟 dom,也可以是一个真实 dom;

  1. 倘若oldVnode 是真实 dom,说明进行的是首次渲染,直接用生存的虚拟 dom 生成真实 dom 并进行替换
  2. 倘若 oldVnode不是真实 dom,说明不是初次渲染,就可以进行 diff 比对。接下来是 diff 算法比对的步骤:

1. 倘若传入的 oldVnode 与 newVnode 标签不一样的话

那么以 newVnode 为主,根据 newVnode 生成真实 dom 并替换 oldVnode 对应的真实 dom

if (oldVnode.tag !== vnode.tag) {
      // 老的 dom 元素
      // replaceChild 会返回被替换的 dom 元素,后面是被替换的元素
      // el属性 代表这个 vdom 对应的真实dom
      return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
}

2. oldVnode 与 newVnode 标签一致,都为 undefined

说明新老虚拟 dom 皆为文本节点,这时候复用之前的 dom,改变其文本内容即可,不需要损耗性能生成真实 dom 并替换

if (!oldVnode.tag) {
      // 说明新老节点都是文本节点
      if (oldVnode.text !== vnode.text) {
        return oldVnode.el.textContent = vnode.text
      }
}

3. oldVnode 与 newVnode 皆为元素节点且标签一致

此时两者对标的皆为元素节点,那么可以直接复用之前的 dom,避免创建真实节点并替换,只要更改其属性和其子节点,子节点再做一次diff比对,递归地去进行与父节点相同的操作。

3.1 属性的更新 updateProperties

其实就是根据新的虚拟dom的属性对象,与老dom的属性一一对比,之前没有现在有则在对应的元素添加,无则删除,新的覆盖旧的,大致的代码如下:

function updateProperties(vnode, oldProps = {}) {
  let newProps = vnode.data || {} // 新的属性
  let el = vnode.el
  // 老的有,新的没有,删除属性
  for (let key in oldProps) {
    if (!newProps[key]) {
      // 移除真实 dom 的属性
      el.removeAttribute(key)
    }
  }
  // 样式处理 
  // 老的样式 style = {color:red}  新的样式 style={background:black}
  let newStyle = newProps.style || {}
  let oldStyle = oldProps.style || {}
  // 老样式中有,新样式中没有,删除老样式
  for (let style in oldStyle) {
    if (!newStyle[style]) {
      el.style[style] = ''
    }
  }
  // 新的有,直接用新的去更新即可
  if (newProps) {
    for (let key in newProps) {
      if (key == 'style') {    // {...,style:{color:red,font-size:12px}} -> <div style="color:red;font-size:12px">
        let str = ''
        for (let styleName in newProps.style) {
          str += (styleName + ':' + newProps.style[styleName] + ';')
        }
        el.setAttribute('style', str)
      } else if (key == 'class') {
        el.className = newProps.class
      }
      else {
        el.setAttribute(key, newProps[key])
      }
    }
  }
}
3.2 子节点的更新 updateChildren

updateChildren 是 diff 算法的核心,在更新完属性后进行。

要进行子节点的比对,先拿到新旧节点的 children,如果没有 children ,那就将其 children 设置为一个空数组

  1. 如果新节点没有 children ,那么将当前 el ,也就是 oldVnode.el 清空孩子节点
  2. 如果老节点没有 children ,那么将新节点中所有的孩子生成真实节点挂载到 el
  3. 如果都没有 children ,啥也不用做
  4. 如果都有 children,进行新老儿子比对,也就是执行 updateChildren

updateChildren 本质上是做一个循环,同时循环新老节点,哪个先结束,循环即停止,但头插还是尾插存在许多情况,为了能最高效率的进行比对, updateChildren 循环的方法为:

假设 oldStartVnode 为老节点的起始节点,oldStartIndex 为起始指针,oldEndVnode 为老节点的末尾节点,oldEndIndex 为末尾指针,新节点同理,

  1. oldStartVnode 与 newStartVnode 比,如果相同,startIndex 指针一起后移
  2. 如果不一样,就让 oldEndVnode 与 newEndVnode 比,如果相同,endIndex 指针一起前移
  3. 如果不一样的话,让 oldStartVnode 与 newEndVnode 比,如果相同,将 oldStartVnode 对应的 el 插到 newEndVnode 后面元素的前面, oldStartIndex 往后,newEndIndex 往前
  4. 如果不一样的话,让 newStartVnode 与 oldEndVnode 比,如果相同,将 oldEndVnode 对应的 el 插到 oldStartVnode.el 之前, newStartIndex 往后,oldEndIndex 往前
  5. 走到这一步,说明上面的四种情况都不满足,这个时候就只能进行暴力比对了 : 将当前的 newStartVnode 与 oldStartVnode 到 oldEndVnode 之间的每一个节点进行比较,看看是不是同一个节点( 可以将 oldChildren 做成一个映射表,这样就不用去循环找到底在 oldnode 中有没有与 newnode 对应的节点 )
  • 5.1 如果能找到相同的,将当前对应的 el 放到 oldStartVnode 对应的元素之前,并将当前数组位置置空占位,但是要注意,如果置为null,下次比就要跳过改位置——因为 isSameVnode 的参数中一定要跟的是一个节点(不管是元素节点或文本节点,好歹得是一个节点,不然 oldVnode.tag 会报错)—— isSameVnode 用来判定这两个节点是否是本该对应的节点,主要通过判断新旧元素的标签名与 key 属性是否相等(这也是为什么列表渲染需要 key 属性的原因)
  • 5.2 如果找不到相同的,将当前的元素创建真实元素放到 oldStartVnode 对应的元素之前

非暴力比对暴力比对情况

最后将 oldStartVnode 与 oldEndVnode 之间的节点对应的 el 全部从父节点中移除。

以下是自己写的 diff 比对代码,可供参考:

// 更换新旧节点
export function patch(oldVnode, vnode) {
  // 默认初始化时,是直接用虚拟节点创建真实节点,替换掉老节点
  // 在更新时,拿老的节点与新的节点做对比,在不同的地方更新真实 dom

  if (oldVnode.nodeType === 1) {   // 说明是真实的 dom 节点,继续做以前的操作
    let el = createElm(vnode)  // 产生真实 dom
    let parentElm = oldVnode.parentNode  // 拿到老的 dom app 的父节点 body
    parentElm.insertBefore(el, oldVnode.nextSibling)  // 将新生成的 dom app 放到当前老 dom 的后面
    parentElm.removeChild(oldVnode)  // 删除老的 dom
    return el
  } else {
    // 在更新时,拿老的虚拟节点与新的虚拟节点做对比,将不同的地方更新为真实的 dom
    // 更新功能
    // 1. 比较两个元素的标签,标签不一样,直接替换掉即可
    if (oldVnode.tag !== vnode.tag) {
      // 老的 dom 元素
      // replaceChild 会返回被替换的 dom 元素,后面是被替换的元素
      // el属性 代表这个 vdom 对应的真实dom
      return oldVnode.el.parentNode.replaceChild(createElm(vnode), oldVnode.el)
    }
    // 能走到这一步,说明新老节点的 tag 是一样的
    // 2. 有一种可能,两个是文本节点,标签都是 undefined 
    if (!oldVnode.tag) {
      // 说明新老节点都是文本节点
      if (oldVnode.text !== vnode.text) {
        // 因为我最后 vnode 会变成老节点,因此这里需要将 vnode.el 继承之前的真实dom
        vnode.el = oldVnode.el
      }
    }

    // 3. 标签一样,并且需要比对标签的属性和孩子
    // 标签一样,直接复用即可
    let el = vnode.el = oldVnode.el
    // 更新属性,用新的虚拟节点的属性和老的比较,去更新节点
    // 新老属性做对比
    updateProperties(vnode, oldVnode.data)
    // 当前元素比较完毕了之后比较孩子
    let oldChildren = oldVnode.children || []
    let newChildren = vnode.children || []
    // 儿子比较分为以下几种情况
    if (oldChildren.length > 0 && newChildren.length > 0) {
      //  老的有儿子,新的也有儿子 diff 算法
      updateChildren(oldChildren, newChildren, el)
    } else if (oldChildren.length > 0) {
      // 老的有儿子,新的没儿子,清空真实dom中的儿子节点
      el.innerHTML = ''
    } else if (newChildren.length > 0) {
      // 老的没儿子,新的有儿子, 给真实dom添加儿子节点
      for (let i = 0; i < newChildren.length; i++) {
        let child = newChildren[i]
        el.appendChild(createElm(child))
      }
    }
  }
}
// 将虚拟节点转换为真实节点
export function createElm(vnode) {
  let { tag, data, children, key, text } = vnode
  if (typeof tag === 'string') {  // 创建真实 dom 放到 vnode.el 上 
    // 如果是元素节点
    vnode.el = document.createElement(tag)
    // 添加属性
    updateProperties(vnode)
    children.forEach(child => {        // 遍历儿子,将儿子变成真实 dom 后,放到父亲里面
      vnode.el.appendChild(createElm(child))
    })
  } else {
    // 如果是文本节点
    vnode.el = document.createTextNode(text)
  }
  return vnode.el
}

// 为创建的 dom 添加属性
function updateProperties(vnode, oldProps = {}) {
  let newProps = vnode.data || {} // 新的属性
  let el = vnode.el

  // 老的有,新的没有,删除属性
  for (let key in oldProps) {
    if (!newProps[key]) {
      // 移除真实 dom 的属性
      el.removeAttribute(key)
    }
  }
  // 样式处理 
  // 老的样式 style = {color:red}  新的样式 style={background:black}
  let newStyle = newProps.style || {}
  let oldStyle = oldProps.style || {}
  // 老样式中有,新样式中没有,删除老样式
  for (let style in oldStyle) {
    if (!newStyle[style]) {
      el.style[style] = ''
    }
  }

  // 新的有,直接用新的去更新即可

  if (newProps) {
    for (let key in newProps) {
      if (key == 'style') {    
        let str = ''
        for (let styleName in newProps.style) {
          str += (styleName + ':' + newProps.style[styleName] + ';')
        }
        el.setAttribute('style', str)
      } else if (key == 'class') {
        el.className = newProps.class
      }
      else {
        el.setAttribute(key, newProps[key])
      }

    }
  }

}
// 判断两个节点是不是一样
function isSameVnode(oldVnode, newVnode) {
  return (oldVnode.tag === newVnode.tag) && (oldVnode.key === newVnode.key)
}

// 儿子间的比较 ——— diff核心算法
function updateChildren(oldChildren, newChildren, parentNode) {
  // 老节点指针
  let oldStartIndex = 0  // 老的索引
  let oldStartVnode = oldChildren[0]
  let oldEndIndex = oldChildren.length - 1
  let oldEndVnode = oldChildren[oldEndIndex]
  // 新节点指针
  let newStartIndex = 0
  let newStartVnode = newChildren[0]
  let newEndIndex = newChildren.length - 1
  let newEndVnode = newChildren[newEndIndex]
  // vue 中的 diff 算法做了很多优化
  // DOM 中操作有很多常见的逻辑 把节点插入到当前儿子的头部、尾部、儿子倒叙正序
  // vue2 采用双指针
  let map = makeIndexByKey(oldChildren)
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    if (!oldStartVnode) {
      // 如果当前的 oldVnode 是空,跳过这次处理
      oldStartVnode = oldChildren[++oldStartIndex]
    } else if (!oldEndVnode) {
      oldEndVnode = oldChildren[--oldEndIndex]
    } else if (isSameVnode(oldStartVnode, newStartVnode)) {
      // console.log('xxx');
      // 如果 tag 与 key 一样,更新样式并递归更新子节点
      patch(oldStartVnode, newStartVnode)
      oldStartVnode = oldChildren[++oldStartIndex]
      newStartVnode = newChildren[++newStartIndex]
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      // 从后往前,与前面那个相反
      patch(oldEndVnode, newEndVnode)
      oldEndVnode = oldChildren[--oldEndIndex]
      newEndVnode = newChildren[--newEndIndex]
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      patch(oldStartVnode, newEndVnode)
      // 将当前元素对应的el(这个el已经被更新过了)插到当前记录的end元素下一个元素的前面
      parentNode.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)
      oldStartVnode = oldChildren[++oldStartIndex]
      newEndVnode = newChildren[--newEndIndex]
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      patch(oldEndVnode, newStartVnode)
      parentNode.insertBefore(oldEndVnode.el, oldStartVnode.el)
      oldEndVnode = oldChildren[--oldEndIndex]
      newStartVnode = newChildren[++newStartIndex]
    } else {
      // 以上都不行,暴力比对
      let moveIndex = map[newStartVnode.key] // 拿到对应要移动的元素的索引
      if (moveIndex == undefined) {
        // 如果在老的孩子元素中没找到与之 key 相等的节点
        // 有可能 moveIndex 为 0 ,可不能与undefined、null混为一谈
        // 那么不用移动老节点,但是要创建对应的真实元素插到 oldStartVnode 之前
        parentNode.insertBefore(createElm(newStartVnode), oldStartVnode.el)
      } else {
        let moveNode = oldChildren[moveIndex]  // 这个老的虚拟节点需要移动
        oldChildren[moveIndex] = null
        // 比对更新
        patch(moveNode, newStartVnode)
        parentNode.insertBefore(moveNode.el, oldStartVnode.el)
      }
      newStartVnode = newChildren[++newStartIndex]  // 用新节点不同在老的节点里找

    }
  }
  // 遍历完成,看一看是不是新节点这里多了节点,如果多了,需要添加节点
  if (newStartIndex <= newEndIndex) {
    // 说明新节点后面还有,那么将后面的节点化为真实节点插进去,但是要判断是从哪里开始遍历的,
    // 如果是从前往后遍历,那么要尾插;如果是从后往前遍历,那么需要头插
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 将新的多余的节点插入进去, vnode.el 已经复用了 oldVnode.el,因此每个被 patch 过的虚拟节点,都会挂上 el 属性
      let ele = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].el
      parentNode.insertBefore(createElm(newChildren[i]), ele)
    }
  }
  // 除此之外,如果老节点多了节点,就需要删除节点
  if (oldStartIndex <= oldEndIndex) {
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      // 如果我前面不讲移动过的对应位置设为null
      // 那么我这里要做的操作就是 parentNode.removeChild(oldChildren[i].el)
      // 这样就会把移动到前方的元素删除,这不是我想要的,设置为 null 就是为了防止数组塌陷,省的删除元素了
      let child = oldChildren[i]
      if (child != null) {
        parentNode.removeChild(child.el)
      }
    }
  }
}

// 根据key创建一个映射表
function makeIndexByKey(children) {
  let map = {}
  children.forEach((item, index) => {
    // 有 key 才放进表里
    if (item.key) {
      map[item.key] = index
    }
  })
  return map
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值