Vue.js 源码(7)—— patch

467 阅读5分钟

这是我参与更文挑战的第7天,活动详情查看: 更文挑战

前言

虚拟 DOM 最核心部分是 patch,它可以将 vnode 渲染成真实的 DOM。

patch 本身有补丁修补等意思,其实际作用是在现在 DOM 上进行修改来实现更新视图的目的。

介绍

对比两个 vnode 之间的差异只是 patch 的一部分,这是手段,而不是目的。 patch 的目的是修改 DOM 节点。要对现有的 DOM 进行修改需要做三件事:

  1. 创建新增的节点
  2. 删除已经废弃的节点
  3. 修改需要更新的节点

patch 的过程其实就是创建节点、删除节点和修改节点对的过程。

新增节点

如果一个节点已经存在于 DOM 中,那就不需要重新创建一个相同的节点去替换已经存在的节点。只有那些因为状态的改变而新增的节点在 DOM 中并不存在时,我们才需要创建一个节点插入到 DOM 中。

graph LR
    v[vnode] --> e
    o[oldVnode] -.-e
    
    e[元素] --> w[视图]
    

首次渲染视图时,使用 vnode 直接渲染视图。

删除节点

vnode 中不存在的节点都属于被废弃的节点,被废弃的节点需要从 DOM 中删除。

更新节点

新增节点和删除节点,有一个共同点——两个虚拟节点是完全不相同的。

我们更常见的场景是新旧两个节点是相同的节点,我们需要对这两个节点进行比较细致的对比,然后才能更新视图。

小结

patch 的主体流程

graph 
    p([patch]) --> o{oldVnode <br />是否存在}
    o --不存在--> i([使用vnode创建<br />节点并插入视图])
    o --存在--> s{oldVnode <br />和 vnode <br /> 是否是同<br />一个节点}
    
    s --是--> u([使用 patchvnode <br /> 进行更详细的 <br /> 对比和更新操作])
    
    s --不是--> r[使用vnode创建真实节点 <br /> 并插入到视图中就节点的旁边] 
    r --> d([将视图中的旧节点删除])
    
    

创建节点

Vue.js 中只有三种类型的节点会被创建并插入到 DOM 中:

  • 元素节点
  • 注释节点
  • 文本节点

我们可以调用 document.createElement 来创建真实的元素节点,然后调用 parentNode.appendChild ,将元素节点插入到指定的父节点中。

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

graph 
    c([创建节点]) --> e[创建元素节点] --> cc[创建子节点] -->i([插入到parentNode中])

除了元素节点,我们还可以创建注释节点文本节点

如果一个 vnode 的 tag 属性不存在,我们可以用 isComment 属性来判断它是注释节点还是文本节点。如果是文本节点,调用 document.createTextNode 方法创建文本节点。如果是注释节点,调用 document.createComment 方法创建注释节点。

graph
    s([创建节点]) --> s1{vnode是元素节点?} --是--> c[创建元素节点] --> e([插入到指定父节点])
    
    s1 --否--> s2{vnode是注释节点?} --是--> cc[创建注释节点] --> e
    
    s2 --否--> ct[创建文本节点] --> e

图:创建一个节点到渲染视图的全流程

删除节点

Vue.js 中删除节点的代码并不多。

function removeVnodes(vnodes, startIdx, endIdx){
    for(; startIdx <= endIdx; ++startIdx){
        const ch = vnodes[startIdx];
        if (isDef(ch)){
            removeNode(ch.elm)
        }
    }
}

removeNode 实现逻辑:

const nodeOps = {
    removeChild(node, child){
        node.removeChild(child)
    }
}

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

考虑到跨平台,把节点操作放到 nodeOps 中。

更新节点

静态节点

什么是静态节点?

静态节点指的是那些一旦渲染到界面上,无论状态怎么改变,都不会发生变化的节点。

例如:

<p>这是一个静态节点</p>

所以,当新旧两个虚拟节点是静态节点时,可以直接跳过更新节点的过程。

新虚拟节点有文本属性

如果新生成的虚拟节点有 text 属性,直接调用 setTextContent 方法来将视图中 DOM 节点的内容改为 text 属性所保存的文字。

