# patch(Diff算法)

介绍虚拟DOM的时候,我们就说过引入虚拟DOM的原因就是对比状态更新前后的虚拟DOM的差异,来更新真实DOM,达到减少操作真实DOM的目的。

这对比的过程,就是patch的一部分,我们称之为Diff。而完整的patch函数是 Vue2 内部的一个核心函数,用于将新的虚拟DOM转化为真实DOM并应用到浏览器中。它会根据Diff算法的计算结果进行更新,包括添加、移动、修改或删除真实DOM节点,以确保最终的DOM结构与最新的虚拟DOM一致。

patch对现有DOM的更改主要做三件事情:

  1. 创建新增的节点:新的VNode中有而旧的oldVNode中没有,就在旧的oldVNode中创建。
  2. 删除已经废弃的节点:新的VNode中没有而旧的oldVNode中有,就从旧的oldVNode中删除。
  3. 修改需要更新的节点:新的VNode和旧的oldVNode中都有,就以新的VNode为准,更新旧的oldVNode

# 创建节点

创建节点通常发生在两种情况下

  1. oldVNode不存在而vnode存在时,需要使用vnode生成真实的DOM元素插入到视图中。一般发生在首次渲染时
  2. oldVNodevnode完全不是同一个节点时,需要使用vnode生成真实的DOM元素插入到视图中。

我们之前分析过VNode可以生成六种不同的节点类型,但实际上只有3种类型的节点能够被创建并插入到DOM中,它们分别是:元素节点、文本节点、注释节点。在创建节点的过程中,会根据它们的特点来进行不同的创建方式。源码在/src/core/vdom/patch.js

