patch

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2021-05-23

patch 介绍

patch 也可以叫做 patching 算法,就是用来对比新旧两个 vnode 之间有哪些不同,然后根据对比结果找到需要更新的节点进行更新,之所以要这么做,本质就是使用 JavaScript 的运算成本来替换 DOM 操作的执行成本。

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

vue-patch

1、新增节点

  • 只有那些因为状态改变而新增的节点在DOM不存在时,才需要创建一个节点并插入到 DOM 中,即当 oldVnode 不存在而 vnode 存在时,首次渲染时所有的节点都需要新增,因为此时的 oldVnode 是不存在的。

  • 当 vnode 和 oldVnode 完全不是同一个节点时,需要使用 vnode 生成真实的 DOM 元素并将其插入到视图中。

2、删除节点

  • 以 vnode 为准,当一个节点只在 oldVnode 中存在,而在 vnode 中不存在,则需要把它从DOM中删除。
  • 当 vnode 和 oldVnode 完全不是同一个节点时,就需要将 oldVnode 删除。

3、更新节点

  • 当新旧两个节点是同一个节点,就需要进行比较,然后对 oldVnode 在视图中所对应的真实节点进行更新。

创建节点

创建 DOM根据 vnode 的类型来创建相同类型的DOM元素,然后将DOM元素插入到视图中。事实上,只有三种节点会被创建并插入到DOM中:元素节点、注释节点和文本节点。

vue-patch-createNode

function createElm (
  vnode,
  insertedVnodeQueue,
  parentElm,
  refElm,
  nested,
  ownerArray,
  index
) {
  if (isDef(vnode.elm) && isDef(ownerArray)) {
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

  const data = vnode.data
  const children = vnode.children
  const tag = vnode.tag
  if (isDef(tag)) {
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)

    setScope(vnode)

    createChildren(vnode, children, insertedVnodeQueue)

    if (isDef(data)) {
      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)
  }
}

删除节点

function removeVnodes (vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) { // 是否已定义,不为 undefined 和 null
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch)
        invokeDestroyHook(ch)
      } else { // Text node
        removeNode(ch.elm)
      }
    }
  }
}

// 对节点操作的封装,针对跨平台 在 Vue2 源码中就包含对weex的支持
// https://cn.vuejs.org/v2/guide/comparison.html#%E5%8E%9F%E7%94%9F%E6%B8%B2%E6%9F%93
const nodeOps = { 
  removeChild(node, child) {
    node.removeChild(child);
  },
  parentNode(node) {
    return node.parentNode
  }
}

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

更新节点

只有两个节点是同一个节点时,才需要更新元素节点,而更新节点并不是很暴力的使用新节点覆盖旧节点,而是通过比对找出新旧两个节点不一样的地方,针对那些不一样的地方进行更新。

当更新节点时,首先需要判断新旧两个虚拟节点是否是静态节点,如果是静态节点就不需要更新,直接跳过,因为静态节点不会因为状态的变化而发生变化。

当新旧两个虚拟节点不是静态节点,并且有不同的属性时,要以新虚拟节点为准来更新视图。

  • 当新虚拟节点有 text 属性,则直接调用 setTextContent (对应浏览器环境下的 node.textContent)来将视图中DOM节点的内容修改为虚拟节点的 text 属性所保存的文字。不需要关心旧节点中包含的内容是什么,无论是文本还是元素节点都不重要,唯一需要关心的就是,旧节点也是文本,并且与新节点的文本内容相同,就不需要执行 setTextContent

  • 当新虚拟节点没有 text 属性,那么它就是一个元素节点,那么旧节点是否有子节点也会存在不同的情况

    • 旧虚拟节点有 children 时,如果 oldVnode 有 children, 那么需要对两个虚拟节点的 children 进行一个更详细的对比并更新 更新子节点
    • 旧虚拟节点没有 children 时,这说明旧虚拟节点就是一个空节点(没有text也没有children),直接将新虚拟节点中的 children 挨个创建成真实的 DOM 节点并插入到视图。

vue-patch-updateNode

更新子节点

更新子节点大概可以分为4种,更新节点、新增节点、删除节点、移动节点位置。

需要理解的是,此时的真实DOM上的节点列表也就是指 oldChildren 在DOM树中的渲染结果。会以 newChildren 为准,比对 oldChildren 找出差异,通过最少的DOM操作使得 oldChildren 成为新的 newChildren,然后将这些操作(Patch)应用在真实DOM树上。

