虚拟DOM简析

420 阅读9分钟

1. 什么是vnode

模版转换为视图的过程

虚拟dom执行流程 Vuejs目前对状态的侦测策略采用了中等粒度。当状态发生变化时,只通知到组件级别,然后组件内使用虚拟 DOM 来渲染视图。 当某个状态发生改变时,只通知使用了这个状态的组件。也就是说,只要组件使用的众多状态中有一个发生了变化,那么整个组件就要重新渲染。

如果组件只有一个节点发生了变化,那么重新渲染整个组件的所有节点,很明显会造成很大的性能浪费。因此,对vnode进行缓存,并将上一次缓存的vnode和当前新创建的vnode进行对比,只更新发生变化的节点就变得尤为重要。这也是vnode最重要的一个作用。

1.1 vNode类型

1.1.1 注释节点

export const createEmptyVNode = text => {
    const node= new VNode()
    node.text= text 
    node.isComment= true 
    return node
}

可以看出,一个注释节点只有两个有效属性——text和iscomment,其余属性全是默认的undefined或者 false。 例如,一个真实的注释节点∶

<!-- 注释节点 -->

所对应的 vnode是下面的样子∶

{
    text∶"注释节点"isComment:true
}

1.1.2 文本节点

export function createTexthode(val){
   return new vNode(undefined, undefined, undefined, String(val))
}

文本类型的 vnode被创建时,只有一个text属性

text:"Hello Berwin"

1.1.3 克隆节点

克隆节点是将现有节点的属性复制到新节点中,让新创建的节点和被克隆节点的属性保持一致,从而实现克隆效果。它的作用是优化静态节点和插槽节点(slot node)。
以静态节点为例,当组件内的某个状态发生变化后,当前组件会通过虚拟DOM重新渲染视图,静态节点因为它的内容不会改变,所以除了首次渲染需要执行渲染函数获取vnode之外,后续更新不需要执行渲染函数重新生成vnode。因此,这时就会使用创建克隆节点的方法将vnode克隆一份,使用克隆节点进行渲染。这样就不需要重新执行渲染函数生成新的静态节点的vnode,从而提升一定程度的性能。

克隆节点和被克隆节点之间的唯一区别是isCloned属性,克隆节点的iscloned为true,被克隆的原始节点的isCloned为 false。

1.1.4 元素节点

元素节点通常会存在以下4种有效属性。
tag∶ 节点的名称,例如p、ul、1i和div等。
data∶ 节点上的数据,比如attrs、class和style等。
children∶当前节点的子节点列表。
context∶当前组件的vuejs实例。
例如,一个真实的元素节点∶

<p><span>Helo</span><span>Bervin</span></p>

所对应的vnode是下面的样子∶

{
    children:[VNode,VNode], 
    context:{..}, 
    data:{...},
    tag:"p",
    ...
}

1.1.5 组件节点

组件节点和元素节点类似,有以下两个独有的属性。 componentOptions∶组件节点的选项参数,其中包含propsData、tag 和 children等信息。 componentInstance∶组件的实例,也是Vuejs的实例。事实上,在Vucjs中,每个组件都是一个 Vuejs实例。 一个组件节点∶

<child></child>

所对应的 vnode是下面的样子∶

{
    componentInstance:{...},
    componentOptions:{...}, 
    context:{...}, 
    data:{..}
    tag:"vue-component-1-child",
    ...
}

1.1.6 函数式组件

函数式组件和组件节点类似,它有两个独有的属性 functionalContext 和 functional-Options。 通常,一个函数式组件的 vnode是下面的样子

{
    functionalContext:{...},
    functionaloptions:{...},
    context:{...},
    data:{...},
    tag:"div"
}

2. patch

对比两个vnode之间的差异只是patch的一部分,这是手段,而不是目的。patch的目的其实是修改DOM节点,也可以理解为渲染视图。patch 不是暴力替换节点,而是在现有DOM上进行修改来达到渲染视图的目的。对现有DOM进行修改需要做三件事∶

创建新增的节点;
删除已经废弃的节点;
修改需要更新的节点。

2.1 新增节点

首次渲染时,oldVnode并不存在,只需要使用vnode即可。

当vnode和 oldVnode完全不是同一个节点时,可以得知vnode就是一个全新的节点,而oldVnode 就是一个被废弃的节点。需要使用vnode生成真实的DOM元素并将其插入到视图当中。

2.2 删除节点

当一个节点只在 oldVnode 中存在时,我们需要把它从DOM中删除,所以vnode中不存在的节点都属于被废弃的节点,需要从dom中删除。

function removeVnodes(vnodes, startIdx,endIdx){
    for(;startIdx<= endIdx;+startIdx){
        const ch= vnodes[startIdx]
        if(isDef(ch)){ // 判断是否为空 null/undefined
            removeNode(ch.elm)
        }
    }
}

