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

884 阅读10分钟
Vue 3.0 系列文章

Vue 3.0组件的渲染流程

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

揭开Vue3.0 setup函数的神秘面纱

Vue 3.0 Props的初始化和更新流程的细节分析

Vue3.0 响应式实现原理分析

Vue 3.0 计算属性的实现原理分析

Vue3.0 常用响应式API的使用和原理分析(一)

Vue3.0 常用响应式API的使用和原理分析(二)

Vue 3.0 Provide和Inject实现共享数据

Vue 3.0 Teleport的使用和原理分析

Vue3侦听器和异步任务调度, 其中有个神秘角色

Vue3.0 指令

Vue3.0 内置指令的底层细节分析

Vue3.0 的事件绑定的实现逻辑是什么

Vue3.0 的双向绑定是如何实现的

Vue3.0的插槽是如何实现的?

探究Vue3.0的keep-alive和动态组件的实现逻辑

Vuex 4.x

Vue Router 4 的使用,一篇文章给你讲透彻

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

案例

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

  • App组件中有一个Hello组件,并且赋值msg这个prop值给Hello组件;
  • msgVue 3时,App组件中有li标签数组显示vue3.feature,即显示Vue 3的新特性,当msgVue 2时则不显示;
  • App组件中有一个按钮切换msg的值。
App.vue
<template>
  <HelloWorld :msg="msg" />
  <h1>App 组件显示:</h1>
  <ul>
    <li v-for="item in vue3.feature" v-bind:key="item">{{ item }}</li>
  </ul>
  <button @click="changeMsg">切换</button>
</template>

<script lang="ts">
import { defineComponent, reactive, ref } from "vue";
import HelloWorld from "./components/HelloWorld.vue";

export default defineComponent({
  name: "App",
  components: {
    HelloWorld,
  },
  setup() {
    const msg = ref("Vue 2");

    const feature3: string[] = ["reactive", "composition api", "setup", "toRef", "Teleport"];
    const feature2: string[] = ["reactive", "option api"];
    const vue3 = reactive({ feature: feature2});
    let current = 0;

    const changeMsg = () => {
      if (current == 0) {
        msg.value = "Vue 3";
        vue3.feature = feature3;
        current = 1;
      } else {
        msg.value = "Vue 2";
        vue3.feature = feature2;
        current = 0;
      }
    };

    return {
      msg,
      vue3,
      changeMsg,
    };
  },
});
</script>

Hello.vue
<template>
  <h1>Hello 组件显示:{{ msg }}</h1>
</template>

<script lang="ts">
import { ref, defineComponent } from "vue";
export default defineComponent({
  name: "HelloWorld",
  props: {
    msg: {
      type: String,
      required: true,
    },
  },
  setup: () => {

  },
});
</script>
效果图如下

效果

副作用渲染函数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的数据进行更新; 差异
  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流程。 处理逻辑 本例中第一个子节点是组件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值; next赋值
  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
        )
      }
    }
  }
}

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

子节点类型例子
数组子节点<ul><li>1</li><li>1</li><li>1</li></ul>
文本子节点<div>文本</div>
空子节点<img />
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<string | number, number> = 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<newIndex, oldIndex>
// 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[循环次数]进行的操作
15h130直接挂载h
24c133c不进行操作,将increasingNewIndexSequence的索引-1,变为0
33d004取到元素d,移动到c前面
42e000直接挂载e

总结

详解