虚拟DOM:虚拟DOM和实际的DOM有何不同?

DOM的缺陷

对于js对DOM的操作,例如document.body.appendChild(node)往body节点上添加⼀个元素后会引发⼀系列的连锁反应,

⾸先渲染引擎会将node节点添加到body节点之上,然后触发样式计算、布局、绘制、栅格化、合成等任务,我们把这⼀过程称为重排

除了重排之外,还有可能引起重绘或者合成操作,形象地理解就是**“牵⼀发⽽动全⾝”**。

对于DOM的不当操作还有可能引发强制同步布局和布局抖动的问题

这些操作都会⼤⼤降低渲染效率。因此,对于DOM的操作我们时刻都需要⾮常⼩⼼谨慎。


什么是虚拟DOM

虚拟DOM解决了哪些事情

  • 将⻚⾯改变的内容应⽤到虚拟DOM上,⽽不是直接应⽤到DOM上
  • 变化被应⽤到虚拟DOM上时,虚拟DOM并不急着去渲染⻚⾯,⽽仅仅是调整虚拟DOM的内部状态,这样 操作虚拟DOM的代价就变得⾮常轻了。
  • 在虚拟DOM收集到⾜够的改变时,再把这些变化⼀次性应⽤到真实的DOM上。

DOM结构

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

虚拟DOM

let oldVDOM = { // 旧虚拟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到底怎么运⾏的

  • 创建阶段。⾸先依据JSX和基础数据创建出来虚拟DOM,它反映了真实的DOM树的结构。然后由虚拟 DOM树创建出真实DOM树,真实的DOM树⽣成完后,再触发渲染流⽔线往屏幕输出⻚⾯。(这个阶段并没有体现虚拟DOM的优化)
  • 更新阶段。如果数据发⽣了改变,那么就需要根据新的数据创建⼀个新的虚拟DOM树;然后使用diff算法⽐较两 个树,找出变化的地⽅,并把变化的地⽅⼀次性更新到真实的DOM树上;最后渲染引擎更新渲染流⽔ 线,并⽣成新的⻚⾯。

什么是Diff算法

Diff算法是一种对比算法。对比两者是旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率


Diff同层对比

新旧虚拟DOM对比的时候,Diff算法比较只会在同层级进行, 不会跨层级比较。


Diff对比流程

当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher,订阅者们就会调用patch方法,给真实DOM打补丁,更新相应的视图。


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

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的子节点真实化之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要
function patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el // 获取真实DOM对象
  // 获取新旧虚拟节点的子节点数组
  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方法

用于新旧虚拟节点的子节点对比,用首尾指针法来实现

例如

<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

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

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

  • 1、oldS 和 newS 使用sameVnode方法进行比较,sameVnode(oldS, newS)
  • 2、oldS 和 newE 使用sameVnode方法进行比较,sameVnode(oldS, newE)
  • 3、oldE 和 newS 使用sameVnode方法进行比较,sameVnode(oldE, newS)
  • 4、oldE 和 newE 使用sameVnode方法进行比较,sameVnode(oldE, newE)
  • 5、如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnodekey 去找出在旧节点中可以复用的位置。
  • 第一步
oldS = a, oldE = c
newS = b, newE = a

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


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

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


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

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


  • 第四步

oldS > oldE,则oldCh先遍历完成了,而newCh还没遍历完,说明newCh比oldCh多,所以需要将多出来的节点,插入到真实DOM上对应的位置上


如果是newCh先遍历完,那么oldCh中多出的节点将会被删除


平常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>

这样用index做key会导致li标签全要更新一遍

在进行子节点的 diff算法 过程中,会进行 旧首节点和新首节点的sameNode对比,这一步命中了逻辑,因为现在新旧两次首部节点key 都是 0了,同理,key为1和2的也是命中了逻辑,导致相同key的节点会去进行patchVnode更新文本,而原本就有的c节点,却因为之前没有key为4的节点,而被当做了新节点

所以就建议用独一无二的值来作为key