删除vnodes数组中从startIdx指定的位置到endIdx指定位置的内容

const nodeOps = {
    removechild(node,child){
        node.removeChild(child)
    }
}
function removeNode(el) {
    const parent = nodeOps.parentNode(el)
    if(isDef(parent)){
        nodeOps.removeChild(parent, el)
    }
}

removeNode用于删除视图中的单个节点,而removeVnodes用于删除一组指定的节点。

2.3 更新节点

patch过程: 当oldVnode不存在时,直接使用vnode渲染视图;当oldVnode和vnode都存在但并不是同一个节点时,使用vnode创建的 DOM 元素替换旧的DOM元素;当oldVnode和vnode是同一个节点时,使用更详细的对比操作对真实的DOM节点进更新。

patch运行流程

只有三种类型的节点会被创建并插人到DOM中∶元素节点、注释节点和文本节点。
要判断 vnode是否是元素节点,只需要判断它是否具有tag属性即可。接着,我们就可以调用当前环境下的createElement方法(在浏览器环境下就是document.createElement)来创建真实的元素节点。当一个元素节点被创建后,接下来要做的事情就是将它插入到指定的父节点中。

将元素渲染到视图的过程非常简单。只需要调用当前环境下的appendchld方法(在浏览器环境下就是调用parentNode.appendchild),就可以将一个元素插入到指定的父节点中。如果这个指定的父节点已经被渲染到视图,那么把元素插人到它的下面将会自动将元素渲染到视图。

另外。元素节点通常都会有子节点(children),所以当一个元素节点被创建后,我们需要将它的子节点也创建出来并插入到这个刚创建出的节点下面。

创建子节点的过程是一个递归过程。vnode中的children属性保存了当前节点的所有子虚拟节点(childvirtualnode),所以只需要将vnode中的children属性循环一遍,并且将每个子虚拟节点都执行一遍创建元素的逻辑

创建子节点时,子节点的父节点就是当前刚创建出来的这个节点,所以子节点被创建后,会被插入到当前节点的下面。当所有子节点都创建完并插人到当前节点中之后,我们把当前节点插入到指定父节点的下面。如果这个指定的父节点已经被渲染到视图中,那么将当前这个节点插人进去之后,会将当前节点(包括其子节点)渲染到视图中。

创建元素节点并将其渲染到视图的过程

除了元素节点外,还要创建注释节点和文本节点。在创建节点时,如果vnode中不存在tag属性,那么它可能会是另外两种节点∶注释节点和文本节点。

注释节点有一个唯一的标识属性isComment。在所有类型的vnode中,只有注释节点的isComment属性是true,所以通过isComment 属性就可以判断一个 vnode是否是注释节点。

当发现一个 vnode的tag属性不存在时,我们可以用isComment属性来判断它是注释节点还是文本节点。如果是文本节点,则调用当前环境下的createTextNode方法(浏览器环境下调用document.createTextNode)来创建真实的文本节点并将其插入到指定的父节点中;如果是注释节点,则调用当前环境下的createcomment 方法(浏览器环境下调用document. createComment方法)来创建真实的注释节点并将其插入到指定的父节点中。

创建节点并渲染到视图的过程

2.4 更新节点

  1. 静态节点 静态节点一旦渲染到界面上以后,无论日后状态如何变化,都不会发生任何变化的节点。例如:
<p1>我是静态节点,我不会发生变化</p1>
  1. 新虚拟节点有文本属性 当新旧两个虚拟节点(vnode和oldVnode)不是静态节点,并且有不同的属性时,要以新虚拟节点(vnode)为准来更新视图。根据新节点(vnode)是否有text 属性,更新节点可以分为两种不同的情况。

如果新生成的虚拟节点(vnode)有text属性,那么不论之前旧节点的子节点是什么,直接调用setTextContent方法(在浏览器环境下是node.textContent方法)来将视图中DOM节点的内容改为虚拟节点(vnode)的 text 属性所保存的文字。

如果之前的旧节点也是文本,并且和新节点的文本相同,那么就不需要执行setTextContent方法来重复设置相同的文本。

当新虚拟节点有文本属性,并且和旧虚拟节点的文本属性不一样时,我们可以直接把视图中的真实DOM节点的内容改成新虚拟节点的文本。

  1. 新虚拟节点无文本属性 如果新创建的虚拟节点没有text属性,那么它就是一个元素节点。元素节点通常会有子节点,也就是children 属性,但也有可能没有子节点,所以存在两种不同的情况。

有 children的情况