新虚拟节点无文本属性

有children

  1. 旧虚拟节点也有 children,调用 updateChildren 进行更详细的对比,具体的后面会分析。
  2. 旧虚拟节点没有 children,说明旧虚拟节点要么是个空标签,要么是个有文本的文本节点。所以如果是文本节点,要先清空文本,然后将新虚拟节点的 children 依次创建成真实 DOM 节点。

无children

当新虚拟节点既没有 text 属性,也灭有children属性时,表明这是一个空标签,那么只要把旧虚拟节点的 text 和 children 都清空就行。

小结

graph
    s([更新节点]) --> a{vnode 与 <br> oldVnode <br> 完全相等} --是--> b([退出程序])
    a --否--> c{vnode 与 <br> oldVnode <br> 是静态节点} --是--> b([退出程序])
    c --否--> e{vnode 有text 属性?} --有--> f{vnode 的文本<br>和oldVnode的<br>文本是否相同?}
    f --相同--> b
    f --不相同--> text[把真实DOM节点的内容<br>改成 vnode.text 所保存的文本] -->b
    
    e -- 没有--> g{vnode有<br>子节点?}
    g --没有--> h{oldVnode<br>有子节点?}
    h --有--> i([清空DOM中<br>的子节点])
    h --没有--> j{oldVnode<br>有文本?} --> k([清空DOM中的文本])
    
    g --有--> l{oldVnode <br>有子节点?}
    l --有--> m{oldVnode子节点<br>与vnode子节点<br>是否不相同} --是-->n([更新子节点])
    l --没有--> o{oldVnode <br>有文本?} --有--> p[清空DOM中文本] --> q
    o --没有--> q([把vnode的子节点添加到DOM])

更新子节点(updateChildren)

大多数情况下,并不是所有子节点的位置都会发生移动,一个列表中总有一些节点的位置是不变的。

为此,Vue.js 参考了 snabbdom 的双端对比diff 算法。为了避免循环 oldChildren 来查找节点,提供了4中快捷查找方式:

  • 新前与旧前
  • 新后与旧后
  • 新后与旧前
  • 新前与旧后 解释一下里面的4个名词:
  • 新前:newChildren 中所有未处理的第一个节点
  • 新后:newChildren 中所有未处理的最后一个节点
  • 旧前:oldChildren 中所有未处理的第一个节点
  • 旧后:oldChildren 中所有未处理的最后一个节点

新前与旧前

如果新前和旧前是相同节点,不需要执行移动节点的操作,直接更新节点即可。如果不是,使用新后与旧后比较。

新后与旧后

如果新后与旧后是相同,也是更新节点。如果不是,使用新后与旧前比较。

新后与旧前

如果新后与旧前是相同节点,由于它们的位置不同,所以除了更新节点外,还需要将节点移动到 oldChildren 中所有未处理节点的最后面

新前与旧后

和上面的类似,除了更新节点外,还需要将节点移动到 oldChildren 中所有未处理节点的最前面

小结

在上面的过程中,如果 oldChilren 先遍历完,newChildren 中未处理的节点就是需要新增的节点。反之,如果 newChildren 先遍历完,oldChildren 中未处理的节点就是需要删除的节点。

如果上面4个查找都做查完,发现oldChildren 和 newChildren 中仍然没有一个把所有子节点都遍历完,说明剩余节点里可能存在一些可以复用的节点不是连续的,此时我们需要循环 oldChildren 来建立节点 key 与 index 索引的对应关系。

重要考点来了!

为什么推荐使用 key?

除非你的子节点列表比较简单,或者你希望利用默认的就地复用的原则来提高效率的话,都推荐使用key。使用key比较明显的好处就是如果出现上面的情况,可复用的节点不是连续的,或者是被打乱了,此时通过 key 与 index 索引的对应关系,我们可以很快的从 oldChildren 中找到我们需要对比的节点。

总结

本文,我们一起学习了 Vue.js 的 patch。

  • 如何更新新旧节点;
  • 如何使用双端对比的diff算法来更新子节点。