所以以下根据比对产生的操作,都会被当做一个个Patch,进行真实DOM操作更新patchVnode

创建子节点

新旧两个子节点列表是通过循环进行比对的,循环 newChildren(新子节点列表) 如果在 oldChildren (旧子节点列表) 中没有找到,就说明本次循环所指向的新节点是新增节点。执行创建节点的操作,将新创建的节点插入到 oldChildren 中所有未处理的前面。

或者 oldChildren 先循环完,但是 newChildren 中仍然存在未处理的节点即为需要新增的节点。执行创建节点的操作,将新创建的节点插入到 oldChildren 的最后面。

更新节点

两个节点是同一个节点并且位置相同,就只需要进行更新节点的操作即可。

移动子节点

在 newChildren 中的某个节点和 oldChildren 中的某个节点是同一个节点,但是位置不同,所以在真实的DOM中需要将这个节点的位置以新虚拟节点的位置为基准进行移动。

当前处理的这个节点,也就是所有未处理节点的第一个节点,如果此时循环比对发现它的位置是变化的,例如,当前在 newChildren 中的位置是 3,在 oldChildren 中位置是 8,那么就将 oldChildren 位置是 8 的这个元素,移动到所有未处理节点的最前面即可。

删除子节点

本质就是删除那些在 oldChildren 中存在但是 newChildren 中不存在的节点。当 newChildren 中的所有节点被循环一遍后,也就是循环结束后,如果 oldChildren 还存在未处理的节点,那这些节点就是被废弃的,需要删除的节点。

优化策略

为了避免使用循环来查找比对节点,提升执行速度,会尝试去使用相同位置的两个节点来比对是否是同一个节点,如果恰巧是就可以进入更新操作,如果尝试失败了,不是同一个节点再用循环的方式来查找节点,这种方式被称为快速查找。

在Vue diff算法中,有四种快速的查找节点的方式,分别是:

  • 新前与旧前
  • 新后与旧后
  • 新后与旧前
  • 新前与旧后

由于优化策略,在循环体中不再是只处理所有未处理过的节点的第一个,而是从两边向中间循环,并设置有4个变量,oldStartIdxoldEndIdxnewStartIdxnewEndIdx

在循环体内,每处理一个节点,就将下标向指定方向(start只能往后,end只能往前)移动一个位置,通常情况下是对新旧两个节点进行更新操作,就相当于一次性处理两个节点。所以startend之间的节点就是未处理的。

以新前与旧前为例,会首先尝试使用“新前”与“旧前”进行对比,如果是同一个节点,则进行对比更新(不需要执行移动节点/删除节点)。

如果不是,使用剩下3种方式进行查找,都不行再使用循环来查找与之对应的节点

1、新后与旧前是同一个节点

由于它们位置不同除了更新节点还要进行移动节点的操作。即将 oldVnode 旧前对应的节点移动到 oldChildren 中所有未处理节点的后面。

2、新前与旧后是同一个节点

而如果新前与旧后是同一个节点,则需要将节点移动 oldChildren 中所有未处理节点的最前面。

3、新增子节点

遇到 oldChildren中没有的节点,则需要新增节点,创建一个节点插入到 oldVnode 中。

当开始位置大于结束位置的时候,说明所有的节点都遍历过了。

while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 更新操作
}

// 循环结束 遍历完了

如果 oldChildren 先循环完,这个时候如果 newChildren 中还有剩余未处理的节点,说明都是需要新增的。

如果 newChildren 先循环完,这个时候如果 oldChildren 中还有未处理的节点,说明都是需要删除的节点。

在Vue中,渲染列表时可以为节点设置一个属性key,这个属性可以标示一个节点的唯一ID,为什么都会“要求”设置这个key属性呢?

在更新子节点时,需要在 oldChildren 中循环去找一个节点,但是如果我们设置了属性key,建立起keyindex索引的对应关系时,就会生成一个[{ key: 节点下标}]的这样一个对象,那么在 oldChildren 中找相同节点时,可以直接通过key拿到下标,从而获取节点,这样,就根本不需要经过循环来查找节点了。

例如,在一个列表中最前面插入一项,如果没有设置 key,就会将 oldVnode 的第一项渲染成新插入一项,第二项渲染成原来的第一项,依次类推,大大浪费了效率,如果有 key值,建立了映射关系,就可以知道原来的项根本就没有改变,只需要新增一项就可以了,不需要“原地复用”。