当新创建的虚拟节点有ch1ldren 属性时,其实还会有两种情况,那就是要看旧虚拟节点(oldVnode)是否有children 属性。 如果旧虚拟节点也有children 属性,那么我们要对新旧两个虚拟节点的children进行一个更详细的对比并更新。更新children可能会移动某个子节点的位置,也有可能会删除或新增某个子节点

如果旧虚拟节点没有children 属性,那么说明旧虚拟节点要么是一个空标签,要么是有文本的文本节点。如果是文本节点,那么先把文本清空让它变成空标签,然后将新虚拟节点(vnode)中的children挨个创建成真实的DOM元素节点并将其插人到视图中的DOM节点下面。

无 children的情况

当新创建的虚拟节点既没有 text 属性也没有 children属性时,这说明这个新创建的节点是一个空节点,它下面既没有文本也没有子节点,这时如果旧虚拟节点(oldVnode)中有子节点就删除子节点,有文本就删除文本。有什么删什么,最后达到视图中是空标签的目的。

更新节点的逻辑

更新节点的具体实现过程

2.5 更新子节点

更新子节点大概分为4种操作:更新节点、新增节点、删除节点、移动节点位置。

更新策略: 如果在 oldCh1ldren中没有找到与本次循环所指向的新子节点相同的节点,那么说明本次循环所指向的新子节点是一个新增节点。对于新增节点,我们需要执行创建节点的操作,并将新创建的节点插入到oldch1ldren中所有未处理节点(未处理就是没有进行任何更新操作的节点)的前面。

插入到未处理节点的前面

注意是插入到未处理节点的前面,而不是已处理节点的后面,否则会出现下图问题,节点插入的位置并不是我们希望插入的位置:

插入到已处理节点的后面

移动子节点
通过Node.insertBefore()方法,我们可以成功地将一个已有节点移动到一个指定的位置。
如何知道新虚拟节点位置: 对比两个子节点列表是通过从左到右循环 newchldren这个列表,然后每循环一个节点,就去oldCh1ldren中寻找与这个节点相同的节点进行处理。也就是说,newCh1ldren中当前被循环到的这个节点的左边都是被处理过的。那就不难发现,这个节点的位置是所有未处理节点的第一个节点。

优化策略:

● 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引
● 在对开始和结束节点比较的时候,总共有四种情况
● oldStartVnode vs newStartVnode (旧开始节点 vs 新开始节点)
● oldEndVnode vs newEndVnode (旧结束节点 vs 新结束节点)
● oldStartVnode vs oldEndVnode (旧开始节点 vs 新结束节点)
● oldEndVnode vs newStartVnode (旧结束节点 vs 新开始节点)

五条比对规则:

  1. 开始节点和结束节点比较,这两种情况类似

    ● oldStartVnode vs newStartVnode (旧开始节点 vs 新开始节点)
    ● oldEndVnode vs newEndVnode (旧结束节点 vs 新结束节点)
    ● 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)
    ● 调用 patchVnode()对比和更新节点
    ● 把旧开始和新开始索引往后/往前移动

  2. 旧开始节点对比新结束节点

    ● oldStartVnode vs newEndVnode (旧开始节点 vs 新结束节点)相同
    ● 调用 patchVnode()对比和更新节点
    ● 把 oldStartVnode 对应的 DOM 元素,移动到所有未处理节点的最右边
    ● 更新索引,旧首索引右移、新尾索引左移

  3. 旧结束节点 vs 新开始节点

    ● oldEndVnode vs newStartVnode (旧结束节点 vs 新开始节点)相同
    ● 调用 patchVnode()对比和更新节点
    ● 把 oldEndVnode 对应的 DOM 元素,移动到所有未处理节点的最左边
    ● 更新索引,旧尾索引左移、新首索引右移

  4. 旧新通过 key 找寻在旧节点组相同的新首节点

    ● 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
    ● 如果没有找到,说明 newStartNode 是新节点
    ----创建新节点对应的 DOM 元素,插入到 DOM 树中
    ● 如果找到了
    ----判断新节点和找到的老节点的 sel 选择器是否相同
    ----如果不相同,说明节点被修改了
    ----重新创建对应的 DOM 元素,插入到 DOM 树中
    ----如果相同,把 elmToMove 对应的 DOM 元素,移动到所有未处理节点的最左边

收尾工作:
循环结束

● 当老节点的所有子节点先遍历完(oldStartldx > oldEndldx),循环结束
● 新节点的所有子节点先遍历完(newStartldx > newEndldx),循环结束
● 如果老节点的数组先遍历完(oldStartldx >oldEndldx), 说明新节点有剩余,把剩余节点批量插入到右边
image.png

● 如果新节点的数组先遍历完(newStartldx > newEndldx),说明老节点有剩余,把剩余节点批量删除 image.png

github snabbdom 注释版:github.com/nymlc/snabb…

流程图.png