Vue2.x | Patch机制

1,178 阅读14分钟

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

patch也可以叫做patching算法,通过它渲染真实DOM时,并不是暴力覆盖原有的DOM,而是比对新旧两个vnode之间有哪些不同,然后根据对比结果找出现有DOM中需要更新的节点进行修改,来实现更新视图的目的。

之所以要这么做,是因为DOM操作的执行速度远不如JavaScript运行速度快,因此把大量的DOM操作搬运到JavaScript中。使用patching算法计算出真正需要更新的节点,最大限度的减少DOM操作,从而显著提升性能。

1.patch

patch的过程就是对比两个vnode之间的差异,然后修改现有DOM节点,也可以理解为渲染视图。既然是修改DOM那应该会有三种操作:

  • 创建新增的节点

  • 删除已废弃的节点

  • 修改需要更新的节点

patch的过程其实就是创建节点,删除节点,修改节点。如果oldVnodevnode不同时,以vnode为标准来渲染视图。

1.1 新增节点

有两种情况需要新增节点:

  • oldVnode不存在,而vnode中存在的节点,通常发生在首次渲染
  • oldVnodevnode完全不是同一个节点时,以vnode为标准来创建元素。
// 第一种情况
if (isUndef(oldVnode)) {
  isInitialPatch = true
  createElm(vnode, insertedVnodeQueue)
}
// 第二种情况
if (!sameVnode(oldVnode, vnode)) {
  createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))
}

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      )
    )
  )
}

第二种情况下要做的事情就是使用vnode创建一个新的DOM节点,去替换oldVnode对应的真实DOM

1.2 删除节点

也存在两种情况需要删除节点:

  • 节点在vnode中不存在,而在oldVnode中存在的节点,说明这是一个被废弃的节点,所以需要删除。
  • oldVnodevnode完全不是同一个节点时,将新创建的DOM节点插入旧DOM的旁边,然后再将旧DOM删除。
// 第一种情况
if (isUndef(vnode)) {
  if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
  return
}
// 第二种情况
if (!sameVnode(oldVnode, vnode)) {
  createElm(vnode, insertedVnodeQueue, parentElm, nodeOps.nextSibling(oldElm))
}

1.3 更新节点

当新旧两个vnode是相同节点时,需要对这两个节点进行更细致的对比,然后对oldVnode所在的视图中对应的真实节点进行更新。

if (sameVnode(oldVnode, vnode)) {
  patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}

2.创建节点

创建节点的过程就是根据vnode的类型来创建出相同类型的DOM元素,然后将DOM元素插入到视图中。

事实上,只有三种类型的节点会被插入到DOM中:元素节点、注释节点和文本节点

2.1 元素节点

要判断vnode是否是元素节点,只需要判断它是否具有tag属性即可。如果一个vnode具有tag属性,就认为它是元素节点。接着调用当前环境下的createElment方法(在浏览器中是document.createElement)来创建真实的元素节点

// function createElm
if (isDef(tag)) {
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)
  setScope(vnode) // 设置css scoped
}

// node-ops.js
export function createElement (tagName: string, vnode: VNode): Element {
  const elm = document.createElement(tagName)
}

当一个元素被创建后,接下来要做的就是将它插入到指定的父节点中。

将元素渲染到视图的过程非常简单。只需要调用当前环境下的appendChild方法(在浏览器环境下是调用parentNode.appendChild),将一个元素插入到指定的父节点中,如果父节点已经渲染到视图,那么插入的子节点也会立即渲染。

// function createElm
if (isDef(tag)) {
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)
  setScope(vnode) // 设置css scoped
  
  insert(parentElm, vnode.elm, refElm)
}

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

// node-ops.js
export function appendChild (node: Node, child: Node) {
  node.appendChild(child)
}

export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
  parentNode.insertBefore(newNode, referenceNode)
}

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

