JS基础系列之 —— 虚拟DOM

2,271 阅读10分钟

什么是虚拟DOM?

虚拟 DOM (Virtual DOM )这个概念相信大家都不陌生,简单地说,就是一个普通的 JavaScript 对象,包含了 tag、props、children 三个属性,以这三个属性来描述一个DOM节点,每组描述就是一个VNode,整个VNode的集合就是一个虚拟DOM树。

举个🌰

<div id="app">
  <p class="text">hello world!!!</p>
</div>

将上面的HTML模版抽象成虚拟DOM树:

{
  tag: 'div',
  props: {
    id: 'app'
  },
  chidren: [
    {
      tag: 'p',
      props: {
        className: 'text'
      },
      chidren: [
        'hello world!!!'
      ]
    }
  ]
}

为什么需要虚拟DOM?

  • 具备跨平台的优势

由于虚拟DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等,是实现ssr、小程序等的基础。

  • 提升渲染性能。

因为DOM是一个很大的对象,直接操作DOM,即便是一个空的 div 也要付出昂贵的代价,执行速度远不如我们抽象出来的 Javascript 对象的速度快,因此,把大量的DOM操作搬运到 Javascript 中,运用diff算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。虚拟DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

  • 虚拟DOM的应用

渲染函数:cn.vuejs.org/v2/guide/re…

如何利用虚拟DOM来更新真实DOM?—— diff

diff(different),顾名思义,在构建DOM的过程中,会由diff过程就是比对计算DOM变动的地方,核心是由patch算法将变动映射到真实DOM上,所以视图的创建更新流程就是下面这样👇

  1. 用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文 档当中

  2. 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较(diff过程),记录两棵树差异

  3. 把2所记录的差异应用到步骤1所构建的真正的DOM树上(patch),视图就更新了

Vue的diff实现

patch算法核心

function patch (oldVnode, vnode) {
// some code
if (sameVnode(oldVnode, vnode)) {
    patchVnode(oldVnode, vnode)
} else {
    const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
    let parentEle = api.parentNode(oEl)  // 父元素
    createEle(vnode)  // 根据Vnode生成新元素
    if (parentEle !== null) {
        api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
        api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
        oldVnode = null
    }
}
// some code
return vnode
}

// 用于比对是否是同一VNode
function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}

patch函数接收两个参数oldVnode和Vnode分别代表新的节点和之前的旧节点,判断两节点是否值得比较,值得比较则执行patchVnode,不值得比较则用Vnode替换oldVnode

patchVnode过程

patchVnode (oldVnode, vnode) {
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
            if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
            }else if (ch){
            createEle(vnode) //create el's children dom
            }else if (oldCh){
            api.removeChildren(el)
            }
    }
}
  • 找到对应的真实dom,称为el

  • 判断Vnode和oldVnode是否指向同一个对象,如果是,那么直接return

  • 如果他们都有文本节点并且不相等,那么将Vnode的文本节点设置为el的文本节点。

  • 如果oldVnode有子节点而Vnode没有,则删除el的子节点

  • 如果oldVnode没有子节点而Vnode有,则将Vnode的子节点真实化之后添加到el

  • 如果两者都有子节点,则执行updateChildren函数比较子节点

updateChildren

这一部分可以说是 diff 算法中,变动最多的部分,因为前面的部分,各个库对比的方向基本一致,而关于子节点的对比,各个仓库都在前者基础上不断得进行改进,此处以Vue源码的 updateChildren 为例。

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) {   // 对于vnode.key的比较,会把oldVnode = 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)
    }
}

图解updateChildren

如上图的例子,更新前是1到10排列的Node列表,更新后是乱序排列的Node列表。罗列一下图中有以下几种类型的节点变化情况:

  • 头部相同、尾部相同的节点:如1、10

  • 头尾相同的节点:如2、9(处理完头部相同、尾部相同节点之后)

  • 新增的节点:11

  • 删除的节点:8

  • 其他节点:3、4、5、6、7

上图例子中设置了oldStart+oldEnd,newStart+newEnd这样2对指针,分别对应oldVdom和newVdom的起点和终点。Vue不断对vnode进行处理同时移动指针直到其中任意一对起点和终点相遇。处理过的节点Vue会在oldVdom和newVdom中同时将它标记为已处理。Vue通过以下措施来提升diff的性能:

1. 优先处理特殊场景

  • 头部的同类型节点、尾部的同类型节点这类节点更新前后位置没有发生变化,所以不用移动它们对应的DOM

  • 头尾/尾头的同类型节点这类节点位置很明确,不需要再花心思查找,直接移动DOM就好

处理了这些场景之后,一方面一些不需要做移动的DOM得到快速处理,另一方面待处理节点变少,缩小了后续操作的处理范围,性能也得到提升

2. 复用

复用是指Vue会尽可能复用DOM,尽可能不发生DOM的移动。Vue在判断更新前后指针是否指向同一个节点,其实不要求它们真实引用同一个DOM节点,实际上它仅判断指向的是否是同类节点(比如2个不同的div,在DOM上它们是不一样的,但是它们属于同类节点),如果是同类节点,那么Vue会直接复用DOM,这样的好处是不需要移动DOM

复用应该就是设置key和不设置key的区别:

不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,根据sameVnode 方法的实现可以知道key也会成为“是否是同一个节点”的判断依据。

解析updateChildren过程(设置了key)

1、处理头部的同类型节点。即oldStart和newStart指向同类节点的情况,如下图中的节点1

这种情况下,将节点1的变更更新到DOM,然后对其进行标记,标记方法是oldStart和newStart后移1位即可,过程中不需要移动DOM(更新DOM或许是要的,比如属性变更了,文本内容变更了等等)

2、处理尾部的同类型节点。即oldEnd和newEnd指向同类节点的情况,如下图中的节点10

与情况(1)类似,这种情况下,将节点10的变更更新到DOM,然后oldEnd和newEnd前移1位进行标记,同样也不需要移动DOM

3、处理头尾/尾头的同类型节点。即oldStart和newEnd,以及oldEnd和newStart指向同类节点的情况,如下图中的节点2和节点9

先看节点2,其实是往后移了,移到哪里?移到oldEnd指向的节点(即节点9)后面,移动之后标记该节点,将oldStart后移1位,newEnd前移一位

同样地,节点9接下来也是类似的处理,处理完之后成了下面这样

4、处理新增的节点

newStart来到了节点11的位置,在oldVdom中找不到节点11,说明它是新增的

那么就创建一个新的节点,插入DOM树,插到什么位置?插到oldStart指向的节点(即节点3)前面,然后将newStart后移1位标记为已处理(注意oldVdom中没有节点11,所以标记过程中它的指针不需要移动),处理之后如下图

5、处理更新的节点

经过第(4)步之后,newStart来到了节点7的位置,在oldVdom中能找到它而且不在指针位置(查找oldVdom中oldStart到oldEnd区间内的节点),说明它的位置移动了

那么需要在DOM树中移动它,移到哪里?移到oldStart指向的节点(即节点3)前面,与此同时将节点标记为已处理,跟前面几种情况有点不同,newVdom中该节点在指针处,可以移动newStart进行标记,而在oldVdom中该节点不在指针处,所以采用设置为undefined的方式来标记

6、处理3、4、5、6节点

经过第(5)步处理之后,我们看到了令人欣慰的一幕,newStart和oldStart又指向了同一个节点(即都指向节点3),很简单,按照(1)中的做法只需移动指针即可,非常高效,3、4、5、6都如此处理,处理完之后如下图

7、处理需删除的节点

经过前6步处理之后(实际上前6步是循环进行的),朋友们看newStart跨过了newEnd,它们相遇啦!而这个时候,oldStart和oldEnd还没有相遇,说明这2个指针之间的节点(包括它们指向的节点,即上图中的节点7、节点8)是此次更新中被删掉的节点。

OK,那我们在DOM树中将它们删除,再回到前面我们对节点7做了标记,为什么标记是必需的?标记的目的是告诉Vue它已经处理过了,是需要出现在新DOM中的节点,不要删除它,所以在这里只需删除节点8。

在应用中也可能会遇到oldVdom的起止点相遇了,但是newVdom的起止点没有相遇的情况,这个时候需要对newVdom中的未处理节点进行处理,这类节点属于更新中被加入的节点,需要将他们插入到DOM树中。

至此,整个diff过程结束了。整个过程是逐步找到更新前后vdom的差异,然后将差异反应到DOM树上

其他

为什么不用index作为key?

假设有这样一个列表:

<body> 
<div id="app"> 
    <ul> 
        <li v-for="(value, index) in arr" :key="index"> 
            <test /> 
        </li>
    </ul> 
    <button @click="handleDelete">delete</button> 
</div> 
</body> 
<script> 
new Vue({ 
name: "App", 
el: '#app', 
data() { 
    return { 
        arr: [1, 2, 3] 
       }; 
}, 
methods: { 
  handleDelete() { 
    this.arr.splice(0, 1); 
  } 
}, 
components: { 
    test: { template: "<li>{{Math.random()}}</li>" } 
} 
}) 
</script>

那么初始Vnode列表是:

[ { 
    tag: "li", 
    key: 0, 
    // 这里其实子组件对应的是第一个 假设子组件的text1 
}, { 
    tag: "li", 
    key: 1, 
    // 这里其实子组件对应的是第二个 假设子组件的text2 
}, { 
    tag: "li", 
    key: 2, 
    // 这里其实子组件对应的是第三个 假设子组件的text3 
} ];

回顾一下判断 sameNode 的时候,只会判断key、 tag、是否有data的存在(不关心内部具体的值)、是否是注释节点、是否是相同的input type,来判断是否可以复用这个节点。

当我们执行了删除操作后:

[ { 
    tag: "li", 
    key: 0, 
    // 这里其实上一轮子组件对应的是第二个 假设子组件的text2
}, { 
    tag: "li", 
    key: 1, 
    // 这里其实子组件对应的是第三个 假设子组件的text3 
} ];

由于对应的 key使用了 index导致的错乱,它会把

  1. 原来的第一个节点text: 1直接复用。

  2. 原来的第二个节点text: 2直接复用。

  3. 然后发现新节点里少了一个,直接把多出来的第三个节点text: 3 丢掉。

至此为止,我们本应该把 text: 1节点删掉,然后text: 2、text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。