function createElm (
    vnode, // 虚拟节点
    insertedVnodeQueue, // 插入虚拟节点队列
    parentElm, // 父元素
    refElm, // 参考元素
    nested, // 是否嵌套
    ownerArray, // 虚拟节点所属的数组
    index // 虚拟节点在数组中的索引
  ) {
    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      // 如果是元素节点
      vnode.elm = vnode.ns
        // 创建 SVG 元素和其他 XML 具有命名空间的元素
        ? nodeOps.createElementNS(vnode.ns, tag) 
        // 创建普通元素
        : nodeOps.createElement(tag, vnode) 

      // 设置作用域
      setScope(vnode)
      // 创建子节点
      createChildren(vnode, children, insertedVnodeQueue)

      if (isDef(data)) {
        // 如果存在节点数据,则调用 invokeCreateHooks 函数执行创建钩子函数
        invokeCreateHooks(vnode, insertedVnodeQueue)
      }
      insert(parentElm, vnode.elm, refElm)
    } else if (isTrue(vnode.isComment)) {
      // 如果是注释节点,创建注释节点并插入到父元素中
      vnode.elm = nodeOps.createComment(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    } else {
      // 否则,创建文本节点并插入到父元素中
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

nodeOps是Vue为了跨平台兼容性,对所有节点操作进行了封装,例如nodeOps.createTextNode()在浏览器端等同于document.createTextNode()

从上述代码中,我们可以发现,创建节点时,我们先对节点类型做了判断

  • 根据tag判断是否为元素节点,如果是元素节点,则调用createElementcreateElementNS方法创建元素节点,再递归子节点,插入到当前节点中,最后把当前节点插入DOM中
  • 不是元素节点则根据isComment判断是否为注释节点,如果是注释节点,则调用createComment生成注释节点,插入DOM中
  • 如果都不是则为文本节点,则调用createTextNode生成文本节点,插入DOM中

这样就完成了创建流程,流程图如下

创建节点流程图

# 删除节点

删除节点的场景很简单,就是当一个节点只存在于oldVNode中时,从DOM中删除

代码也很简单,获取父级节点,如果父级节点存在,则从父级中删除,如果父级不存在,说明整个节点都被删除了,无需操作

function removeNode (el) {
  const parent = nodeOps.parentNode(el)
  // element may have already been removed due to v-html / v-text
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}
成功
1
2
3
4
5
6
7

# 更新节点

新增节点和删除节点的场景,都是在新旧两个节点是完全不同的情况下。我们需要以新节点为标准渲染DOM,所以只能新增新节点和删除旧节点。

相比于这种场景,新旧两个节点是同一节点的场景更为常见。在这个场景中,我们需要对新旧两个节点做更详细的对比。

  1. 如果 vnodeoldVnode 是同一个对象(引用相同),则直接返回,无需更新。
  2. 如果 vnodeoldVnode 都是静态节点,并且它们的 key 相同,则更新 vnode 的一些属性到oldVnode 上,并返回。
  3. 如果 vnode 为文本节点,则判断oldVnode是否为文本节点
    1. oldVnode为文本节点,并且两者文本内容不同,则直接更新oldVnode的文本内容
    2. oldVnode不为文本节点,则删除oldVnode子节点,并将其改为文本节点,更新文本内容
  4. 如果 vnode 为元素节点,则判断vnode是否包含子节点
    1. 如果vnode有子节点,则判断oldVnode是否包含子节点
      1. 如果oldVnode有子节点,则需要对比子节点后进行更新
      2. 如果oldVnode没有子节点,那这个节点可能是空节点或者文本节点
        1. 如果oldVnode是空节点,则将vnode子节点挨个添加到oldVnode
        2. 如果oldVnode是文本节点,则将文本内容清空后,将vnode子节点挨个添加到oldVnode
    2. 如果vnode没有子节点,又不是文本节点,说明是个空节点,直接将oldVnode内容清空,置为空节点

带着这个逻辑,我们看看Vue源码中是如何处理更新节点的,源码在/src/core/vdom/patch.js

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // 节点相同不更新(引用相同)
    if (oldVnode === vnode) {
      return
    }

    const elm = vnode.elm = oldVnode.elm

    // 如果都是静态节点
    if (isTrue(vnode.isStatic) &&
      isTrue(oldVnode.isStatic) &&
      // 相同的key
      vnode.key === oldVnode.key &&
      // 新节点时克隆节点 || 只渲染一次的节点
      (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
    ) {
      // 直接更新组件实例
      vnode.componentInstance = oldVnode.componentInstance
      return
    }

    let i
    const data = vnode.data
    const oldCh = oldVnode.children
    const ch = vnode.children

    // 新节点没有文本内容text
    if (isUndef(vnode.text)) {
      // 新旧节点的子节点都存在
      if (isDef(oldCh) && isDef(ch)) {
        // 并且不相等,更新子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      // 只有新节点的有子节点
      } else if (isDef(ch)) {
        // 如果旧节点存在文本内容text,则清空DOM的文本内容
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        // 新的节点的子节点添加到旧的节点的 DOM 元素中。
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      // 新节点没有文本内容,而且只有旧节点的有子节点
      } else if (isDef(oldCh)) {
        // 删除DOM的子节点
        removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      // 新节点没有文本内容,旧节点有文本内容
      } else if (isDef(oldVnode.text)) {
        // 清空旧节点的内容
        nodeOps.setTextContent(elm, '')
      }
    // 新节点有文本内容并且跟旧节点文本不相等
    } else if (oldVnode.text !== vnode.text) {
      // 新节点是文本节点,直接把旧节点的文本内容替换
      nodeOps.setTextContent(elm, vnode.text)
    }
  }
成功
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

vnodeoldVnode都有子节点时,调用了 updateChildren方法去对比子节点,这个方法我们下节去分析

源码中的顺序跟我们分析的顺序不太一样,但整体的判断逻辑是一致的,可以跟着流程图对比源码中的注释一起梳理

更新节点

# 总结

这节主要分析了patch中的Diff部分,针对创建节点、删除节点、更新节点三部分分析了适用场景及源码逻辑,并辅助有流程图去分析。

对于vnodeoldVnode都有子节点时的逻辑,我们在下节进行详细的分析。