// function createElm
if (isDef(tag)) {
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)
  setScope(vnode) // 设置css scoped
  
  // 新增
  createChildren(vnode, children, insertedVnodeQueue)

  insert(parentElm, vnode.elm, refElm)
}

function createChildren (vnode, children, insertedVnodeQueue) {
  if (Array.isArray(children)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(children) // 检查key是否重复
    }
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  } else if (isPrimitive(vnode.text)) {
    nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
  }
}

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

创建子节点时,子节点的父节点就是当前刚创建出来的这个节点,所以子节点刚创建出来会被插入到当前节点下面。

当所有子节点都被创建并插入到它的父节点后,如果当前节点(就是最上面的那个节点)的父节点已经被渲染到视图,那么把当前节点会立即被渲染到视图。

2.2 注释节点

如果vnode不存在tag属性,那么它可能会是注释节点文本节点

如果是注释节点那么vnode一定会有一个isComment属性且为true。调用当前环境下的createComment方法(浏览器环境下是document.createComment)来创建真实的注释节点并将其插入到指定的父节点中。

// function createElm
if (isTrue(vnode.isComment)) {
  vnode.elm = nodeOps.createComment(vnode.text)
  insert(parentElm, vnode.elm, refElm)
}

// node-ops.js
export function createComment (text: string): Comment {
  return document.createComment(text)
}

2.3 文本节点

如果vnode没有tag属性和isComment属性为true,那么就是文本节点。

创建文本节点,只要调用当前环境下的createTextNode方法(浏览器中为document.createTextNode)创建真实的文本节点并将其插入到指定的父节点中。

// function createElm
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)
}

// node-ops.js
export function createTextNode (text: string): Text {
  return document.createTextNode(text)
}

3.删除节点

删除节点是将元素从视图中删除,Vue中的源码并不多。

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

上面代码的功能是删除vnodes数组从startIdxendIdx的内容。

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

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

// node-ops.js
function removeChild(parent, child) {
  parent.removeChild(child)
}

4.更新节点

两个节点是同一节点时会进行更细致的对比。找出两个节点不一样的地方,然后进行更新。

4.1 静态节点

首先判断两个节点是否是静态节点,如果是,就不需要进行更新操作,可以直接跳过更新节点的过程。

静态节点指的是那些一旦渲染到界面后,无论以后状态如何变化,都不会发生任何变化的节点。

// function patchVnode
if (isTrue(vnode.isStatic) &&
  isTrue(oldVnode.isStatic) &&
  vnode.key === oldVnode.key &&
  (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
  vnode.componentInstance = oldVnode.componentInstance
  return
}

4.2 虚拟节点有文本属性

根据vnode是否有text属性,更新节点可以分为两种情况。

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

如果oldVnode也是文本且和vnode的文本相同,那么就不需要执行setTextContent方法来重复设置相同的文本。

// function patchVnode
if (oldVnode.text !== vnode.text) {
  nodeOps.setTextContent(elm, vnode.text)
}

// node-ops.js
export function setTextContent (node: Node, text: string) {
  node.textContent = text
}

4.3 虚拟节点无文本属性

如果虚拟节点(vnode)没有text属性,那么它就是一个元素节点。元素节点通常会有一个子节点,那就是children属性。但是也可能没有子节点,所以存在两种情况。

// function patchVnode
if (isUndef(vnode.text)) {
  ...
}

1. vnode有children的情况

当创建的vnodechildren属性时,其实还会有两种情况,那就要看oldVnode是否有children属性。

oldVnode有children的情况

这种情况下需要对新旧两个虚拟节点的children进行一个更详细的对比并更新。更新children可能会移动某个子节点的位置,也有可能会删除或新增某个子节点

if (isUndef(vnode.text)) {
  if (isDef(oldCh) && isDef(ch)) {
    if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  }
}

oldVnode没有children的情况

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

if (isUndef(vnode.text)) {
  if (isDef(oldCh) && isDef(ch)) {
    if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  } else if (isDef(ch)) { // isUndef(oldCh) === true
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(ch)
    }
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  }
}

