Vue 3.0组件的更新流程和diff算法详解

上篇文章我们介绍了组件的渲染流程,本篇文章我们来介绍响应式数据变化后组件的更新渲染流程。最后有不看文章的分析总结图。

案例

为了方便介绍流程,我们这里举一个例子:

  • App组件中有一个Hello组件,并且赋值msg这个prop值给Hello组件;
  • msgVue 3时,App组件中有li标签数组显示vue3.feature,即显示Vue 3的新特性,当msgVue 2时则不显示;
  • App组件中有一个按钮切换msg的值。
App.vue




Hello.vue



效果图如下

Vue 3.0组件的更新流程和diff算法详解_第1张图片

副作用渲染函数componentUpdateFn开启组件重新渲染

我们上篇文章提到过组件挂载的时候会创建一个副作用渲染函数componentUpdateFn,这个函数在响应式数据变化后则会被调用。

数据变化后为什么就会引发副作用渲染函数的调用?这是Vue 3.0响应式系统的相关内容,后续介绍。目前知道是这个逻辑就行。

const componentUpdateFn = () => {
  // 1. 
  if (!instance.isMounted) {
    
    instance.isMounted = true

  } else {
    let { next, bu, u, parent, vnode } = instance
    let originNext = next
    let vnodeHook: VNodeHook | null | undefined
    
    // 2. 
    if (next) {
      next.el = vnode.el
      updateComponentPreRender(instance, next, optimized)
    } else {
      next = vnode
    }
    
    // 3
    const nextTree = renderComponentRoot(instance)
    
    const prevTree = instance.subTree
    instance.subTree = nextTree
    
    // 4
    patch(
      prevTree,
      nextTree,
      // parent may have changed if it's in a teleport
      hostParentNode(prevTree.el!)!,
      // anchor may have changed if it's in a fragment
      getNextHostNode(prevTree),
      instance,
      parentSuspense,
      isSVG
    )
  }
}
  1. componentUpdateFn只有第一次执行的时候执行挂载逻辑,第一次执行后isMounted被置为true,后面都是执行更新的逻辑;
  2. 组件自己更新的场景下,next为空,将next指向组件对象自己的vnode;
  3. renderComponentRoot更新子树VNode,本例子中主要是将子树VNode的第一个和第三个子VNode的数据进行更新;
    Vue 3.0组件的更新流程和diff算法详解_第2张图片
  4. patch用来对比新旧子树VNode,找到合适的方式更新DOM
patch 更新组件的逻辑
const patch: PatchFn = (
  n1,
  n2,
  container,
  anchor = null,
  parentComponent = null,
  parentSuspense = null,
  isSVG = false,
  slotScopeIds = null,
  optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
  // 1. 
  if (n1 === n2) {
    return
  }

  // 2. 
  if (n1 && !isSameVNodeType(n1, n2)) {
    anchor = getNextHostNode(n1)
    unmount(n1, parentComponent, parentSuspense, true)
    n1 = null
  }

  // 3. 
  const { type, ref, shapeFlag } = n2
  switch (type) {
    // 省略 ...
    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        processElement(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        processComponent(
          n1,
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      }
    // 省略 ...
  }

}

  1. 如果新旧VNode节点是同一个,则直接返回不做处理;
  2. 如果新旧VNode节点的类型不同,那就将旧的VNode节点卸载,然后将旧的VNode节点置空,最后走挂载逻辑;
  3. 如果新旧VNode节点的类型相同,会根据不同的VNode类型走不同更新逻辑,譬如组件走processComponent流程, 普通DOM元素节点走processElement流程。
    Vue 3.0组件的更新流程和diff算法详解_第3张图片
    本例中第一个子节点是组件VNode节点走processComponent,其他几个VNode节点走processElement流程。
子组件更新流程updateComponent

App组件对象的子树VNode的第一个子节点VNodeHello组件对象的VNode,其prop值变化了,所以Hello组件对象需要更新渲染,接下来我们就来看看Hello子组件的更新逻辑processComponent

const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
  const instance = (n2.component = n1.component)!
  // 1.
  if (shouldUpdateComponent(n1, n2, optimized)) {
    // 2.   
    instance.next = n2
    // 3.
    invalidateJob(instance.update)
    // 4.
    instance.update()
  } else {
    // 2.
    n2.component = n1.component
    n2.el = n1.el
    instance.vnode = n2
  }
}

  1. 首先使用shouldUpdateComponent判断组件是否需要重新渲染,因为有些VNode值的变化并不需要立即显示更新。更新的条件包括propchildren的变化等;
  2. 给组件对象设置了next值,也就是说如果是组件自己更新是没有设置next,如果是父组件触发更新,则子组件对象有设置这个next值;
    Vue 3.0组件的更新流程和diff算法详解_第4张图片
  3. 更新队列中取消子组件对象的更新,避免重复更新;
  4. 子组件的副作用渲染函数componentUpdateFn被调用,进入了又一轮的递归调用;
  • 问题:为什么子组件对象重新渲染需要设置next值?
  • 答案:此时子组件对象不知道需要更新到的VNode, 所有需要赋值给子组件对象让其知道如何更新渲染。
父组件触发的子组件的副作用渲染函数componentUpdateFn的和组件自身触发的区别
let { next, bu, u, parent, vnode } = instance
let originNext = next
        
if (next) {
  next.el = vnode.el
  updateComponentPreRender(instance, next, optimized)
} else {
  next = vnode
}

区别就在于父组件对象触发的子组件的VNodenext值,此时需要执行updateComponentPreRender,从而在渲染前完成propsslot等属性的赋值;

  • 问题:组件对象自身触发的渲染为什么不需要执行updateComponentPreRender方法?
  • 答案:组件对象在挂载的时候已经执行过了updateComponentPreRender方法,所以自身触发的情景下只需要更新一些属性值就行,要么通过updateComponentPreRender,要么直接给设置vnode属性值。
普通元素节点更新入口patchElement
const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  // 1.
  patchProps(el, n2, oldProps, newProps, parentComponent, parentSuspense, isSVG)
  // 2. 
  patchChildren(
    n1,
    n2,
    el,
    null,
    parentComponent,
    parentSuspense,
    areChildrenSVG,
    slotScopeIds,
    false
  )
}

这个方法特别的长,功能是通过patchProps更新propsstyleclassevent等;通过patchChildren更新子节点。

接下来我们就来重点介绍下子节点的更新逻辑。

普通元素节点的子节点更新patchChildren
const patchChildren: PatchChildrenFn = (
  n1,
  n2,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  slotScopeIds,
  optimized = false
) => {
    
  // 1.  
  const c1 = n1 && n1.children
  const prevShapeFlag = n1 ? n1.shapeFlag : 0
  const c2 = n2.children

  const { patchFlag, shapeFlag } = n2
  if (patchFlag > 0) {
    // 2.
    if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
      patchKeyedChildren(
        c1 as VNode[],
        c2 as VNodeArrayChildren,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      return
    } else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
      // unkeyed
      patchUnkeyedChildren(
        c1 as VNode[],
        c2 as VNodeArrayChildren,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      return
    }
  }

  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 3. 
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
    }
    if (c2 !== c1) {
      hostSetElementText(container, c2 as string)
    }
  } else {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 4. 
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        patchKeyedChildren(
          c1 as VNode[],
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
      }
    } else {
      // 5.
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        hostSetElementText(container, '')
      }
      if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        mountChildren(
          c2 as VNodeArrayChildren,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      }
    }
  }
}

普通元素节点的子节点有三种情况:

子节点类型 例子
数组子节点
  • 1
  • 1
  • 1
文本子节点
文本
空子节点

patchChildren针对这三种情况进行分别处理, 9种情况:

行-旧节点,列-新节点 数组子节点 文本子节点 空子节点
数组子节点 diff比对 卸载数组节点,设置文本 卸载数组节点
文本子节点 将文本节点替换为数组节点 文本替换 去掉文本节点
空子节点 挂载数组子节点 设置文本  
没有v-key数组子节点的比对patchUnkeyedChildren
const patchUnkeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  c1 = c1 || EMPTY_ARR
  c2 = c2 || EMPTY_ARR
  const oldLength = c1.length
  const newLength = c2.length
  const commonLength = Math.min(oldLength, newLength)
  let i
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    patch(
      c1[i],
      nextChild,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  }
  if (oldLength > newLength) {
    // remove old
    unmountChildren(
      c1,
      parentComponent,
      parentSuspense,
      true,
      false,
      commonLength
    )
  } else {
    // mount new
    mountChildren(
      c2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized,
      commonLength
    )
  }
}

这个方法逻辑简单:先将两个数组从前往后逐个patch,当某个数组对比完成后,如果新的子节点数组还有元素就将剩下的节点进行mountChildren挂载,如果是旧节点有剩余的则unmountChildren卸载。

这个方法简单,但是效率比较低,。我们接下来分析高效的比对方法。

v-key数组子节点的高效比对patchKeyedChildren

这个逻辑很长,我们分拆来分析:

1. 同步头部节点

旧节点 (a b) c

新节点 (a b) d e

先从两个数组的头部开始比对,如果节点是相同的VNode类型,执行patch更新节点,否则同步结束。
上面例子中第三个节点的时 同步头部节点这一逻辑结束。

let i = 0
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index
let e2 = l2 - 1 // next ending index

// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  i++
}

2. 同步尾部节点

// a (b c)

// d e (b c)

先从两个数组的尾部开始比对,如果节点是相同的VNode类型,执行patch更新节点,否则同步尾部结束。
上面例子中倒数第三个节点的时 同步尾部部节点这一逻辑结束。

// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
  const n1 = c1[e1]
  const n2 = (c2[e2] = optimized
    ? cloneIfMounted(c2[e2] as VNode)
    : normalizeVNode(c2[e2]))
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else {
    break
  }
  e1--
  e2--
}
3. 新子节点数组有需要添加的新子节点

(a b)

(a b) c

if (i > e1) { // 旧子节点到了尾部
  if (i <= e2) { // 新子节点剩余节点
    const nextPos = e2 + 1
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
    // 逐个挂载
    while (i <= e2) {
      patch(
        null,
        (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i])),
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )
      i++
    }
  }
}
4. 旧子节点数组有需要卸载子节点

(a b) c (d e)

(a b) (d e)

else if (i > e2) {
  while (i <= e1) {
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}
5. 处理未知子序列

// [i … e1 + 1]: a b [c d j] f g

// [i … e2 + 1]: a b [e d c h] f g

// i = 2, e1 = 4, e2 = 5

  • 1.建立新子序列的索引图—未知新子序列的每个节点在新子序列中对应的索引值
// 5.1 build key:index map for newChildren
const keyToNewIndexMap: Map = new Map()
for (i = s2; i <= e2; i++) {
  const nextChild = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))
  if (nextChild.key != null) {
    if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
      warn(
        `Duplicate keys found during update:`,
        JSON.stringify(nextChild.key),
        `Make sure keys are unique.`
      )
    }
    keyToNewIndexMap.set(nextChild.key, i)
  }
}

结果:

{
    {"e" => 2},
    {"d" => 3},
    {"c" => 4},
    {"h" => 5}
}
  • 2.遍历旧子序列,有相同的key就执行patch更新,并且移除不在新子序列中的节点,并且确定序列是否有排列顺序的变化。
  • 建一个newIndexToOldIndexMap数组,数组长度是未知新子序列的长度,每个元素的初始值为0,当最后处理完还是0,那说明这个节点是新添加的节点;
  • 正序遍历旧子序列查找旧子序列节点在新子序列中的索引,如果找不到说明新子序列中没有该节点,这个节点需要卸载;如果找到了,就将其在旧子序列中的索引更新到newIndexToOldIndexMap`中,索引加了1;
  • 利用maxNewIndexSoFar来计算新子节点的顺序是否有更换,如果有更换将moved设置为true;
  • 如果新子节点序列已经遍历完成,旧子节点还有元素,直接卸载节点即可。
let j
let patched = 0
const toBePatched = e2 - s2 + 1
let moved = false
// used to track whether any node has moved
let maxNewIndexSoFar = 0
// works as Map
// Note that oldIndex is offset by +1
// and oldIndex = 0 is a special value indicating the new node has
// no corresponding old node.
// used for determining longest stable subsequence
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

for (i = s1; i <= e1; i++) {
  const prevChild = c1[i]
  if (patched >= toBePatched) {
    // all new children have been patched so this can only be a removal
    unmount(prevChild, parentComponent, parentSuspense, true)
    continue
  }
  let newIndex
  if (prevChild.key != null) {
    newIndex = keyToNewIndexMap.get(prevChild.key)
  } else {
    // key-less node, try to locate a key-less node of the same type
    for (j = s2; j <= e2; j++) {
      if (
        newIndexToOldIndexMap[j - s2] === 0 &&
        isSameVNodeType(prevChild, c2[j] as VNode)
      ) {
        newIndex = j
        break
      }
    }
  }
  if (newIndex === undefined) {
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
    newIndexToOldIndexMap[newIndex - s2] = i + 1
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex
    } else {
      moved = true
    }
    patch(
      prevChild,
      c2[newIndex] as VNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
    patched++
  }
}

结果:

newIndexToOldIndexMap: [0, 4, 3, 0] // d在旧子节点的索引是3,c在旧子节点的所以为2,e,h都是新增的节点 
moved: true
  • 3.移动和挂载子节点
  • 如果movedtrue, 则求解最大递增子序列increasingNewIndexSequence,最大递增子序列能够让移动的次数最小化;
    本例子中得到的的值为[0, 2],表示newIndexToOldIndexMap对应的0, 3
  • 倒序遍历新子节点,如果newIndexToOldIndexMap对应的索引的值为0,说明新增的节点,进行挂载;
  • 倒序遍历新子节点,如果碰到了不是increasingNewIndexSequence中的对应索引下元素的值值则需要移动,否则不进行操作;

我们用上面的例子解释下:

循环次数 新子节点索引 新子节点 increasingNewIndexSequence的索引 increasingNewIndexSequence[索引] newIndexToOldIndexMap[循环次数] 进行的操作
1 5 h 1 3 0 直接挂载h
2 4 c 1 3 3 c不进行操作,将increasingNewIndexSequence的索引-1,变为0
3 3 d 0 0 4 取到元素d,移动到c前面
4 2 e 0 0 0 直接挂载e
总结

Vue 3.0组件的更新流程和diff算法详解_第5张图片

你可能感兴趣的:(Vue,vue.js,前端)