2. vnode没有children的情况

当创建的vnode既没有text属性也没有children属性时,说明这个新创建的节点是一个空节点。这时如果oldVnode有子节点就删除子节点,有文本就删除文本。

if (isUndef(vnode.text)) {
  if (isDef(oldCh) && isDef(ch)) {
    if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  } else if (isDef(ch)) {
    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(ch)
    }
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  } else if (isDef(oldCh)) {
    removeVnodes(oldCh, 0, oldCh.length - 1)
  } else if (isDef(oldVnode.text)) {
    nodeOps.setTextContent(elm, '')
  }
}

5.更新子节点

更新节点的时候,如果新节点的子节点和旧节点的子节点都存在且不相同时,会进行子节点的更新操作。

更新操作可以分为4种:更新节点、删除节点、新增节点、移动节点

首先循环newChildren(新子节点列表),每循环到一个子节点,就去oldChildren(旧子节点列表)中找到和当前节点相同的那个子节点。如果在oldChildren中找不到,则创建节点并插入视图;如果找到了,则做更新操作;如果找到了,但是位置不同,则需要移动节点。

5.1 更新策略

创建子节点

新旧两个子节点的列表是通过循环进行对比的,如果没有在oldChildren列表里找到本次循环所指向的新节点,那么说明这是一个新增的节点。创建这个节点,并将其插入到oldChildren中所有未处理节点的前面

更新子节点

如果newChildrenoldChildren中同时存在一个节点并且它们的位置都相同。那么就进行更新操作。

移动子节点

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

通过Node.insertBefore()方法,可以成功的将一个已有的节点移动到一个指定的位置。

这个位置就是所有未处理节点的最前面

删除子节点

本质上是删除那些,newChildren上不存在而oldChildren中存在的节点。

newChildren循环了一遍之后,如果oldChildren中还有未被处理的节点,那么这些节点将被删除。

5.2 优化策略

通常情况下,并不是所有的子节点的位置都会发生移动。针对这些位置不会变的或者说位置可以预测的节点,我们不需要循环来查找,因为有一个更快捷的查找方式。

假设我们只是修改了列表中某个数据的内容,而没有新增数据或者删除数据等,这种情况下newChildrenoldChildren中所有节点的位置都是相同的。只要尝试使用相同位置的两个节点来比对是否同一节点:如果恰巧是同一节点,直接就可以进入更新节点操作。如果尝试失败了,再用循环方式来查找节点。这样做可以很大程度的提升执行速度

快捷查找一共有4种方式:

  • 新前和旧前

  • 新后和旧后

  • 新前和旧后

  • 新后和旧前

新前:newChildren中所有未处理节点的第一个节点

新后:newChildren中所有未处理节点的最后一个节点

旧前:oldChildren中所有未处理节点的第一个节点

旧后:oldChildren中所有未处理节点的第一个节点

1. 新前和旧前

"新前"节点和"旧前"节点进行对比,如果是相同节点,则使用更新操作进行更新。

if (sameVnode(oldStartVnode, newStartVnode)) {
  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  oldStartVnode = oldCh[++oldStartIdx]
  newStartVnode = newCh[++newStartIdx]
}

2. 新后与旧后

"新后"节点和"旧后"节点进行对比,如果是相同节点,则使用更新操作进行更新。

if (sameVnode(oldEndVnode, newEndVnode)) {
  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
}

3. 新后与旧前

"新后"节点和"旧前"节点进行对比,如果是相同节点,由于位置不同,需要将oldChildren中对应的"新后"的那个节点,移动到所有未处理节点的后面

if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
  patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
  oldStartVnode = oldCh[++oldStartIdx]
  newEndVnode = newCh[--newEndIdx]
}

4. 新前与旧后

"新前"节点和"旧后"节点进行对比,如果是相同节点,由于位置不同,需要将oldChildren中对应的"信前"的那个节点,移动到所有未处理节点的前面

if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
  patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  oldEndVnode = oldCh[--oldEndIdx]
  newStartVnode = newCh[++newStartIdx]
}

当这4种方式都没有找到相同的节点的时候,会再通过循环的方式去oldChildren中再找一圈。

5.3 哪些节点是未处理的

由于优化策略,循环是两边向中间循环的。

需要4个变量: newStartIdx、newEndIdx、oldStartIdx、oldEndIdx。分别表示newChildren的开始和结束位置,oldChildren的开始和结束位置。

在循环体内,每处理一个节点,就将新旧两个节点的下标进行移动。开始位置被处理后,就向后移动一个位置。结束位置被处理后,就向前移动一个位置。

也就是说newStartIdxoldStartIdx只能向后移动,而newEndIdxoldEndIdx只能向前移动。

当开始位置大于结束位置时,就会退出循环。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  ...
}

newChildrenoldChildren中有一个循环结束,就会退出。那么,当新子节点和旧子节点数量不一致时,会导致循环结束后仍有未处理的节点。如果是oldChildren先循环完,那么newChildren有很多未处理的节点,那么这些节点都是要新增的节点。如果是newChildren先循环完,这时如果oldChildren中还有未处理的节点,那么这些就是需要删除的节点。

要找出newChildrenoldChildren未循环完的节点很简单,它们就是在startIdxendIdx之间的节点。

if (oldStartIdx > oldEndIdx) {
  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else if (newStartIdx > newEndIdx) {
  removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}

5.4 key值的作用

Vue.js中,渲染列表时可以为节点设置一个属性key,这个属性作为一个节点的唯一ID

if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

// 创建一个对象,保存了key值对应的下标
function createKeyToOldIdx(children, beginIdx, endIdx) {
  var i, key;
  var map = {};
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key;
    if (isDef(key)) {
      map[key] = i;
    }
  }
  return map;
}

以上代码表示,如果oldKeyToId不存在:也就是key对应下标的map不存在。那么就用createKeyToOldIdx创建了一个key对应下标的对象。

if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

// 新增
idxInOld = isDef(newStartVnode.key)
  ? oldKeyToIdx[newStartVnode.key]
  : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

function findIdxInOld(node, oldCh, start, end) {
  for (var i = start; i < end; i++) {
    var c = oldCh[i];
    if (isDef(c) && sameVnode(node, c)) {
      return i;
    }
  }
}

如果newStartVnode里的key值存在,那么就去map里去找对应的下标;如果不存在那么就去oldChildren里循环查找,是否有节点和这个newStartVnode相同,有就返回下标。最后把下标赋值给idxInOld

if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

idxInOld = isDef(newStartVnode.key)
  ? oldKeyToIdx[newStartVnode.key]
  : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

// 新增
if (isUndef(idxInOld)) { // New element
  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}

以上代码:如果idxInOld不存在,说明在map里没找到且在oldChildren里也没找到,那么这个newStartVnode就是一个新节点,将其创建。

再来看如果idxInOld存在,说明这是一个oldChildren已经存在的节点。

if (isUndef(oldKeyToIdx))
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

idxInOld = isDef(newStartVnode.key)
  ? oldKeyToIdx[newStartVnode.key]
  : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

if (isUndef(idxInOld)) { // New element
  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
  // 新增
  vnodeToMove = oldCh[idxInOld]
  if (sameVnode(vnodeToMove, newStartVnode)) {
      patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      oldCh[idxInOld] = undefined
      nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
    } else {
      // same key but different element. treat as new element
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    }
}

oldChildren中和newStartVnode对应key下标的节点赋值给vnodeToMove

如果它们拥有相同的key,但是不是相同节点,则创建新节点。

如果它们是相同节点,那么就进行更详细的节点对比。之后将oldChildren中对应下标的节点置为undefined,用来标记节点已被处理并且移动到其他位置,防止后续重复处理。